* test(3582): failing-first cold-tree coverage and the seam drift lint On a plugin-channel install the compiled gsd-core/bin/lib/*.cjs are legitimately absent (ADR-457 build-at-publish; the npm package builds before publishing, a raw tree materialization never does). gsd-tools.cjs calls ensureRuntimeBuild() before requiring ./lib; no hook does, so the isolation guard's Cannot-find-module lands in its fail-closed catch and is misreported as an unreadable dispatch-isolation configuration, blocking every executor dispatch. These tests fail on that: cold-tree runs of the isolation guard, statusline, cursor guard and update worker, plus the seam's actionable build error surfacing instead of the generic misreport. Also adds the drift lint the acceptance criteria require, with a fixture proving it CAN fail — a guard never shown to fail is worthless. It is red here by design: it flags today's unfixed hooks, which is exactly the defect. * fix(3582): route every hook's compiled-module require through the self-heal seam RED proven at 5b174b0d: 11 failures — the cold-tree runs for the isolation guard, cursor guard and update worker, the fail-closed-with-actionable-message assertion, and the lint's own real-tree check. The compiled runtime library is produced by build:lib and gitignored (ADR-457, build-at-publish). The npm package builds before publishing; a plugin-marketplace or git-clone install materializes the raw tree and never does, so on that channel those modules are legitimately absent. The self-heal seam added by #2002 exists to heal exactly this, and the CLI entrypoint already calls it — no hook did. The isolation guard's Cannot-find-module therefore landed in its fail-closed catch and was reported as 'could not read or resolve dispatch-isolation configuration', so an ARTIFACT ABSENCE was misdiagnosed as an unreadable project config and every executor dispatch was blocked. All SEVEN affected files now call the seam before their first compiled require. The issue named four; a scan found six; implementing it surfaced a seventh — the shared isolation sentinel helper, used by BOTH guards, which requires two compiled modules itself and would have defeated the guards' own fix on a genuinely cold tree. Same defect class, so fixed here rather than left as a known-broken remainder. Failure posture is deliberately split by hook kind: - Gates (agent isolation guard, cursor subagent start) surface the seam's actionable build error distinctly instead of swallowing it into the generic text, and stay fail-closed — a genuinely unreadable project config still DENIES exactly as before. - Cosmetic and detached hooks (statusline, update worker, update check, update banner) DEGRADE rather than crash: the statusline draws on every render and the worker is a detached process, so a build failure there must not take down the prompt. The npm path is untouched: the seam's already-built fast path returns immediately, so prebuilt installs pay nothing and behave bit-for-bit as before. Adds a drift lint, wired into the CI lint chain, so the invariant is enforced rather than remembered — without it the next hook to add a compiled require reintroduces the class silently. It is proven able to fail: a fixture hook requiring a compiled module without the seam is flagged, and one that uses the seam is not. Verified directly — on the unfixed tree it named all seven offenders; with the fix it passes. While writing the lint's comment stripper, a naive whole-text block-comment regex ate its own fixture, because this repo's comments legitimately spell the compiled-lib glob whose star-slash reads as a comment opener. Rewritten as a line-based scanner with a regression test pinning that case. * fix(3582): test the three untested seam call sites and assert typed reason codes Two independent reviews converged on the same major gap: the fix wired the seam into seven files but only four had cold-tree tests. The adversarial pass put it plainly — deleting the shared isolation-sentinel helper's seam call would not have failed any test in the diff. That file was my own addition beyond the issue's four, so it shipped untested; that is now closed. - Shared isolation-sentinel helper: its seam call is only reached when .planning is NOT directly under cwd, and every existing cold-tree fixture puts it there, so the early return always fired first. Now covered, and proven load-bearing by mutation: with the call removed the spy records zero seam invocations and the test fails. - update-check hook and update-banner hook: cold-tree tests added asserting the DEGRADED VERDICT — the fallback cache filename, and silent suppression when the package name degrades to null — rather than merely 'did not throw'. The banner hook previously had no test file at all. Standards violation fixed: two tests asserted on free-form prose via assert.match against a JSON reason string, which CONTRIBUTING bans by name — its own BAD example is exactly that. The ESLint rule only covers readFileSync/spawnSync text, so tooling did not catch it. Both isolation guards now emit a machine-readable reason_code from a frozen enum, following the repo's existing REASON convention, and the tests assert that instead. The human-readable message is unchanged for operators; only the assertion target moved. The duplicated degrade boilerplate across the three cosmetic hooks was deliberately NOT extracted, and the reason is recorded at each site: both viable shapes — a path-parameterized helper, or a ceremony-only wrapper — defeat the drift lint's per-file literal co-occurrence check, so extracting would require the lint to special-case its own helper. Triplication is the lesser evil while the lint stays a co-occurrence scan. The lint's header now states what it does and does not catch (literal quoted requires only; hooks/ scan root), so a future reader does not over-trust a guard that a concatenated path or a require inside a non-hooks helper would evade. * chore(3582): regenerate the committed install-tree fixtures Adding a new shipped hook helper changed the install tree, and those fixtures are committed-and-derived (regen:derived / gen:install-tree), so 12 'install tree — <runtime>' tests failed on 541a1913. Regenerated rather than hand-edited. The delta across all 15 runtime fixtures is exactly two lines — the new helper under both its hooks/ and gsd-hooks/ install paths — and nothing else, so the regeneration pulled in no unrelated drift. This is the bookkeeping ripple a new file under hooks/ carries; it was not visible from lint:ci, which passed both before and after. * chore(3582): backfill changeset PR number (#3629) --------- Co-authored-by: sim <sim@local>
967 lines
40 KiB
JavaScript
967 lines
40 KiB
JavaScript
/**
|
|
* Tests for the Windows npm resolution platform gate.
|
|
*
|
|
* Background (issue #3103, PR #3102):
|
|
* On Windows, `npm` ships as `npm.cmd`. Node's spawn does not apply PATHEXT
|
|
* resolution and fails with ENOENT. The fix is to spawn through a shell on
|
|
* Windows (cmd.exe resolves npm.cmd via PATHEXT). On POSIX, `npm` resolves
|
|
* without a shell, so spawning `/bin/sh -c` is pure overhead and changes
|
|
* signal / exit-code semantics — undesirable.
|
|
*
|
|
* Relocation (#498): the SessionStart worker no longer spawns npm itself. It
|
|
* delegates the latest-version lookup to check-latest-version's
|
|
* `checkLatestVersion()`, which routes through `execNpm` in the shell-command
|
|
* projection seam. The PR #3102 contract therefore now lives on `execNpm`.
|
|
* This test locks it there, and additionally locks that the worker does NOT
|
|
* re-introduce a direct npm spawn (which would re-open the gate question in a
|
|
* second place).
|
|
*
|
|
* Source-grep policy: these structural assertions read source via readFileSync.
|
|
* The behavior (Windows-only shell resolution) is platform-gated at runtime and
|
|
* cannot be reached on POSIX CI without a Windows lane; a structural assertion
|
|
* is the minimum-cost contract.
|
|
*/
|
|
|
|
// allow-test-rule: structural-regression-guard
|
|
// structural assertion on spawn-options shape; the behavior
|
|
// (Windows-only shell resolution) is platform-gated at runtime and cannot be
|
|
// reached on POSIX CI without a Windows lane.
|
|
|
|
'use strict';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js');
|
|
const PROJECTION_PATH = path.join(
|
|
__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs',
|
|
);
|
|
|
|
function codeOnly(file) {
|
|
return fs.readFileSync(file, 'utf8')
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded hooks/lib source, not adversarial input
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded hooks source, not adversarial input
|
|
.replace(/(^|[^:])\/\/[^\r\n]*/g, '$1');
|
|
}
|
|
|
|
describe('execNpm: Windows npm spawn platform gate (PR #3102, relocated #498)', () => {
|
|
test('projection seam exists', () => {
|
|
assert.ok(fs.existsSync(PROJECTION_PATH), `not found at ${PROJECTION_PATH}`);
|
|
});
|
|
|
|
test('execNpm gates shell to process.platform === "win32"', () => {
|
|
assert.match(
|
|
codeOnly(PROJECTION_PATH),
|
|
/shell:\s*process\.platform\s*===\s*['"]win32['"]/,
|
|
[
|
|
'execNpm must gate shell to `process.platform === "win32"`.',
|
|
'A regression to `shell: true` would spawn /bin/sh -c on POSIX',
|
|
'(adds shell overhead, changes signal/exit semantics). See PR #3102.',
|
|
].join(' '),
|
|
);
|
|
});
|
|
|
|
test('no unconditional shell: true on the npm spawn', () => {
|
|
assert.doesNotMatch(
|
|
codeOnly(PROJECTION_PATH),
|
|
/shell\s*:\s*true\s*[,\s}]/,
|
|
'shell: true is forbidden — use the `process.platform === "win32"` gate.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('worker delegates the npm spawn (does not re-open the gate, #498)', () => {
|
|
test('worker does NOT spawn npm directly', () => {
|
|
const code = codeOnly(WORKER_PATH);
|
|
assert.doesNotMatch(
|
|
code,
|
|
/(execFileSync|spawnSync|execSync|exec)\s*\(\s*['"]npm['"]/,
|
|
'Worker must delegate to checkLatestVersion(), not spawn npm itself.',
|
|
);
|
|
});
|
|
|
|
test('worker requires check-latest-version for the lookup', () => {
|
|
assert.match(codeOnly(WORKER_PATH), /check-latest-version/);
|
|
});
|
|
});
|
|
|
|
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degrade, not crash ─────
|
|
//
|
|
// gsd-core/bin/lib/semver-compare.cjs, package-identity.cjs, and (via
|
|
// check-latest-version.cjs's own transitive requires) gsd-core/bin/lib/
|
|
// cli-exit.cjs + shell-command-projection.cjs are tsc build artifacts
|
|
// (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone
|
|
// install that never ran `npm run build:lib`. This worker is a DETACHED
|
|
// SessionStart background process (spawned with stdio: 'ignore' by
|
|
// hooks/gsd-check-update.js) — before #3582 a missing library crashed the
|
|
// worker at module load with no visible signal (stderr discarded by the
|
|
// parent) and no cache-file write at all, so the statusline/banner would
|
|
// silently never see an update signal. The fix wraps ensureRuntimeBuild()
|
|
// and the three compiled-lib requires in one try/catch and degrades to
|
|
// no-signal fallbacks (isSemverNewer -> false, checkLatestVersion -> not ok,
|
|
// PACKAGE_NAME -> null) on failure — the worker still runs to completion and
|
|
// writes a result cache record. Simulated hermetically via a fixture install
|
|
// tree that copies hooks/ + the seam module but never gsd-core/bin/lib/ or
|
|
// tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the REAL
|
|
// gsd-core/bin/lib/ is never touched.
|
|
{
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
|
|
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
describe('gsd-check-update-worker.js: #3582 cold tree — degrade, not crash', () => {
|
|
test('missing compiled runtime library -> worker still writes a degraded result cache, no crash', (t) => {
|
|
const cold = buildColdInstallTree();
|
|
t.after(cold.cleanup);
|
|
const cacheDir = createTempDir('gsd-worker-cold-');
|
|
t.after(() => cleanup(cacheDir));
|
|
const cacheFile = path.join(cacheDir, 'cache.json');
|
|
|
|
const env = {
|
|
...process.env,
|
|
GSD_CACHE_FILE: cacheFile,
|
|
GSD_PROJECT_VERSION_FILE: path.join(cacheDir, 'no-such-project', 'VERSION'),
|
|
GSD_GLOBAL_VERSION_FILE: path.join(cacheDir, 'no-such-global', 'VERSION'),
|
|
};
|
|
const r = runHookSeam(path.join(cold.hooksDir, 'gsd-check-update-worker.js'), [], {
|
|
env,
|
|
timeoutMs: 8000,
|
|
});
|
|
assert.equal(r.exitCode, 0, `worker must exit 0 on a build failure; stderr: ${r.stderr}`);
|
|
assert.ok(fs.existsSync(cacheFile), 'worker must still reach the end and write a cache record');
|
|
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
assert.equal(cache.package_name, null, 'degraded package_name must be null (no-signal, never a stale/foreign value)');
|
|
assert.ok(!cache.update_available, 'degraded update_available must be falsy');
|
|
assert.equal(cache.installed, '0.0.0', 'installed detection is unaffected by the compiled-lib degrade');
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2992-check-latest-version.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2992-check-latest-version (consolidation epic #1969 B5 #1974)", () => {
|
|
'use strict';
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const { checkLatestVersion, CHECK_REASON, PACKAGE_NAME } = require(
|
|
path.join(ROOT, 'gsd-core', 'bin', 'check-latest-version.cjs'),
|
|
);
|
|
|
|
// checkLatestVersion is a pure-ish function: it spawns one fixed npm
|
|
// command, validates the output, and returns { ok, version | reason }.
|
|
// The package name is HARDCODED — not a free choice for the caller.
|
|
// Tests use a pluggable spawn so no real npm process is invoked.
|
|
|
|
describe('Bug #2992: deterministic latest-version check', () => {
|
|
test('PACKAGE_NAME is the constant @opengsd/gsd-core (no callers can override)', () => {
|
|
assert.equal(PACKAGE_NAME, '@opengsd/gsd-core');
|
|
});
|
|
|
|
test('CHECK_REASON enum exposes the documented codes', () => {
|
|
assert.deepEqual(
|
|
Object.keys(CHECK_REASON).sort(),
|
|
['FAIL_INVALID_OUTPUT', 'FAIL_NPM_FAILED', 'OK'].sort(),
|
|
);
|
|
});
|
|
|
|
test('returns { ok: true, version } when npm prints a valid semver', () => {
|
|
const fakeSpawn = () => ({ status: 0, stdout: '1.39.1\n', stderr: '' });
|
|
const r = checkLatestVersion({ spawn: fakeSpawn });
|
|
assert.deepEqual(r, { ok: true, version: '1.39.1', reason: CHECK_REASON.OK });
|
|
});
|
|
});
|
|
|
|
describe('Bug #2992: error paths', () => {
|
|
const { checkLatestVersion, CHECK_REASON } = require(require('node:path').join(__dirname, '..', 'gsd-core', 'bin', 'check-latest-version.cjs'));
|
|
|
|
test('FAIL_NPM_FAILED when npm exits non-zero (e.g. offline, 404)', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 1, stdout: '', stderr: 'npm ERR! 404\n' }),
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED);
|
|
assert.equal(r.detail, 'npm ERR! 404',
|
|
'detail should be the trimmed stderr when npm reports a real error');
|
|
});
|
|
|
|
// #2993 CR: distinguish timeout from genuine npm failure in `detail`.
|
|
// spawnSync sets status=null and signal='SIGTERM' on timeout; stderr is
|
|
// typically empty. Without the signal-first branch, both shape as
|
|
// 'npm exited non-zero' and the operator cannot tell timeout from failure.
|
|
test('FAIL_NPM_FAILED detail names the signal when spawn times out', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: null, signal: 'SIGTERM', stdout: '', stderr: '' }),
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED);
|
|
assert.equal(r.detail, 'npm timed out (signal: SIGTERM)',
|
|
'detail should explicitly name the signal when status is null and signal is set');
|
|
});
|
|
|
|
test('FAIL_NPM_FAILED detail falls back to generic when neither stderr nor signal is present', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 1, stdout: '', stderr: '' }),
|
|
});
|
|
assert.equal(r.detail, 'npm exited non-zero');
|
|
});
|
|
|
|
test('FAIL_INVALID_OUTPUT when npm prints something that is not a semver', () => {
|
|
// E.g. if a future npm version changes the output format, or if the
|
|
// network returns an HTML error page captured as stdout.
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 0, stdout: '<html>not a version</html>\n', stderr: '' }),
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT);
|
|
});
|
|
|
|
test('FAIL_INVALID_OUTPUT when stdout is empty', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT);
|
|
});
|
|
|
|
test('accepts pre-release semver (e.g. 1.40.0-rc.1)', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 0, stdout: '1.40.0-rc.1\n', stderr: '' }),
|
|
});
|
|
assert.deepEqual(r, { ok: true, version: '1.40.0-rc.1', reason: CHECK_REASON.OK });
|
|
});
|
|
});
|
|
|
|
describe('Issue #815: --next dist-tag support', () => {
|
|
const { buildViewArgs, resolveTag, ALLOWED_TAGS } = require(
|
|
path.join(ROOT, 'gsd-core', 'bin', 'check-latest-version.cjs'),
|
|
);
|
|
|
|
test('ALLOWED_TAGS is the sanctioned channel allowlist (latest, next)', () => {
|
|
assert.deepEqual([...ALLOWED_TAGS].sort(), ['latest', 'next']);
|
|
});
|
|
|
|
test('buildViewArgs() defaults to the bare latest spec (byte-for-byte unchanged)', () => {
|
|
assert.deepEqual(buildViewArgs(), ['view', '@opengsd/gsd-core', 'version']);
|
|
assert.deepEqual(buildViewArgs('latest'), ['view', '@opengsd/gsd-core', 'version']);
|
|
});
|
|
|
|
test('buildViewArgs("next") targets the @next dist-tag', () => {
|
|
assert.deepEqual(buildViewArgs('next'), ['view', '@opengsd/gsd-core@next', 'version']);
|
|
});
|
|
|
|
test('resolveTag defaults to latest when no --tag flag', () => {
|
|
assert.equal(resolveTag(['--json']), 'latest');
|
|
});
|
|
|
|
test('resolveTag reads --tag next', () => {
|
|
assert.equal(resolveTag(['--json', '--tag', 'next']), 'next');
|
|
});
|
|
|
|
test('resolveTag rejects an unknown tag (typo guard)', () => {
|
|
assert.throws(() => resolveTag(['--tag', 'nightly']), /invalid --tag 'nightly'/);
|
|
});
|
|
|
|
test('resolveTag rejects --tag with no value', () => {
|
|
assert.throws(() => resolveTag(['--tag']), /invalid --tag ''/);
|
|
});
|
|
|
|
test('checkLatestVersion accepts an RC under the next tag', () => {
|
|
const r = checkLatestVersion({ tag: 'next', spawn: () => ({ status: 0, stdout: '1.4.0-rc.1\n', stderr: '' }) });
|
|
assert.deepEqual(r, { ok: true, version: '1.4.0-rc.1', reason: CHECK_REASON.OK });
|
|
});
|
|
|
|
test('buildViewArgs rejects a tag outside the allowlist (exported-API guard)', () => {
|
|
assert.throws(() => buildViewArgs('nightly'), /invalid dist-tag 'nightly'/);
|
|
});
|
|
|
|
test('checkLatestVersion rejects an out-of-allowlist tag even with an injected spawn', () => {
|
|
assert.throws(
|
|
() => checkLatestVersion({ tag: 'nightly', spawn: () => ({ status: 0, stdout: '9.9.9\n', stderr: '' }) }),
|
|
/invalid dist-tag 'nightly'/,
|
|
);
|
|
});
|
|
|
|
test('resolveTag handles the --tag=next equals form', () => {
|
|
assert.equal(resolveTag(['--json', '--tag=next']), 'next');
|
|
});
|
|
|
|
test('resolveTag rejects an unknown --tag=value equals form (no silent fallback)', () => {
|
|
assert.throws(() => resolveTag(['--tag=nightly']), /invalid --tag 'nightly'/);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-378-update-check-scoped-name.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-378-update-check-scoped-name (consolidation epic #1969 B5 #1974)", () => {
|
|
/**
|
|
* Regression test for #378 / #498: the SessionStart update worker must end up
|
|
* querying the SCOPED package name (@opengsd/gsd-core) when it asks
|
|
* npm for the latest version.
|
|
*
|
|
* Background (#378): the worker once hardcoded the unscoped 'gsd-core',
|
|
* which 404s from the registry, leaving update_available permanently false.
|
|
*
|
|
* Original #378 fix derived the name from `require('../package.json').name`.
|
|
* That is broken at runtime (#498): no package.json in the installed tree
|
|
* carries a `.name`. It used to resolve to the synthetic `{"type":"commonjs"}`
|
|
* marker GSD wrote at the config root, so post-install the worker queried
|
|
* `npm view undefined version` → latest stayed null → update_available
|
|
* permanently false; since #2544 GSD writes no marker there at all, so the
|
|
* require would now fail to resolve outright. The old structural test passed
|
|
* only because it grepped the DEV tree, where package.json still has a name.
|
|
*
|
|
* New contract (#498): the worker no longer resolves the package name itself.
|
|
* It delegates the latest-version lookup to check-latest-version.cjs's
|
|
* `checkLatestVersion()`, whose `PACKAGE_NAME` is sourced from the baked Package
|
|
* Identity seam (`gsd-core/bin/lib/package-identity.cjs`). The seam's value
|
|
* is a build-time constant, correct in every install layout, so the
|
|
* undefined-at-runtime failure cannot recur. This test locks that contract:
|
|
*
|
|
* 1. Structural: worker must NOT contain the bare unscoped literal.
|
|
* 2. Structural: worker must NOT use `require(...package.json...).name`
|
|
* (the runtime-broken path).
|
|
* 3. Structural: worker delegates to check-latest-version's
|
|
* `checkLatestVersion` rather than calling `npm view` itself.
|
|
* 4. Single-source: check-latest-version's PACKAGE_NAME === the seam's
|
|
* packageName === the scoped '@opengsd/gsd-core'.
|
|
*
|
|
* Source-grep policy: this test reads hook source via readFileSync. The repo's
|
|
* lint-no-source-grep rule targets bin/lib/gsd-core — hooks/ is out of
|
|
* scope. The behavior (correct name → no E404) only manifests at runtime
|
|
* against the live registry; structural assertions are the minimum-cost
|
|
* contract for the worker, the same rationale #378 carried.
|
|
*/
|
|
|
|
// allow-test-rule: structural-regression-guard (see #378)
|
|
// structural assertion on hook delegation; the behavior being
|
|
// tested (correct package name → no E404) only manifests at runtime against the
|
|
// live npm registry, which CI does not call.
|
|
|
|
'use strict';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js');
|
|
const PKG_PATH = path.join(__dirname, '..', 'package.json');
|
|
const SEAM = require('../gsd-core/bin/lib/package-identity.cjs');
|
|
const { PACKAGE_NAME } = require('../gsd-core/bin/check-latest-version.cjs');
|
|
|
|
function workerCodeOnly() {
|
|
const src = fs.readFileSync(WORKER_PATH, 'utf8');
|
|
return src
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded hooks source, not adversarial input
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded hooks source, not adversarial input
|
|
.replace(/(^|[^:])\/\/[^\r\n]*/g, '$1');
|
|
}
|
|
|
|
describe('bug #378 / #498: update worker queries the scoped name via the seam', () => {
|
|
test('worker file exists', () => {
|
|
assert.ok(fs.existsSync(WORKER_PATH), `worker not found at ${WORKER_PATH}`);
|
|
});
|
|
|
|
test('package.json name is the scoped @opengsd/gsd-core', () => {
|
|
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8'));
|
|
assert.equal(pkg.name, '@opengsd/gsd-core');
|
|
});
|
|
|
|
test('worker does NOT hardcode the unscoped gsd-core as a string literal', () => {
|
|
assert.doesNotMatch(
|
|
workerCodeOnly(),
|
|
/['"]gsd-core['"]/,
|
|
"Worker must not pass the unscoped 'gsd-core' to npm — it 404s.",
|
|
);
|
|
});
|
|
|
|
test('worker does NOT resolve the name via require(package.json).name (broken at runtime)', () => {
|
|
assert.doesNotMatch(
|
|
workerCodeOnly(),
|
|
/require\s*\(\s*['"][^'"]*package\.json['"]\s*\)\s*\.name/,
|
|
[
|
|
'require(package.json).name never yields a name in the installed tree —',
|
|
'GSD stages only {"type":"commonjs"} markers, and since #2544 none at the',
|
|
'config root. The worker must delegate to checkLatestVersion(), which',
|
|
'sources the name from the baked seam.',
|
|
].join(' '),
|
|
);
|
|
});
|
|
|
|
test('worker delegates the latest-version lookup to checkLatestVersion', () => {
|
|
const code = workerCodeOnly();
|
|
assert.match(
|
|
code,
|
|
/check-latest-version/,
|
|
'Worker must require check-latest-version.cjs and call checkLatestVersion().',
|
|
);
|
|
assert.match(code, /checkLatestVersion\s*\(/);
|
|
});
|
|
|
|
test('check-latest-version PACKAGE_NAME is single-sourced from the seam', () => {
|
|
assert.equal(PACKAGE_NAME, SEAM.packageName);
|
|
assert.equal(SEAM.packageName, '@opengsd/gsd-core');
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2784-update-cache-clear-path.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2784-update-cache-clear-path (consolidation epic #1969 B5 #1974)", () => {
|
|
// allow-test-rule: structural-regression-guard (see #2784)
|
|
// Reads hook .js or bin/install.js source to assert structural invariants
|
|
// (search array order, function wiring, path constants) that cannot be
|
|
// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception matrix.
|
|
|
|
/**
|
|
* Regression test for bug #2784
|
|
*
|
|
* /gsd-update cache-clear step only cleared per-runtime cache paths
|
|
* (e.g. ~/.claude/cache/gsd-update-check.json) but the SessionStart hook
|
|
* (hooks/gsd-check-update.js) writes to the shared tool-agnostic path
|
|
* ~/.cache/gsd/gsd-update-check.json. After a successful update, the statusline
|
|
* kept showing the stale "⬆ /gsd-update" indicator because the actual cache
|
|
* file was never deleted.
|
|
*
|
|
* Fix: add `rm -f "$HOME/.cache/gsd/gsd-update-check.json"` to the
|
|
* run_update step's cache-clear block in gsd-core/workflows/update.md.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const UPDATE_WORKFLOW = path.join(
|
|
REPO_ROOT,
|
|
'gsd-core',
|
|
'workflows',
|
|
'update.md'
|
|
);
|
|
const CHECK_UPDATE_HOOK = path.join(REPO_ROOT, 'hooks', 'gsd-check-update.js');
|
|
|
|
describe('bug-2784: update.md cache-clear covers shared cache path', () => {
|
|
test('gsd-check-update.js hook constructs cache dir from .cache and gsd path segments', () => {
|
|
const hookContent = fs.readFileSync(CHECK_UPDATE_HOOK, 'utf-8');
|
|
// Parse the path.join() call structurally rather than text-grepping.
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded hooks/gsd-check-update.js source, not adversarial input
|
|
const m = hookContent.match(/const cacheDir\s*=\s*path\.join\(([^)]+)\)/);
|
|
assert.ok(
|
|
m !== null,
|
|
'hook must assign cacheDir via path.join() with explicit path segments'
|
|
);
|
|
const segments = m[1].split(',').map((a) => a.trim().replace(/^['"]|['"]$/g, ''));
|
|
assert.ok(
|
|
segments.includes('.cache'),
|
|
`hook cacheDir path.join() must include '.cache' segment; got: ${JSON.stringify(segments)}`
|
|
);
|
|
assert.ok(
|
|
segments.includes('gsd'),
|
|
`hook cacheDir path.join() must include 'gsd' segment; got: ${JSON.stringify(segments)}`
|
|
);
|
|
});
|
|
|
|
test('update.md run_update bash commands include rm for shared gsd cache file', () => {
|
|
const workflowContent = fs.readFileSync(UPDATE_WORKFLOW, 'utf-8');
|
|
// Parse the step block structurally, then extract only bash fenced code lines.
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content
|
|
const stepMatch = workflowContent.match(/<step name="run_update">[\s\S]*?<\/step>/);
|
|
assert.ok(stepMatch, 'update.md must have a <step name="run_update"> block');
|
|
const stepContent = stepMatch[0];
|
|
|
|
const bashLines = [];
|
|
const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/g;
|
|
let m;
|
|
while ((m = fenceRe.exec(stepContent)) !== null) {
|
|
for (const line of m[1].split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (trimmed) bashLines.push(trimmed);
|
|
}
|
|
}
|
|
|
|
const sharedCacheClearCmds = bashLines.filter(
|
|
(line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check') && line.includes('*.json')
|
|
);
|
|
assert.ok(
|
|
sharedCacheClearCmds.length > 0,
|
|
[
|
|
'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check*.json (glob form clearing legacy + per-package variants).',
|
|
`Bash lines found: ${JSON.stringify(bashLines)}`,
|
|
].join('\n')
|
|
);
|
|
const hasHomeExpansion = sharedCacheClearCmds.some(
|
|
(line) => line.includes('$HOME') || line.includes('~/')
|
|
);
|
|
assert.ok(
|
|
hasHomeExpansion,
|
|
`shared cache rm command must use $HOME or ~/ expansion; found: ${JSON.stringify(sharedCacheClearCmds)}`
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/feat-2795-update-banner.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:feat-2795-update-banner (consolidation epic #1969 B5 #1974)", () => {
|
|
/**
|
|
* Tests for gsd-update-banner.js (#2795).
|
|
*
|
|
* The banner hook is an opt-in SessionStart consumer of the update cache that
|
|
* gsd-check-update-worker.js writes. When a user declines GSD's statusline,
|
|
* install.js may register this hook so update availability still surfaces in
|
|
* runtimes that use a non-GSD statusline.
|
|
*
|
|
* Tests follow the typed-IR convention (CONTRIBUTING.md "Prohibited: Raw Text
|
|
* Matching on Test Outputs"): assert on parsed JSON envelopes, not on raw
|
|
* stdout substrings.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { test, describe } = 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 { runHook: seamRunHook } = require('./helpers/process-seam.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-update-banner.js');
|
|
const {
|
|
buildBannerOutput,
|
|
shouldSuppressFailureWarning,
|
|
RATE_LIMIT_SECONDS,
|
|
} = require('../hooks/gsd-update-banner.js');
|
|
const { updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
|
|
|
|
// ─── Pure function: buildBannerOutput ───────────────────────────────────────
|
|
|
|
describe('buildBannerOutput', () => {
|
|
test('returns null when cache is missing', () => {
|
|
const out = buildBannerOutput({
|
|
cache: null,
|
|
parseError: false,
|
|
suppressFailureWarning: false,
|
|
});
|
|
assert.equal(out, null);
|
|
});
|
|
|
|
test('returns null when update_available is false', () => {
|
|
const out = buildBannerOutput({
|
|
cache: { update_available: false, installed: '1.40.0', latest: '1.40.0' },
|
|
parseError: false,
|
|
suppressFailureWarning: false,
|
|
});
|
|
assert.equal(out, null);
|
|
});
|
|
|
|
test('returns banner envelope when update_available is true', () => {
|
|
const out = buildBannerOutput({
|
|
cache: { update_available: true, installed: '1.39.0', latest: '1.40.0', package_name: '@opengsd/gsd-core' },
|
|
parseError: false,
|
|
suppressFailureWarning: false,
|
|
});
|
|
assert.ok(out, 'expected banner envelope');
|
|
assert.equal(typeof out.systemMessage, 'string');
|
|
assert.ok(
|
|
out.systemMessage.includes('1.39.0'),
|
|
'banner should name installed version'
|
|
);
|
|
assert.ok(
|
|
out.systemMessage.includes('1.40.0'),
|
|
'banner should name latest version'
|
|
);
|
|
assert.ok(
|
|
out.systemMessage.includes('/gsd:update'),
|
|
'banner should reference /gsd:update command'
|
|
);
|
|
});
|
|
|
|
test('returns failure diagnostic on parseError when not suppressed', () => {
|
|
const out = buildBannerOutput({
|
|
cache: null,
|
|
parseError: true,
|
|
suppressFailureWarning: false,
|
|
});
|
|
assert.ok(out, 'expected diagnostic envelope');
|
|
assert.equal(typeof out.systemMessage, 'string');
|
|
assert.ok(
|
|
/check failed/i.test(out.systemMessage),
|
|
'diagnostic should describe a failed check'
|
|
);
|
|
});
|
|
|
|
test('returns null on parseError when suppressed by rate limit', () => {
|
|
const out = buildBannerOutput({
|
|
cache: null,
|
|
parseError: true,
|
|
suppressFailureWarning: true,
|
|
});
|
|
assert.equal(out, null);
|
|
});
|
|
|
|
test('falls back to "unknown" when installed/latest missing', () => {
|
|
const out = buildBannerOutput({
|
|
cache: { update_available: true, package_name: '@opengsd/gsd-core' },
|
|
parseError: false,
|
|
suppressFailureWarning: false,
|
|
});
|
|
assert.ok(out);
|
|
assert.ok(
|
|
out.systemMessage.includes('unknown'),
|
|
'banner should degrade gracefully when versions are absent'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Pure function: shouldSuppressFailureWarning ────────────────────────────
|
|
|
|
describe('shouldSuppressFailureWarning', () => {
|
|
function tmpDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-supp-'));
|
|
}
|
|
|
|
test('returns false when sentinel file is missing', () => {
|
|
const dir = tmpDir();
|
|
try {
|
|
const result = shouldSuppressFailureWarning(
|
|
path.join(dir, 'no-such-file'),
|
|
100
|
|
);
|
|
assert.equal(result, false);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('returns true within rate-limit window', () => {
|
|
const dir = tmpDir();
|
|
try {
|
|
const f = path.join(dir, 'sentinel');
|
|
fs.writeFileSync(f, '1000');
|
|
const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS - 1);
|
|
assert.equal(result, true);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('returns false outside rate-limit window', () => {
|
|
const dir = tmpDir();
|
|
try {
|
|
const f = path.join(dir, 'sentinel');
|
|
fs.writeFileSync(f, '1000');
|
|
const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS + 1);
|
|
assert.equal(result, false);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('returns false when sentinel content is non-numeric', () => {
|
|
const dir = tmpDir();
|
|
try {
|
|
const f = path.join(dir, 'sentinel');
|
|
fs.writeFileSync(f, 'garbage-not-a-number');
|
|
const result = shouldSuppressFailureWarning(f, 100);
|
|
assert.equal(result, false);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── End-to-end: spawn the hook against fixture cache states ────────────────
|
|
|
|
describe('gsd-update-banner.js end-to-end', () => {
|
|
function setupHome() {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-home-'));
|
|
fs.mkdirSync(path.join(home, '.cache', 'gsd'), { recursive: true });
|
|
return home;
|
|
}
|
|
|
|
function runHook(home) {
|
|
// 10000ms: previously UNBOUNDED (no `timeout` option passed to
|
|
// spawnSync). gsd-update-banner.js only reads a small cache file from
|
|
// disk and prints JSON — no subprocess/network work — so 10s is
|
|
// generous headroom over its sub-second worst case even on a
|
|
// contended CI runner.
|
|
const r = seamRunHook(HOOK_PATH, [], {
|
|
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
timeoutMs: 10_000,
|
|
});
|
|
return { status: r.exitCode, stdout: r.stdout, stderr: r.stderr };
|
|
}
|
|
|
|
function writeCache(home, contents) {
|
|
fs.writeFileSync(
|
|
path.join(home, '.cache', 'gsd', updateCacheFileName),
|
|
typeof contents === 'string' ? contents : JSON.stringify(contents)
|
|
);
|
|
}
|
|
|
|
test('exits 0 with empty stdout when cache file missing', () => {
|
|
const home = setupHome();
|
|
try {
|
|
const r = runHook(home);
|
|
assert.equal(r.status, 0, `expected exit 0, got ${r.status} stderr=${r.stderr}`);
|
|
assert.equal(r.stdout.trim(), '');
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('emits valid SessionStart JSON when update_available=true', () => {
|
|
const home = setupHome();
|
|
try {
|
|
writeCache(home, {
|
|
update_available: true,
|
|
installed: '1.39.0',
|
|
latest: '1.40.0',
|
|
package_name: '@opengsd/gsd-core',
|
|
});
|
|
const r = runHook(home);
|
|
assert.equal(r.status, 0);
|
|
const parsed = JSON.parse(r.stdout);
|
|
assert.equal(typeof parsed.systemMessage, 'string');
|
|
assert.ok(parsed.systemMessage.includes('1.40.0'));
|
|
assert.ok(parsed.systemMessage.includes('/gsd:update'));
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('exits silent when update_available=false', () => {
|
|
const home = setupHome();
|
|
try {
|
|
writeCache(home, {
|
|
update_available: false,
|
|
installed: '1.40.0',
|
|
latest: '1.40.0',
|
|
});
|
|
const r = runHook(home);
|
|
assert.equal(r.status, 0);
|
|
assert.equal(r.stdout.trim(), '');
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('emits failure diagnostic when cache JSON is malformed', () => {
|
|
const home = setupHome();
|
|
try {
|
|
writeCache(home, 'not json {{{{');
|
|
const r = runHook(home);
|
|
assert.equal(r.status, 0);
|
|
const parsed = JSON.parse(r.stdout);
|
|
assert.equal(typeof parsed.systemMessage, 'string');
|
|
assert.ok(/check failed/i.test(parsed.systemMessage));
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('suppresses repeat failure diagnostic within 24h via sentinel', () => {
|
|
const home = setupHome();
|
|
try {
|
|
writeCache(home, 'not json');
|
|
const r1 = runHook(home);
|
|
assert.equal(
|
|
r1.status,
|
|
0,
|
|
`expected exit 0, got ${r1.status} stderr=${r1.stderr}`
|
|
);
|
|
const parsed1 = JSON.parse(r1.stdout);
|
|
assert.ok(/check failed/i.test(parsed1.systemMessage));
|
|
|
|
// Sentinel should now exist so the next run is silent
|
|
const sentinel = path.join(home, '.cache', 'gsd', 'banner-failure-warned-at');
|
|
assert.ok(fs.existsSync(sentinel), 'first run must record the warning sentinel');
|
|
|
|
const r2 = runHook(home);
|
|
assert.equal(r2.status, 0);
|
|
assert.equal(
|
|
r2.stdout.trim(),
|
|
'',
|
|
'subsequent run within rate-limit window must stay silent'
|
|
);
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('handles cache present but update_available field absent (older cache schema)', () => {
|
|
const home = setupHome();
|
|
try {
|
|
writeCache(home, { installed: '1.40.0', latest: '1.40.0' });
|
|
const r = runHook(home);
|
|
assert.equal(r.status, 0);
|
|
assert.equal(r.stdout.trim(), '');
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degrade, not crash ─────
|
|
//
|
|
// gsd-core/bin/lib/package-identity.cjs is a tsc build artifact (ADR-457),
|
|
// gitignored and absent on a raw plugin-marketplace / git-clone install that
|
|
// never ran `npm run build:lib`. This is an opt-in SessionStart hook — a
|
|
// build failure must degrade (PACKAGE_NAME stays null), not crash session
|
|
// start. The DEGRADED VERDICT this locks: buildBannerOutput's own lineage
|
|
// guard (`!cache.package_name || cache.package_name !== PACKAGE_NAME`)
|
|
// unconditionally distrusts ANY cache once PACKAGE_NAME is null, so even a
|
|
// cache written by a healthy worker (real package_name, update_available:
|
|
// true) must be suppressed rather than surfaced — the hook stays SILENT
|
|
// (exit 0, empty stdout), never a crash and never a stale/wrong banner.
|
|
// Simulated hermetically via tests/helpers/cold-runtime-lib-fixture.cjs — the
|
|
// REAL gsd-core/bin/lib/ is never touched.
|
|
describe('gsd-update-banner.js: #3582 cold tree — degrades to silent, never crashes', () => {
|
|
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
|
|
|
|
test('missing compiled runtime library -> exits 0 with empty stdout even for an otherwise-valid update-available cache', (t) => {
|
|
const cold = buildColdInstallTree();
|
|
t.after(cold.cleanup);
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-cold-home-'));
|
|
t.after(() => cleanup(home));
|
|
fs.mkdirSync(path.join(home, '.cache', 'gsd'), { recursive: true });
|
|
// The generic fallback filename the hook's own #3582 degrade uses when
|
|
// package-identity.cjs cannot be built (mirrors gsd-check-update.js's
|
|
// identical fallback literal) — this is the SAME cache path a degraded
|
|
// worker would also have written to.
|
|
fs.writeFileSync(
|
|
path.join(home, '.cache', 'gsd', 'gsd-update-check.json'),
|
|
JSON.stringify({
|
|
update_available: true,
|
|
installed: '1.39.0',
|
|
latest: '1.40.0',
|
|
package_name: '@opengsd/gsd-core',
|
|
}),
|
|
);
|
|
|
|
const r = seamRunHook(path.join(cold.hooksDir, 'gsd-update-banner.js'), [], {
|
|
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
timeoutMs: 10_000,
|
|
});
|
|
|
|
assert.equal(r.exitCode, 0, `hook must exit 0 on a build failure; stderr: ${r.stderr}`);
|
|
assert.equal(
|
|
r.stdout.trim(),
|
|
'',
|
|
'a cold tree must silently suppress the banner (PACKAGE_NAME degrades to null, ' +
|
|
'so the lineage guard distrusts every cache) rather than crash or print stale content',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Install.js wiring: prompt + SessionStart entry registration ────────────
|
|
//
|
|
// These tests load bin/install.js as a module via GSD_TEST_MODE and assert on
|
|
// pure exported helpers. The shape mirrors how runtime-prompt-builder /
|
|
// statusline tests interact with install.js.
|
|
|
|
describe('install.js update-banner wiring', () => {
|
|
process.env.GSD_TEST_MODE = '1';
|
|
// Re-require fresh so test-mode exports are populated.
|
|
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
|
|
delete require.cache[installPath];
|
|
const installExports = require(installPath);
|
|
|
|
test('exports buildUpdateBannerPromptText for structural prompt assertions', () => {
|
|
assert.equal(
|
|
typeof installExports.buildUpdateBannerPromptText,
|
|
'function',
|
|
'install.js must export buildUpdateBannerPromptText so tests can assert without grepping source'
|
|
);
|
|
const text = installExports.buildUpdateBannerPromptText();
|
|
assert.equal(typeof text, 'string');
|
|
assert.ok(text.length > 0);
|
|
// Strip ANSI color escapes before structural assertions — the choice
|
|
// digits are wrapped in color codes so word-boundary regex against the
|
|
// raw text would miss them.
|
|
// eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output
|
|
const stripped = text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
// Prompt must offer at least two choices (default + opt-in).
|
|
assert.match(stripped, /\b1\b/);
|
|
assert.match(stripped, /\b2\b/);
|
|
});
|
|
|
|
test('parseUpdateBannerInput defaults to false on empty / "1"', () => {
|
|
assert.equal(typeof installExports.parseUpdateBannerInput, 'function');
|
|
assert.equal(installExports.parseUpdateBannerInput(''), false);
|
|
assert.equal(installExports.parseUpdateBannerInput(' '), false);
|
|
assert.equal(installExports.parseUpdateBannerInput('1'), false);
|
|
});
|
|
|
|
test('parseUpdateBannerInput returns true on "2"', () => {
|
|
assert.equal(installExports.parseUpdateBannerInput('2'), true);
|
|
assert.equal(installExports.parseUpdateBannerInput('2 '), true);
|
|
});
|
|
|
|
test('parseUpdateBannerInput accepts "y" / "yes" affirmative shortcuts', () => {
|
|
assert.equal(installExports.parseUpdateBannerInput('y'), true);
|
|
assert.equal(installExports.parseUpdateBannerInput('Y'), true);
|
|
assert.equal(installExports.parseUpdateBannerInput('yes'), true);
|
|
assert.equal(installExports.parseUpdateBannerInput('YES'), true);
|
|
});
|
|
|
|
test('buildUpdateBannerHookEntry produces a SessionStart hook entry', () => {
|
|
assert.equal(typeof installExports.buildUpdateBannerHookEntry, 'function');
|
|
const entry = installExports.buildUpdateBannerHookEntry(
|
|
'"/usr/local/bin/node" "/home/u/.claude/hooks/gsd-update-banner.js"'
|
|
);
|
|
assert.ok(entry, 'expected hook entry object');
|
|
assert.ok(Array.isArray(entry.hooks), 'entry.hooks must be an array');
|
|
assert.equal(entry.hooks.length, 1);
|
|
assert.equal(entry.hooks[0].type, 'command');
|
|
assert.ok(
|
|
entry.hooks[0].command.includes('gsd-update-banner.js'),
|
|
'command must reference the banner hook'
|
|
);
|
|
});
|
|
|
|
test('buildUpdateBannerHookEntry returns null on null command', () => {
|
|
assert.equal(installExports.buildUpdateBannerHookEntry(null), null);
|
|
assert.equal(installExports.buildUpdateBannerHookEntry(''), null);
|
|
});
|
|
});
|
|
});
|
|
}
|