Files
msd-core/tests/installer-migration-pi-extension-ext.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

233 lines
8.5 KiB
JavaScript

// docs-guard-exempt: docs/installer-migrations.md is cited only in comments as design rationale; never read.
'use strict';
/**
* TDD tests for installer migration 006:
* 2026-07-20-pi-extension-cjs-to-js (#2470)
*
* #2470 renamed pi's installed native extension from `extensions/msd.cjs` to
* `extensions/msd.js`, because pi's own auto-discovery filter
* (`isExtensionFile()` in @earendil-works/pi-coding-agent) accepts only `.ts`
* and `.js` and silently skips everything else. Installs made before that fix
* still carry the stale, permanently-inert `extensions/msd.cjs`; the installer
* writes the new `.js` alongside it and would otherwise orphan the old file
* forever (it drops out of the manifest, so uninstall never removes it).
*
* This migration retires the stale copy. Coverage follows the matrix in
* docs/installer-migrations.md#authoring-workflow:
* 1. metadata / authoring-guard conformance
* 2. stale file absent -> empty plan (idempotent, fresh installs)
* 3. stale file managed-pristine -> remove-managed
* 4. stale file managed-modified -> backup-and-remove (never silent delete)
* 5. stale file unknown -> NO action (never remove unowned files)
* 6. the replacement msd.js and neighbouring user files are never touched
* 7. runtime scoping: pi only
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const migration = require('../msd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs');
const {
classifyArtifact: realClassifyArtifact,
readInstallManifest,
} = require('../msd-core/bin/lib/installer-migrations.cjs');
const STALE_REL = 'extensions/msd.cjs';
const CURRENT_REL = 'extensions/msd.js';
function createTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'msd-migration-006-test-'));
}
function cleanup(dir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup in migration test; no helpers import available
fs.rmSync(dir, { recursive: true, force: true });
}
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, 'msd-file-manifest.json'),
JSON.stringify(
{
version: '1.7.0',
timestamp: '2026-07-20T00:00:00.000Z',
mode: 'full',
files,
},
null,
2,
),
'utf8',
);
}
function makePlanCtx(configDir) {
const manifest = readInstallManifest(configDir);
return {
configDir,
classifyArtifact: (relPath) => realClassifyArtifact(configDir, relPath, manifest),
};
}
/** sha256 hash in the manifest's own format, so a file reads as pristine. */
function hashOf(root, relPath) {
const crypto = require('node:crypto');
return crypto
.createHash('sha256')
.update(fs.readFileSync(path.join(root, relPath)))
.digest('hex');
}
// ---------------------------------------------------------------------------
// 1. Metadata
// ---------------------------------------------------------------------------
describe('migration 006 metadata', () => {
test('exports a single migration object with the required authoring fields', () => {
assert.equal(typeof migration, 'object');
assert.equal(typeof migration.id, 'string');
assert.ok(migration.id.length > 0, 'id must be non-empty');
assert.equal(typeof migration.title, 'string');
assert.equal(typeof migration.description, 'string');
assert.equal(typeof migration.introducedIn, 'string');
assert.ok(Array.isArray(migration.scopes), 'scopes must be an array');
assert.ok(migration.scopes.includes('global'), 'scopes must include global');
assert.ok(migration.scopes.includes('local'), 'scopes must include local');
assert.strictEqual(migration.destructive, true);
assert.equal(typeof migration.plan, 'function');
});
test('is scoped to pi only (no other runtime installs this artifact)', () => {
assert.ok(Array.isArray(migration.runtimes), 'runtimes must be an explicit array');
assert.deepEqual(migration.runtimes, ['pi']);
});
test('id carries the expected date prefix and names the retired artifact', () => {
assert.ok(
migration.id.startsWith('2026-07-20-'),
`id should start with the date prefix, got: ${migration.id}`,
);
assert.match(migration.id, /pi-extension/);
});
});
// ---------------------------------------------------------------------------
// 2-5. plan() behaviour by classification
// ---------------------------------------------------------------------------
describe('migration 006 plan()', () => {
test('emits no actions when the stale extension is absent (fresh install, idempotent)', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, CURRENT_REL, '// current extension\n');
writeManifest(dir, { [CURRENT_REL]: hashOf(dir, CURRENT_REL) });
const actions = migration.plan(makePlanCtx(dir));
assert.deepEqual(actions, [], 'no stale file -> no actions');
});
test('emits remove-managed for a pristine manifest-managed stale extension', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, STALE_REL, '// stale pre-#2470 extension\n');
writeManifest(dir, { [STALE_REL]: hashOf(dir, STALE_REL) });
const actions = migration.plan(makePlanCtx(dir));
assert.equal(actions.length, 1, `expected exactly one action, got ${JSON.stringify(actions)}`);
assert.equal(actions[0].type, 'remove-managed');
assert.equal(actions[0].relPath, STALE_REL);
assert.ok(
typeof actions[0].ownershipEvidence === 'string' && actions[0].ownershipEvidence.length > 0,
'destructive actions require ownershipEvidence (authoring guard)',
);
assert.ok(
typeof actions[0].reason === 'string' && actions[0].reason.length > 0,
'action must carry a human-readable reason for dry-run output',
);
});
test('emits backup-and-remove when the user locally modified the stale extension', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, STALE_REL, '// stale pre-#2470 extension\n');
// Manifest records a DIFFERENT hash -> managed-modified.
writeManifest(dir, { [STALE_REL]: 'a'.repeat(64) });
const actions = migration.plan(makePlanCtx(dir));
assert.equal(actions.length, 1);
assert.equal(
actions[0].type,
'backup-and-remove',
'a locally patched managed file must be backed up, never silently deleted',
);
assert.equal(actions[0].relPath, STALE_REL);
assert.ok(actions[0].ownershipEvidence);
});
test('emits NO action for an unknown (non-manifest) msd.cjs — never remove unowned files', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
// File present but absent from the manifest -> classification 'unknown'.
writeFile(dir, STALE_REL, '// hand-placed by the user\n');
writeManifest(dir, {});
const actions = migration.plan(makePlanCtx(dir));
assert.deepEqual(
actions,
[],
'unknown files are preserved (docs/installer-migrations.md#ownership)',
);
});
test('never targets the replacement extension or neighbouring user files', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, STALE_REL, '// stale\n');
writeFile(dir, CURRENT_REL, '// current\n');
writeFile(dir, 'extensions/my-own-extension.js', '// user-authored\n');
writeManifest(dir, {
[STALE_REL]: hashOf(dir, STALE_REL),
[CURRENT_REL]: hashOf(dir, CURRENT_REL),
});
const actions = migration.plan(makePlanCtx(dir));
const targeted = actions.map((a) => a.relPath);
assert.deepEqual(targeted, [STALE_REL]);
assert.ok(!targeted.includes(CURRENT_REL), 'must not remove the replacement extension');
assert.ok(
!targeted.includes('extensions/my-own-extension.js'),
"must not touch a user's own extension sitting in the same directory",
);
});
test('plan() does not mutate disk (planning is pure)', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, STALE_REL, '// stale\n');
writeManifest(dir, { [STALE_REL]: hashOf(dir, STALE_REL) });
migration.plan(makePlanCtx(dir));
assert.ok(
fs.existsSync(path.join(dir, STALE_REL)),
'plan() must not remove anything — the executor owns mutation',
);
});
});