281 lines
13 KiB
JavaScript
281 lines
13 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs, { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, readdirSync, statSync, linkSync } from 'node:fs';
|
|
import { syncBuiltinESMExports } from 'node:module';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, basename } from 'node:path';
|
|
import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES, reconcileManifest, reconcileProfile } from './reconcile-profile.mjs';
|
|
import { initProfile } from './init-profile.mjs';
|
|
|
|
const old = name => 'file:/usr/local/lib/dsh-mgr/' + name + '-0.0.1-rc.1+build.2.tgz';
|
|
test('runtime-7.9 plugin references migrate to runtime-7.11 without changing bundle choices', t => {
|
|
const bundles = ['dsh-web-mobile', 'dsh-chat-manager'];
|
|
const f = fixture(t, {
|
|
dependencies: {
|
|
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.1.0.tgz',
|
|
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.2.tgz',
|
|
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.16-dsh-mgr.1.tgz',
|
|
},
|
|
dsh: { profile: { bundles } },
|
|
});
|
|
const result = f.run();
|
|
assert.equal(result.changed, true);
|
|
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
|
|
const current = JSON.parse(readFileSync(f.path));
|
|
for (const name of Object.keys(current.dependencies)) assert.equal(current.dependencies[name], BUILTIN_DEPENDENCIES[name]);
|
|
assert.deepEqual(current.dsh.profile.bundles, bundles);
|
|
assert.deepEqual(f.run(), { changed: false });
|
|
});
|
|
test('runtime-7.10 plugin references migrate to runtime-7.11 without changing bundle choices', t => {
|
|
const bundles = ['dsh-web-mobile', 'dsh-chat-manager'];
|
|
const f = fixture(t, {
|
|
dependencies: {
|
|
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz',
|
|
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.15.tgz',
|
|
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.18-dsh-mgr.1.tgz',
|
|
},
|
|
dsh: { profile: { bundles } },
|
|
});
|
|
const result = f.run();
|
|
assert.equal(result.changed, true);
|
|
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
|
|
const current = JSON.parse(readFileSync(f.path));
|
|
for (const name of Object.keys(current.dependencies)) assert.equal(current.dependencies[name], BUILTIN_DEPENDENCIES[name]);
|
|
assert.deepEqual(current.dsh.profile.bundles, bundles);
|
|
assert.deepEqual(f.run(), { changed: false });
|
|
});
|
|
function fixture(t, manifest) {
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-reconcile-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const profile = join(root, 'profiles/web');
|
|
const archiveDirectory = join(root, 'archives');
|
|
mkdirSync(profile, { recursive: true });
|
|
mkdirSync(archiveDirectory);
|
|
for (const reference of [...Object.values(BUILTIN_DEPENDENCIES), ...Object.values(BUILTIN_OVERRIDES)]) {
|
|
writeFileSync(join(archiveDirectory, basename(reference)), 'fixture');
|
|
}
|
|
const path = join(profile, 'package.json');
|
|
const original = typeof manifest === 'string' ? manifest : JSON.stringify(manifest, null, 4) + '\n';
|
|
writeFileSync(path, original);
|
|
return { root, profile, path, original, archiveDirectory, run: () => reconcileProfile(profile, { archiveDirectory }) };
|
|
}
|
|
|
|
test('migrates all sections, backs up exact bytes, replaces atomically with 0600, and is idempotent', t => {
|
|
const f = fixture(t, {
|
|
dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') },
|
|
devDependencies: { 'dsh-chat-manager': old('dsh-chat-manager') },
|
|
optionalDependencies: { 'dsh-free-search': old('dsh-free-search') },
|
|
dsh: { profile: { bundles: [] } },
|
|
});
|
|
const originalLink = join(f.root, 'original-link');
|
|
linkSync(f.path, originalLink);
|
|
const first = f.run();
|
|
assert.equal(first.changed, true);
|
|
assert.equal(readFileSync(first.backupPath, 'utf8'), f.original);
|
|
assert.equal(readFileSync(originalLink, 'utf8'), f.original);
|
|
assert.equal(statSync(f.path).mode & 0o777, 0o600);
|
|
assert.equal(statSync(first.backupPath).mode & 0o777, 0o600);
|
|
const current = JSON.parse(readFileSync(f.path));
|
|
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
for (const name of Object.keys(current[section])) assert.equal(current[section][name], BUILTIN_DEPENDENCIES[name]);
|
|
}
|
|
assert.deepEqual(current.pnpm.overrides, BUILTIN_OVERRIDES);
|
|
assert.deepEqual(current.dsh.profile.bundles, []);
|
|
const before = statSync(f.path);
|
|
assert.deepEqual(f.run(), { changed: false });
|
|
assert.equal(statSync(f.path).ino, before.ino);
|
|
assert.equal(statSync(f.path).mtimeMs, before.mtimeMs);
|
|
assert.equal(readdirSync(f.profile).length, 3);
|
|
assert.equal(readFileSync(join(f.profile, '.dsh-lockfile-pending'), 'utf8'), 'builtin profile migration\n');
|
|
writeFileSync(f.path, f.original);
|
|
assert.notEqual(f.run().backupPath, first.backupPath);
|
|
assert.equal(readFileSync(first.backupPath, 'utf8'), f.original);
|
|
});
|
|
|
|
function withSyncTrace(f, callback, failAt) {
|
|
const originals = Object.fromEntries(
|
|
['openSync', 'writeFileSync', 'fsyncSync', 'closeSync', 'renameSync'].map(name => [name, fs[name]]),
|
|
);
|
|
const descriptors = new Map();
|
|
const events = [];
|
|
let directoryOpens = 0;
|
|
fs.openSync = (path, ...args) => {
|
|
const fd = originals.openSync(path, ...args);
|
|
const target = path === f.profile ? `directory-${++directoryOpens}`
|
|
: String(path).startsWith(`${f.path}.backup-`) ? 'backup'
|
|
: String(path).startsWith(`${f.path}.tmp-`) ? 'temporary' : undefined;
|
|
if (target) {
|
|
descriptors.set(fd, target);
|
|
events.push(`open:${target}`);
|
|
}
|
|
return fd;
|
|
};
|
|
for (const [name, operation] of [['writeFileSync', 'write'], ['fsyncSync', 'fsync'], ['closeSync', 'close']]) {
|
|
fs[name] = (fd, ...args) => {
|
|
const target = descriptors.get(fd);
|
|
if (target) {
|
|
const event = `${operation}:${target}`;
|
|
events.push(event);
|
|
if (event === failAt) throw new Error(`injected ${event} failure`);
|
|
}
|
|
const result = originals[name](fd, ...args);
|
|
if (name === 'closeSync') descriptors.delete(fd);
|
|
return result;
|
|
};
|
|
}
|
|
fs.renameSync = (source, destination) => {
|
|
assert.ok(source.startsWith(`${f.path}.tmp-`));
|
|
assert.equal(destination, f.path);
|
|
events.push('rename:manifest');
|
|
return originals.renameSync(source, destination);
|
|
};
|
|
syncBuiltinESMExports();
|
|
try {
|
|
callback(events);
|
|
} finally {
|
|
Object.assign(fs, originals);
|
|
syncBuiltinESMExports();
|
|
}
|
|
}
|
|
|
|
const durableWriteOrder = [
|
|
'open:backup', 'write:backup', 'fsync:backup', 'close:backup',
|
|
'open:directory-1', 'fsync:directory-1', 'close:directory-1',
|
|
'open:temporary', 'write:temporary', 'fsync:temporary', 'close:temporary',
|
|
'open:directory-2', 'fsync:directory-2', 'close:directory-2',
|
|
'rename:manifest',
|
|
'open:directory-3', 'fsync:directory-3', 'close:directory-3',
|
|
];
|
|
|
|
test('syncs backup and directory before replacement, then syncs the renamed manifest directory', t => {
|
|
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
|
|
withSyncTrace(f, events => {
|
|
const result = f.run();
|
|
assert.equal(result.changed, true);
|
|
assert.deepEqual(events, durableWriteOrder);
|
|
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
|
|
assert.equal(JSON.parse(readFileSync(f.path)).dependencies['dsh-web-mobile'], BUILTIN_DEPENDENCIES['dsh-web-mobile']);
|
|
});
|
|
});
|
|
|
|
for (const target of ['backup', 'directory-1', 'temporary']) {
|
|
test(`${target} fsync failure preserves the original manifest and does not rename`, t => {
|
|
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
|
|
const before = statSync(f.path);
|
|
const failAt = `fsync:${target}`;
|
|
withSyncTrace(f, events => {
|
|
assert.throws(f.run, { message: `injected ${failAt} failure` });
|
|
assert.deepEqual(events, [
|
|
...durableWriteOrder.slice(0, durableWriteOrder.indexOf(failAt) + 1),
|
|
`close:${target}`,
|
|
]);
|
|
}, failAt);
|
|
assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
assert.equal(statSync(f.path).ino, before.ino);
|
|
assert.equal(statSync(f.path).mtimeMs, before.mtimeMs);
|
|
const backups = readdirSync(f.profile).filter(name => name.startsWith('package.json.backup-'));
|
|
assert.equal(backups.length, 1);
|
|
assert.equal(readFileSync(join(f.profile, backups[0]), 'utf8'), f.original);
|
|
assert.deepEqual(readdirSync(f.profile).sort(), ['package.json', ...backups].sort());
|
|
});
|
|
}
|
|
|
|
test('preserves deleted plugins, disabled bundles, custom dependencies and overrides', t => {
|
|
const custom = {
|
|
dependencies: { 'dsh-chat-manager': old('dsh-chat-manager'), 'dsh-web-mobile': '^3.0.0', other: old('other') },
|
|
devDependencies: { 'dsh-free-search': 'file:/custom/dsh-free-search-0.1.0.tgz' },
|
|
optionalDependencies: { 'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/not-the-same-1.0.0.tgz' },
|
|
pnpm: { overrides: { 'dsh-chat-manager>clsx': '^2.0.0', unrelated: 'custom' }, other: true },
|
|
dsh: { profile: { bundles: ['user-plugin'] } },
|
|
};
|
|
const f = fixture(t, custom);
|
|
writeFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'custom workspace');
|
|
writeFileSync(join(f.profile, 'cordis.patch.yml'), 'custom patch');
|
|
f.run();
|
|
const expected = structuredClone(custom);
|
|
expected.dependencies['dsh-chat-manager'] = BUILTIN_DEPENDENCIES['dsh-chat-manager'];
|
|
assert.deepEqual(JSON.parse(readFileSync(f.path)), expected);
|
|
assert.equal(readFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'utf8'), 'custom workspace');
|
|
assert.equal(readFileSync(join(f.profile, 'cordis.patch.yml'), 'utf8'), 'custom patch');
|
|
});
|
|
|
|
test('strict archive matching leaves non-semver references untouched', () => {
|
|
for (const version of ['latest', '01.0.0', '1.0', '1.0.0-01', '1.0.0.tgz/extra']) {
|
|
const input = { dependencies: { 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-' + version + '.tgz' } };
|
|
assert.equal(reconcileManifest(input).changed, false);
|
|
}
|
|
});
|
|
|
|
test('current defaults and user-only manifests remain byte-identical', t => {
|
|
for (const manifest of [
|
|
{ dependencies: BUILTIN_DEPENDENCIES, pnpm: { overrides: BUILTIN_OVERRIDES } },
|
|
{ dependencies: { 'dsh-chat-manager': '^1.0.0' }, custom: true },
|
|
{ dsh: { profile: { bundles: [] } } },
|
|
]) {
|
|
const f = fixture(t, manifest);
|
|
assert.equal(f.run().changed, false);
|
|
assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
assert.deepEqual(readdirSync(f.profile), ['package.json']);
|
|
}
|
|
});
|
|
|
|
test('missing archives fail before writes, even when already current', t => {
|
|
for (const reference of [old('dsh-web-mobile'), BUILTIN_DEPENDENCIES['dsh-web-mobile']]) {
|
|
const f = fixture(t, { dependencies: { 'dsh-web-mobile': reference } });
|
|
rmSync(join(f.archiveDirectory, basename(BUILTIN_DEPENDENCIES['dsh-web-mobile'])));
|
|
assert.throws(f.run, /ENOENT/);
|
|
assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
assert.deepEqual(readdirSync(f.profile), ['package.json']);
|
|
}
|
|
});
|
|
|
|
test('invalid JSON and malformed sections fail without changing original', t => {
|
|
for (const input of ['{ broken', 'null', '[]', JSON.stringify({ dependencies: [] }), JSON.stringify({ pnpm: { overrides: null } })]) {
|
|
const f = fixture(t, input);
|
|
assert.throws(f.run);
|
|
assert.equal(readFileSync(f.path, 'utf8'), input);
|
|
assert.deepEqual(readdirSync(f.profile), ['package.json']);
|
|
}
|
|
});
|
|
|
|
test('rejects manifest/workspace symlinks, dangling links and directories', t => {
|
|
for (const name of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', '.dsh-lockfile-pending']) {
|
|
for (const kind of ['symlink', 'dangling', 'directory']) {
|
|
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
|
|
const target = join(f.root, 'outside');
|
|
if (kind === 'symlink') writeFileSync(target, f.original);
|
|
const path = join(f.profile, name);
|
|
rmSync(path, { force: true });
|
|
if (kind === 'directory') mkdirSync(path);
|
|
else symlinkSync(target, path);
|
|
const before = readdirSync(f.profile);
|
|
assert.throws(f.run, /unsafe profile file/);
|
|
assert.deepEqual(readdirSync(f.profile), before);
|
|
if (kind === 'symlink') assert.equal(readFileSync(target, 'utf8'), f.original);
|
|
if (name !== 'package.json') assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('rejects symlinked profile and ancestors', t => {
|
|
const f = fixture(t, '{}');
|
|
const link = join(f.root, 'linked');
|
|
symlinkSync(join(f.root, 'profiles'), link);
|
|
assert.throws(() => reconcileProfile(join(link, 'web'), { archiveDirectory: f.archiveDirectory }), /unsafe profile directory/);
|
|
const direct = join(f.root, 'direct');
|
|
symlinkSync(f.profile, direct);
|
|
assert.throws(() => reconcileProfile(direct), /unsafe profile directory/);
|
|
});
|
|
|
|
test('init migrates before restoring workspace and rejects unsafe workspace before writes', t => {
|
|
const f = fixture(t, { optionalDependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
|
|
const run = () => initProfile(f.root, { archiveDirectory: f.archiveDirectory });
|
|
symlinkSync(join(f.root, 'missing'), join(f.profile, 'pnpm-workspace.yaml'));
|
|
assert.throws(run, /unsafe profile file/);
|
|
assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
rmSync(join(f.profile, 'pnpm-workspace.yaml'));
|
|
run();
|
|
assert.equal(JSON.parse(readFileSync(f.path)).optionalDependencies['dsh-web-mobile'], BUILTIN_DEPENDENCIES['dsh-web-mobile']);
|
|
assert.match(readFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'utf8'), /autoInstallPeers: false/);
|
|
});
|