import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { checkedSpawn } from "./audit.bench.mjs"; import { assertLatest, collectPackageVersions, compareVersions, createReport, extractCandidates, extractCapabilities, parseChangelog, resolvePiInstallation, } from "./audit.mjs"; const changelog = `# Changelog ## [2.0.0] - 2026-07-13 ### Added - Added harmless sparkle output. ### Changed - Renamed \`oldCall()\` to \`newCall()\`. ### Fixed - Removed the obsolete \`legacyOption\` request field. - Fixed ordinary rendering noise. ## [1.5.0] - 2026-07-01 ### Breaking Changes - Replace \`alpha()\` with \`beta()\`. ## [1.0.0] - 2026-06-01 ### Added - Initial release. `; test("parses release sections newest first", () => { assert.deepEqual( parseChangelog(changelog).map(({ version }) => version), ["2.0.0", "1.5.0", "1.0.0"], ); }); test("extracts migration sections and keyword bullets in the version interval", () => { assert.deepEqual( extractCandidates(changelog, { afterVersion: "1.5.0", throughVersion: "2.0.0", }).map(({ section, text }) => [section, text]), [ ["Changed", "Renamed `oldCall()` to `newCall()`."], ["Fixed", "Removed the obsolete `legacyOption` request field."], ], ); }); const capabilitiesChangelog = `# Changelog ## [1.2.0] ### Added - Added a future capability. ## [1.1.0] ### New Features - Program status reporting through OSC 7501: working, blocked, done, or failed. ### Added - Added \`+name\` and \`-name\` entries to \`--tools\`. ### Changed - Status reports never contain prompts or model output; desktop notifications remain separate. ### Fixed - Fixed ordinary rendering noise. ## [1.0.4] ### Fixed - Fixed system prompt rules naming hidden tools; \`BuildSystemPromptOptions\` gains \`hiddenTools\`. ## [1.0.2] ### Added - Added \`samplingParamsByThinkingLevel\`. ## [1.0.1] ### Added - Added an older capability. `; test("collects native features and Fixed API additions without migration keywords", () => { const capabilities = extractCapabilities(capabilitiesChangelog, { afterVersion: "1.0.2", throughVersion: "1.1.0", }); assert.deepEqual( capabilities.map(({ section }) => section), ["New Features", "Added", "Changed", "Fixed", "Fixed", "Added"], ); for (const shape of [ "OSC 7501", "+name", "hiddenTools", "samplingParamsByThinkingLevel", ]) assert.ok( capabilities.some(({ text }) => text.includes(shape)), shape, ); assert.ok( capabilities.some(({ text }) => text.includes("ordinary rendering noise")), ); }); test("includes the repository-resolved release but excludes older and future capabilities", () => { const capabilities = extractCapabilities(capabilitiesChangelog, { afterVersion: "1.0.2", throughVersion: "1.1.0", }); assert.deepEqual( [...new Set(capabilities.map(({ version }) => version))], ["1.1.0", "1.0.4", "1.0.2"], ); assert.deepEqual( extractCapabilities(capabilitiesChangelog, { afterVersion: "1.1.0", throughVersion: "1.1.0", }).map(({ section }) => section), ["New Features", "Added", "Changed", "Fixed"], ); }); test("keeps partial-replacement evidence despite observable behavior differences", () => { const capabilities = extractCapabilities(capabilitiesChangelog, { afterVersion: "1.0.2", throughVersion: "1.1.0", }); assert.ok( capabilities.some(({ text }) => text.includes("desktop notifications remain separate"), ), ); }); test("rejects changelogs without release sections", () => { assert.throws(() => parseChangelog("# Changelog\n\nNothing yet."), /release/); }); test("compares semantic versions numerically", () => { assert.equal(compareVersions("0.80.10", "0.80.6"), 1); assert.equal(compareVersions("0.80.6", "0.80.6"), 0); assert.equal(compareVersions("0.79.9", "0.80.1"), -1); }); test("fails when installed Pi is not latest", () => { assert.doesNotThrow(() => assertLatest("0.80.6", "0.80.6")); assert.throws(() => assertLatest("0.80.5", "0.80.6"), /not latest/); }); test("resolves the installed Pi package from launcher and CLI paths", (context) => { const directory = mkdtempSync(path.join(tmpdir(), "pi-modernize-")); context.after(() => rmSync(directory, { recursive: true, force: true })); const packageRoot = path.join( directory, "node_modules", "@earendil-works", "pi-coding-agent", ); const cli = path.join(packageRoot, "dist", "cli.js"); const changelog = path.join(packageRoot, "CHANGELOG.md"); const launcher = path.join(directory, "pi.CMD"); mkdirSync(path.dirname(cli), { recursive: true }); writeFileSync( path.join(packageRoot, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: "0.85.0", bin: { pi: "dist/cli.js" }, }), ); writeFileSync(cli, ""); writeFileSync(changelog, ""); writeFileSync(launcher, ""); const expected = { cli, changelog, version: "0.85.0" }; assert.deepEqual(resolvePiInstallation(launcher, ""), expected); assert.deepEqual(resolvePiInstallation(cli, ""), expected); }); test("fails when no installed Pi package is near the launcher", (context) => { const directory = mkdtempSync(path.join(tmpdir(), "pi-modernize-")); context.after(() => rmSync(directory, { recursive: true, force: true })); const launcher = path.join(directory, "pi"); writeFileSync(launcher, ""); assert.throws( () => resolvePiInstallation(launcher, ""), /installed @earendil-works\/pi-coding-agent package not found/, ); }); function managedInstallation(context) { const directory = mkdtempSync(path.join(tmpdir(), "pi-modernize-")); context.after(() => rmSync(directory, { recursive: true, force: true })); const installRoot = path.join(directory, "install"); const launcher = path.join(directory, "bin", "pi.CMD"); const packageRoot = path.join( installRoot, "releases", "0.85.0", "node_modules", "@earendil-works", "pi-coding-agent", ); const cli = path.join(packageRoot, "dist", "cli.js"); const changelog = path.join(packageRoot, "CHANGELOG.md"); mkdirSync(path.dirname(launcher), { recursive: true }); mkdirSync(path.dirname(cli), { recursive: true }); writeFileSync(launcher, ""); writeFileSync(path.join(installRoot, "current-version"), "0.85.0\n"); writeFileSync( path.join(packageRoot, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: "0.85.0", bin: { pi: "dist/cli.js" }, }), ); writeFileSync(cli, ""); writeFileSync(changelog, ""); return { installRoot, launcher, packageRoot, expected: { cli, changelog, version: "0.85.0" }, }; } test("resolves the selected managed release from the launcher layout", (context) => { const { launcher, expected } = managedInstallation(context); assert.deepEqual(resolvePiInstallation(launcher, ""), expected); }); test("explicit managed root takes precedence over executable proximity", (context) => { const { installRoot, expected } = managedInstallation(context); assert.deepEqual( resolvePiInstallation(process.execPath, installRoot), expected, ); }); test("rejects invalid managed version pointers without falling back", (context) => { const { installRoot, launcher } = managedInstallation(context); for (const version of ["", ".", "..", "../outside", "a/b", "a\\b"]) { writeFileSync(path.join(installRoot, "current-version"), version); assert.throws( () => resolvePiInstallation(launcher, installRoot), /version file is invalid/, ); } }); test("fails for missing managed pointers and releases", (context) => { const { installRoot, launcher } = managedInstallation(context); writeFileSync(path.join(installRoot, "current-version"), "0.86.0"); assert.throws( () => resolvePiInstallation(launcher, installRoot), /package not found in managed release/, ); rmSync(path.join(installRoot, "current-version")); assert.throws(() => resolvePiInstallation(launcher, installRoot), /ENOENT/); }); test("rejects managed release metadata mismatching the pointer", (context) => { const { installRoot, launcher, packageRoot } = managedInstallation(context); writeFileSync( path.join(packageRoot, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: "0.84.0", }), ); assert.throws( () => resolvePiInstallation(launcher, installRoot), /does not match package version/, ); }); test("reports inherited-stdio child failures by exit code", () => { assert.throws( () => checkedSpawn(process.execPath, ["-e", "process.exit(7)"], { stdio: "inherit", }), /exited with code 7/, ); }); test("collects resolved earendil Pi package versions", () => { assert.deepEqual( collectPackageVersions({ packages: { "node_modules/@earendil-works/pi-ai": { version: "0.80.4" }, "extensions/x/node_modules/@earendil-works/pi-ai": { version: "0.80.4", }, "node_modules/@earendil-works/pi-coding-agent": { version: "0.80.6", }, "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { version: "0.1.7", }, "node_modules/vitest": { version: "4.1.9" }, }, }), [ { name: "@earendil-works/pi-ai", version: "0.80.4" }, { name: "@earendil-works/pi-coding-agent", version: "0.80.6" }, ], ); }); test("reports only evidence needed before tool-driven inspection", () => { assert.deepEqual( createReport({ piVersion: "2.0.0", candidatesAfterVersion: "1.5.0", localChangelogPath: "/pi/CHANGELOG.md", remoteChangelogUrl: "https://example.test/CHANGELOG.md", candidates: [{ version: "2.0.0", text: "Replace alpha." }], capabilities: [{ version: "1.5.0", text: "Added native status." }], }), { schemaVersion: 3, status: "ok", piVersion: "2.0.0", candidatesAfterVersion: "1.5.0", changelogs: { installed: "/pi/CHANGELOG.md", remote: "https://example.test/CHANGELOG.md", }, candidates: [{ version: "2.0.0", text: "Replace alpha." }], capabilities: [{ version: "1.5.0", text: "Added native status." }], }, ); });