Pay down pre-existing error→warn lint debt. Removes dead imports/vars, unused functions, redundant regex/string escapes, and stale eslint-disable directives; converts unused `catch (_e)` to optional catch binding (src/*.cts). No behavior change. Lint 345→125 warnings (0 errors); deferred categories (n/no-process-exit, test-sleeps, control-regex) tracked in #732 for follow-up. Full test suite green (0 failures); code-review verified all removals unused and all escape fixes semantics-preserving. Closes #732 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
235 lines
9.1 KiB
JavaScript
235 lines
9.1 KiB
JavaScript
/**
|
||
* Regression test for #3541: first-time-baseline installer migration
|
||
* `prompt-user` actions threw hard with no resolution path, making
|
||
* `/gsd-update` unrecoverable when leftover `gsd-*` files were classified
|
||
* as `stale-gsd-looking`.
|
||
*
|
||
* Fix shape (per triage brief):
|
||
* A. Classify-and-default for safe categories - stale SDK build
|
||
* artifacts default to "remove"; user-facing skills/gsd-asterisk/SKILL.md
|
||
* defaults to "keep". Each resolution is logged.
|
||
* B. Improved error message when an unresolved prompt-user action
|
||
* remains: lists choices, suggests the resolution path, groups
|
||
* blocked paths by reason.
|
||
*
|
||
* Behavioural test — exercises the actual installer migration code paths
|
||
* via the public `runInstallerMigrations` + new resolver entry points.
|
||
* No source-grep (per CONTEXT.md L98–101 / RULESET.TESTS).
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const {
|
||
runInstallerMigrations,
|
||
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||
const {
|
||
assertInstallerMigrationsUnblocked,
|
||
resolveInstallerMigrationPromptsForNonTty,
|
||
} = require('../gsd-core/bin/lib/installer-migration-report.cjs');
|
||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||
|
||
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.41.2',
|
||
timestamp: '2026-05-10T00:00:00.000Z',
|
||
mode: 'full',
|
||
files,
|
||
}, null, 2),
|
||
'utf8'
|
||
);
|
||
}
|
||
|
||
describe('#3541: installer migration prompt-user non-TTY resolution', { concurrency: false }, () => {
|
||
let configDir;
|
||
|
||
beforeEach(() => {
|
||
configDir = createTempDir('gsd-3541-');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(configDir);
|
||
});
|
||
|
||
test('Test A: non-TTY default resolution removes stale SDK artifacts and keeps user skills', () => {
|
||
// Stale SDK build artifact: replicates the 1.41.2 → 1.42.2 upgrade where
|
||
// 24 stale `gsd-core/sdk/{dist,src}/gsd-*` files leaked into the
|
||
// baseline because the new manifest no longer classifies them as managed.
|
||
writeFile(configDir, 'gsd-core/sdk/dist/gsd-old-bundle.js', 'stale sdk bundle\n');
|
||
// User-facing skill: replicates `skills/gsd-roadmap/SKILL.md` from the
|
||
// same incident — user-owned content that must be preserved.
|
||
writeFile(configDir, 'skills/gsd-roadmap/SKILL.md', '# Roadmap skill\nuser content\n');
|
||
|
||
// Plant an empty manifest so both files classify as `stale-gsd-looking`
|
||
// (they look like GSD artifacts but are not manifest-managed).
|
||
writeManifest(configDir, {});
|
||
|
||
const result = runInstallerMigrations({
|
||
configDir,
|
||
runtime: 'claude',
|
||
scope: 'global',
|
||
baselineScan: true,
|
||
});
|
||
|
||
// Confirm the migration framework classified both as prompt-user
|
||
// blockers — this is the precondition the fix resolves.
|
||
const blockedPaths = (result.blocked || []).map((a) => a.relPath).sort();
|
||
assert.deepEqual(
|
||
blockedPaths,
|
||
['gsd-core/sdk/dist/gsd-old-bundle.js', 'skills/gsd-roadmap/SKILL.md'],
|
||
'precondition: both stale-looking files should be flagged for explicit user choice'
|
||
);
|
||
|
||
// Now run the non-TTY resolver. It must classify-and-default each
|
||
// blocked action and return a structured log of resolutions.
|
||
const resolved = resolveInstallerMigrationPromptsForNonTty(result, { isTty: false });
|
||
|
||
assert.ok(Array.isArray(resolved.resolutions), 'resolver returns a resolutions log');
|
||
assert.equal(
|
||
resolved.resolutions.length,
|
||
2,
|
||
'one resolution entry per blocked action'
|
||
);
|
||
|
||
const byPath = new Map(resolved.resolutions.map((r) => [r.relPath, r]));
|
||
const sdkResolution = byPath.get('gsd-core/sdk/dist/gsd-old-bundle.js');
|
||
const skillResolution = byPath.get('skills/gsd-roadmap/SKILL.md');
|
||
|
||
assert.ok(sdkResolution, 'SDK artifact resolution logged');
|
||
assert.equal(sdkResolution.choice, 'remove', 'stale SDK build artifact defaults to remove');
|
||
assert.equal(sdkResolution.category, 'stale-sdk-build-artifact');
|
||
|
||
assert.ok(skillResolution, 'user skill resolution logged');
|
||
assert.equal(skillResolution.choice, 'keep', 'user-facing skill defaults to keep');
|
||
assert.equal(skillResolution.category, 'user-facing-skill');
|
||
|
||
// After resolution there must be no blocked actions remaining; the
|
||
// assertion gatekeeper must not throw.
|
||
assert.equal(
|
||
(resolved.result.blocked || []).length,
|
||
0,
|
||
'all prompt-user actions resolved'
|
||
);
|
||
assert.doesNotThrow(() => assertInstallerMigrationsUnblocked(resolved.result));
|
||
});
|
||
|
||
test('Test B: error message groups paths by reason and suggests a resolution path', () => {
|
||
// Build a synthetic result with two blocked prompt-user actions of
|
||
// distinct reasons. The improved error message must (1) list the
|
||
// documented choices, (2) suggest the non-interactive resolution
|
||
// path, (3) group blocked paths by reason rather than emit each path
|
||
// individually.
|
||
const blocked = [
|
||
{
|
||
type: 'prompt-user',
|
||
relPath: 'gsd-core/sdk/dist/gsd-a.js',
|
||
reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice',
|
||
classification: 'stale-gsd-looking',
|
||
prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.',
|
||
choices: ['keep', 'remove'],
|
||
},
|
||
{
|
||
type: 'prompt-user',
|
||
relPath: 'gsd-core/sdk/dist/gsd-b.js',
|
||
reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice',
|
||
classification: 'stale-gsd-looking',
|
||
prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.',
|
||
choices: ['keep', 'remove'],
|
||
},
|
||
];
|
||
|
||
let captured = null;
|
||
try {
|
||
assertInstallerMigrationsUnblocked({ blocked });
|
||
assert.fail('expected assertInstallerMigrationsUnblocked to throw');
|
||
} catch (err) {
|
||
captured = err;
|
||
}
|
||
|
||
assert.ok(captured instanceof Error);
|
||
const message = captured.message;
|
||
|
||
// (a) Documented choices listed.
|
||
assert.match(message, /keep/, 'error message lists `keep` choice');
|
||
assert.match(message, /remove/, 'error message lists `remove` choice');
|
||
|
||
// (b) Suggests the resolution path. The fix introduces an
|
||
// environment variable as the documented non-interactive resolution
|
||
// surface — the message must point users at it.
|
||
assert.match(
|
||
message,
|
||
/GSD_INSTALLER_MIGRATION_RESOLVE/,
|
||
'error message suggests the non-interactive resolution env var'
|
||
);
|
||
|
||
// (c) Paths grouped by reason — two paths sharing the same reason
|
||
// appear under one summary count, not as two separate path lines.
|
||
// The message must include a `2 files` (or similar) grouped summary
|
||
// and must NOT list each individual relPath in the top-level message.
|
||
assert.match(
|
||
message,
|
||
/2 (files?|paths?|artifacts?)/,
|
||
'error message groups blocked paths into a count summary'
|
||
);
|
||
|
||
// Structured surface: the thrown error must carry a `blockedByReason`
|
||
// map so callers can render their own report without re-parsing.
|
||
assert.ok(captured.blockedByReason, 'error carries blockedByReason data');
|
||
const reasons = Object.keys(captured.blockedByReason);
|
||
assert.equal(reasons.length, 1, 'two same-reason paths grouped under one key');
|
||
assert.equal(captured.blockedByReason[reasons[0]].length, 2);
|
||
});
|
||
|
||
test('Test C: non-TTY env override resolves otherwise-unclassified prompt-user actions', () => {
|
||
const result = {
|
||
blocked: [
|
||
{
|
||
type: 'prompt-user',
|
||
relPath: 'skills/gsd-custom/SKILL.toml',
|
||
reason: 'custom skill metadata requires user decision',
|
||
choices: ['keep', 'remove'],
|
||
},
|
||
],
|
||
plan: {
|
||
actions: [],
|
||
blocked: [
|
||
{
|
||
type: 'prompt-user',
|
||
relPath: 'skills/gsd-custom/SKILL.toml',
|
||
reason: 'custom skill metadata requires user decision',
|
||
choices: ['keep', 'remove'],
|
||
},
|
||
],
|
||
},
|
||
};
|
||
|
||
const resolved = resolveInstallerMigrationPromptsForNonTty(result, {
|
||
isTty: false,
|
||
env: { GSD_INSTALLER_MIGRATION_RESOLVE: 'keep' },
|
||
});
|
||
|
||
assert.equal(resolved.resolutions.length, 1, 'env override resolves prompt-user action');
|
||
assert.equal(resolved.resolutions[0].choice, 'keep');
|
||
assert.equal(resolved.resolutions[0].source, 'GSD_INSTALLER_MIGRATION_RESOLVE');
|
||
assert.equal(resolved.resolutions[0].category, 'operator-override');
|
||
assert.equal((resolved.result.blocked || []).length, 0);
|
||
assert.equal((resolved.result.plan.blocked || []).length, 0);
|
||
assert.equal((resolved.result.plan.actions || []).length, 1);
|
||
assert.equal(resolved.result.plan.actions[0].type, 'baseline-preserve-user');
|
||
});
|
||
});
|