Files
msd-core/tests/installer-migrations.test.cjs
2026-05-11 14:29:33 -04:00

463 lines
14 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const {
applyInstallerMigrationPlan,
discoverInstallerMigrations,
planInstallerMigrations,
readInstallState,
runInstallerMigrations,
writeInstallState,
} = require('../get-shit-done/bin/lib/installer-migrations.cjs');
function createTempInstall() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installer-migrations-'));
}
function cleanup(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
function writeFile(root, relPath, content) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
function writeManifest(root, files) {
fs.writeFileSync(
path.join(root, 'gsd-file-manifest.json'),
JSON.stringify({
version: '1.49.0',
timestamp: '2026-05-10T00:00:00.000Z',
mode: 'full',
files,
}, null, 2),
'utf8'
);
}
test('plans a pending migration against an unchanged managed file', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
},
],
},
],
now: () => '2026-05-11T00:00:00.000Z',
});
assert.deepEqual(plan.pendingMigrationIds, ['2026-05-11-remove-old-hook']);
assert.equal(plan.blocked.length, 0);
assert.deepEqual(plan.actions, [
{
migrationId: '2026-05-11-remove-old-hook',
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
classification: 'managed-pristine',
originalHash: sha256('managed hook\n'),
currentHash: sha256('managed hook\n'),
},
]);
} finally {
cleanup(configDir);
}
});
test('plans backup before removal for a modified managed file', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
},
],
},
],
now: () => '2026-05-11T00:00:00.000Z',
});
assert.equal(plan.blocked.length, 0);
assert.equal(plan.actions.length, 1);
assert.equal(plan.actions[0].type, 'backup-and-remove');
assert.equal(plan.actions[0].classification, 'managed-modified');
assert.equal(plan.actions[0].originalHash, sha256('managed hook\n'));
assert.equal(plan.actions[0].currentHash, sha256('user changed hook\n'));
assert.equal(plan.actions[0].backupRelPath, 'gsd-migration-backups/2026-05-11-remove-old-hook/hooks/old-hook.js');
} finally {
cleanup(configDir);
}
});
test('blocks removal of unknown files by preserving them by default', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/custom-user-hook.js', 'user hook\n');
writeManifest(configDir, {});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/custom-user-hook.js',
reason: 'retired hook',
},
],
},
],
now: () => '2026-05-11T00:00:00.000Z',
});
assert.equal(plan.actions.length, 1);
assert.equal(plan.actions[0].type, 'preserve-user');
assert.equal(plan.actions[0].requestedType, 'remove-managed');
assert.equal(plan.actions[0].classification, 'unknown');
assert.deepEqual(plan.blocked, [plan.actions[0]]);
} finally {
cleanup(configDir);
}
});
test('applies an unblocked plan with a journal and install-state update', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
},
],
},
],
now: () => '2026-05-11T00:00:00.000Z',
});
const result = applyInstallerMigrationPlan({
configDir,
plan,
now: () => '2026-05-11T00:00:01.000Z',
});
assert.equal(fs.existsSync(path.join(configDir, 'hooks/old-hook.js')), false);
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
assert.equal(result.journalRelPath, 'gsd-migration-journal/2026-05-11T00-00-01-000Z.json');
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
assert.deepEqual(journal.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
assert.equal(journal.actions[0].relPath, 'hooks/old-hook.js');
const state = readInstallState(configDir);
assert.deepEqual(state.appliedMigrations.map((entry) => entry.id), ['2026-05-11-remove-old-hook']);
} finally {
cleanup(configDir);
}
});
test('rolls back touched files and leaves state unchanged when apply fails', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = {
pendingMigrationIds: ['2026-05-11-remove-old-hook'],
blocked: [],
actions: [
{
migrationId: '2026-05-11-remove-old-hook',
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
classification: 'managed-pristine',
originalHash: sha256('managed hook\n'),
currentHash: sha256('managed hook\n'),
},
{
migrationId: '2026-05-11-remove-old-hook',
type: 'unsupported-test-action',
relPath: 'hooks/other.js',
reason: 'force failure',
classification: 'managed-pristine',
originalHash: null,
currentHash: null,
},
],
};
assert.throws(
() => applyInstallerMigrationPlan({
configDir,
plan,
now: () => '2026-05-11T00:00:02.000Z',
}),
/unsupported migration action type/
);
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n');
assert.deepEqual(readInstallState(configDir).appliedMigrations, []);
assert.equal(fs.existsSync(path.join(configDir, 'gsd-migration-journal', '2026-05-11T00-00-02-000Z.json')), false);
} finally {
cleanup(configDir);
}
});
test('reports rollback restore failures instead of swallowing them', () => {
const configDir = createTempInstall();
const originalCopyFileSync = fs.copyFileSync;
try {
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = {
blocked: [],
actions: [
{
migrationId: '2026-05-11-remove-old-hook',
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
classification: 'managed-pristine',
originalHash: sha256('managed hook\n'),
currentHash: sha256('managed hook\n'),
},
{
migrationId: '2026-05-11-remove-old-hook',
type: 'unsupported-test-action',
relPath: 'hooks/other.js',
reason: 'force failure',
classification: 'managed-pristine',
originalHash: null,
currentHash: null,
},
],
};
fs.copyFileSync = (src, dest) => {
if (String(src).includes('2026-05-11T00-00-04-000Z-rollback')) {
throw new Error('simulated rollback copy failure');
}
return originalCopyFileSync(src, dest);
};
assert.throws(
() => applyInstallerMigrationPlan({
configDir,
plan,
now: () => '2026-05-11T00:00:04.000Z',
}),
(error) => {
assert.match(error.message, /rollback incomplete/);
assert.equal(error.rollbackFailures.length, 1);
assert.equal(error.rollbackFailures[0].relPath, 'hooks/old-hook.js');
return true;
}
);
} finally {
fs.copyFileSync = originalCopyFileSync;
cleanup(configDir);
}
});
test('skips migration records already present in install state', () => {
const configDir = createTempInstall();
try {
writeManifest(configDir, {});
writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: [
{
id: '2026-05-11-remove-old-hook',
appliedAt: '2026-05-11T00:00:00.000Z',
journal: 'gsd-migration-journal/prior.json',
},
],
});
const plan = planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
description: 'Remove retired hook',
plan: () => {
throw new Error('already-applied migration planner must not run');
},
},
],
now: () => '2026-05-11T00:00:03.000Z',
});
assert.deepEqual(plan.pendingMigrationIds, []);
assert.deepEqual(plan.actions, []);
assert.deepEqual(plan.blocked, []);
} finally {
cleanup(configDir);
}
});
test('discovers migration records from a directory in filename order', () => {
const configDir = createTempInstall();
try {
const migrationsDir = path.join(configDir, 'migrations');
fs.mkdirSync(migrationsDir, { recursive: true });
fs.writeFileSync(
path.join(migrationsDir, '002-second.cjs'),
"module.exports = { id: 'second', description: 'second', plan: () => [] };\n",
'utf8'
);
fs.writeFileSync(
path.join(migrationsDir, '001-first.cjs'),
"module.exports = { id: 'first', description: 'first', plan: () => [] };\n",
'utf8'
);
const migrations = discoverInstallerMigrations({ migrationsDir });
assert.deepEqual(migrations.map((migration) => migration.id), ['first', 'second']);
} finally {
cleanup(configDir);
}
});
test('rejects migration actions that escape the install root', () => {
const configDir = createTempInstall();
try {
writeManifest(configDir, {});
assert.throws(
() => planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-bad-path',
description: 'Bad path',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/../../outside.js',
reason: 'bad path',
},
],
},
],
}),
/relPath must stay inside configDir/
);
} finally {
cleanup(configDir);
}
});
test('rejects migration actions that normalize to the install root', () => {
const configDir = createTempInstall();
try {
writeManifest(configDir, {});
for (const relPath of ['.', 'hooks/..']) {
assert.throws(
() => planInstallerMigrations({
configDir,
migrations: [
{
id: `2026-05-11-bad-path-${relPath.replace(/[^a-z0-9]/gi, '-')}`,
description: 'Bad path',
plan: () => [
{
type: 'remove-managed',
relPath,
reason: 'bad path',
},
],
},
],
}),
/relPath must stay inside configDir/
);
}
} finally {
cleanup(configDir);
}
});
test('runs discovered installer migrations against manifest-managed legacy orphan files', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/statusline.js', 'legacy managed hook\n');
writeFile(configDir, 'hooks/custom.js', 'custom hook\n');
writeManifest(configDir, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
const result = runInstallerMigrations({
configDir,
now: () => '2026-05-11T00:00:05.000Z',
});
assert.equal(fs.existsSync(path.join(configDir, 'hooks/statusline.js')), false);
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/custom.js'), 'utf8'), 'custom hook\n');
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-legacy-orphan-files']);
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['2026-05-11-legacy-orphan-files']);
} finally {
cleanup(configDir);
}
});