* 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>
2464 lines
98 KiB
JavaScript
2464 lines
98 KiB
JavaScript
/**
|
|
* Tests for gsd-statusline.js GSD state display helpers.
|
|
*
|
|
* Covers:
|
|
* - parseStateMd across YAML-frontmatter, body-fallback, and partial formats
|
|
* - formatGsdState graceful degradation when fields are missing
|
|
* - readGsdState walk-up search with proper bounds
|
|
*/
|
|
|
|
'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 {
|
|
parseStateMd,
|
|
formatGsdState,
|
|
formatGsdStateCompact,
|
|
readGsdState,
|
|
isInstalledAheadOfLatest,
|
|
} = require('../hooks/gsd-statusline.js');
|
|
const { cleanup, saveSessionEnv, restoreSessionEnv, clearSessionEnv } = require('./helpers.cjs');
|
|
|
|
// ─── parseStateMd ───────────────────────────────────────────────────────────
|
|
|
|
describe('parseStateMd', () => {
|
|
test('parses full YAML frontmatter', () => {
|
|
const content = [
|
|
'---',
|
|
'status: executing',
|
|
'milestone: v1.9',
|
|
'milestone_name: Code Quality',
|
|
'---',
|
|
'',
|
|
'# State',
|
|
'Phase: 1 of 5 (fix-graphiti-deployment)',
|
|
].join('\n');
|
|
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.status, 'executing');
|
|
assert.equal(s.milestone, 'v1.9');
|
|
assert.equal(s.milestoneName, 'Code Quality');
|
|
assert.equal(s.phaseNum, '1');
|
|
assert.equal(s.phaseTotal, '5');
|
|
assert.equal(s.phaseName, 'fix-graphiti-deployment');
|
|
});
|
|
|
|
test('treats literal "null" values as null', () => {
|
|
const content = [
|
|
'---',
|
|
'status: null',
|
|
'milestone: null',
|
|
'milestone_name: null',
|
|
'---',
|
|
].join('\n');
|
|
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.status, null);
|
|
assert.equal(s.milestone, null);
|
|
assert.equal(s.milestoneName, null);
|
|
});
|
|
|
|
test('strips surrounding quotes from frontmatter values', () => {
|
|
const content = [
|
|
'---',
|
|
'milestone_name: "Code Quality"',
|
|
"milestone: 'v1.9'",
|
|
'---',
|
|
].join('\n');
|
|
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.milestone, 'v1.9');
|
|
assert.equal(s.milestoneName, 'Code Quality');
|
|
});
|
|
|
|
test('parses phase without name', () => {
|
|
const content = [
|
|
'---',
|
|
'status: planning',
|
|
'---',
|
|
'Phase: 3 of 10',
|
|
].join('\n');
|
|
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.phaseNum, '3');
|
|
assert.equal(s.phaseTotal, '10');
|
|
assert.equal(s.phaseName, null);
|
|
});
|
|
|
|
test('falls back to body Status when frontmatter is missing', () => {
|
|
const content = [
|
|
'# State',
|
|
'Status: Ready to plan',
|
|
].join('\n');
|
|
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.status, 'planning');
|
|
});
|
|
|
|
test('body fallback recognizes executing state', () => {
|
|
const content = 'Status: Executing phase 2';
|
|
assert.equal(parseStateMd(content).status, 'executing');
|
|
});
|
|
|
|
test('body fallback recognizes complete state', () => {
|
|
const content = 'Status: Complete';
|
|
assert.equal(parseStateMd(content).status, 'complete');
|
|
});
|
|
|
|
test('body fallback recognizes archived as complete', () => {
|
|
const content = 'Status: Archived';
|
|
assert.equal(parseStateMd(content).status, 'complete');
|
|
});
|
|
|
|
test('returns empty object for empty content', () => {
|
|
const s = parseStateMd('');
|
|
assert.deepEqual(s, {});
|
|
});
|
|
|
|
test('returns partial state when only some fields present', () => {
|
|
const content = [
|
|
'---',
|
|
'milestone: v2.0',
|
|
'---',
|
|
].join('\n');
|
|
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.milestone, 'v2.0');
|
|
assert.equal(s.status, undefined);
|
|
assert.equal(s.phaseNum, undefined);
|
|
});
|
|
|
|
test('parses next_phases from YAML block-list form (#3153)', () => {
|
|
const content = [
|
|
'---',
|
|
'next_action: execute',
|
|
'next_phases:',
|
|
' - 4.5',
|
|
' - 4.6',
|
|
'---',
|
|
].join('\n');
|
|
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.nextAction, 'execute');
|
|
assert.deepEqual(s.nextPhases, ['4.5', '4.6']);
|
|
});
|
|
|
|
// #2754 — the frontmatter fence regex and downstream splits used literal \n,
|
|
// so a CRLF STATE.md dropped the ENTIRE frontmatter block (every field absent).
|
|
// The invariant is CRLF/LF parity — parseStateMd must yield the same state for
|
|
// the same content regardless of line endings, matching the canonical
|
|
// extractFrontmatter parser (src/frontmatter.cts), which is CRLF-safe.
|
|
const crlf = (lfContent) => lfContent.replace(/\n/g, '\r\n');
|
|
|
|
test('parses full YAML frontmatter identically under CRLF (#2754)', () => {
|
|
const lf = [
|
|
'---',
|
|
'status: executing',
|
|
'milestone: v1.9',
|
|
'milestone_name: Code Quality',
|
|
'active_phase: 4',
|
|
'next_action: execute',
|
|
'---',
|
|
'',
|
|
'# State',
|
|
'Phase: 1 of 5 (fix-graphiti-deployment)',
|
|
].join('\n');
|
|
|
|
const lfState = parseStateMd(lf);
|
|
const crlfState = parseStateMd(crlf(lf));
|
|
assert.deepStrictEqual(crlfState, lfState,
|
|
'CRLF STATE.md must parse identically to LF — pre-fix the entire frontmatter block was dropped (#2754)');
|
|
// pin the specific fields the issue names so a vacuous deepEqual({},{}) can't pass:
|
|
assert.equal(crlfState.status, 'executing');
|
|
assert.equal(crlfState.milestone, 'v1.9');
|
|
assert.equal(crlfState.milestoneName, 'Code Quality');
|
|
assert.equal(crlfState.activePhase, '4');
|
|
assert.equal(crlfState.nextAction, 'execute');
|
|
assert.equal(crlfState.phaseNum, '1');
|
|
assert.equal(crlfState.phaseTotal, '5');
|
|
});
|
|
|
|
test('parses next_phases flow-array form identically under CRLF (#2754)', () => {
|
|
const lf = [
|
|
'---',
|
|
'next_phases: [4.5, 4.6]',
|
|
'---',
|
|
].join('\n');
|
|
|
|
assert.deepStrictEqual(parseStateMd(crlf(lf)), parseStateMd(lf));
|
|
assert.deepEqual(parseStateMd(crlf(lf)).nextPhases, ['4.5', '4.6']);
|
|
});
|
|
|
|
test('parses next_phases block-list form identically under CRLF (#2754)', () => {
|
|
const lf = [
|
|
'---',
|
|
'next_phases:',
|
|
' - 4.5',
|
|
' - 4.6',
|
|
'---',
|
|
].join('\n');
|
|
|
|
assert.deepStrictEqual(parseStateMd(crlf(lf)).nextPhases, parseStateMd(lf).nextPhases,
|
|
'block-list regex used a literal \\n — CRLF must still parse the list');
|
|
assert.deepEqual(parseStateMd(crlf(lf)).nextPhases, ['4.5', '4.6']);
|
|
});
|
|
|
|
test('parses the progress nested block identically under CRLF (#2754)', () => {
|
|
const lf = [
|
|
'---',
|
|
'progress:',
|
|
' completed_phases: 3',
|
|
' total_phases: 5',
|
|
' percent: 60',
|
|
'---',
|
|
].join('\n');
|
|
|
|
assert.deepStrictEqual(parseStateMd(crlf(lf)), parseStateMd(lf),
|
|
'progress block regex used a literal \\n — CRLF must still parse completed/total/percent');
|
|
assert.equal(parseStateMd(crlf(lf)).completedPhases, '3');
|
|
assert.equal(parseStateMd(crlf(lf)).totalPhases, '5');
|
|
assert.equal(parseStateMd(crlf(lf)).percent, '60');
|
|
});
|
|
|
|
test('treats literal "null" values as null identically under CRLF (#2754)', () => {
|
|
const lf = [
|
|
'---',
|
|
'status: null',
|
|
'milestone: null',
|
|
'milestone_name: null',
|
|
'---',
|
|
].join('\n');
|
|
|
|
assert.deepStrictEqual(parseStateMd(crlf(lf)), parseStateMd(lf));
|
|
assert.equal(parseStateMd(crlf(lf)).status, null);
|
|
assert.equal(parseStateMd(crlf(lf)).milestone, null);
|
|
});
|
|
|
|
// #2754 (Generative-Fix Divergence guard, CLAUDE.md "parallel surfaces sharing a
|
|
// parser"): parseStateMd and the canonical extractFrontmatter both derive GSD
|
|
// state from the same STATE.md frontmatter, and they diverged once already (this
|
|
// very CRLF bug). Pin the overlap so a future divergence on a scalar shape or
|
|
// line ending is caught here, not in a live Windows report.
|
|
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
|
|
|
test('parseStateMd frontmatter-derived fields agree with extractFrontmatter (LF + CRLF, #2754)', () => {
|
|
const lf = [
|
|
'---',
|
|
'status: executing',
|
|
'milestone: v1.9',
|
|
'milestone_name: Code Quality',
|
|
'active_phase: 4',
|
|
'next_action: execute',
|
|
'---',
|
|
'',
|
|
'# State',
|
|
'Phase: 1 of 5 (fix-graphiti-deployment)',
|
|
].join('\n');
|
|
|
|
for (const [label, content] of [['LF', lf], ['CRLF', crlf(lf)]]) {
|
|
const fm = extractFrontmatter(content);
|
|
const st = parseStateMd(content);
|
|
// extractFrontmatter yields the raw frontmatter object; parseStateMd projects
|
|
// a subset onto its own keys. Assert the projection matches the raw values.
|
|
assert.equal(st.status, fm.status, `status mismatch (${label})`);
|
|
assert.equal(st.milestone, fm.milestone, `milestone mismatch (${label})`);
|
|
assert.equal(st.milestoneName, fm.milestone_name, `milestone_name mismatch (${label})`);
|
|
assert.equal(st.activePhase, String(fm.active_phase), `active_phase mismatch (${label})`);
|
|
assert.equal(st.nextAction, fm.next_action, `next_action mismatch (${label})`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── formatGsdState ─────────────────────────────────────────────────────────
|
|
|
|
describe('formatGsdState', () => {
|
|
test('formats full state with milestone name, status, and phase name', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v1.9',
|
|
milestoneName: 'Code Quality',
|
|
status: 'executing',
|
|
phaseNum: '1',
|
|
phaseTotal: '5',
|
|
phaseName: 'fix-graphiti-deployment',
|
|
});
|
|
assert.equal(out, 'v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)');
|
|
});
|
|
|
|
test('skips placeholder "milestone" value in milestoneName', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v1.0',
|
|
milestoneName: 'milestone',
|
|
status: 'planning',
|
|
});
|
|
assert.equal(out, 'v1.0 · planning');
|
|
});
|
|
|
|
test('uses short phase form when phase name is missing', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
status: 'executing',
|
|
phaseNum: '3',
|
|
phaseTotal: '7',
|
|
});
|
|
assert.equal(out, 'v2.0 · executing · ph 3/7');
|
|
});
|
|
|
|
test('omits phase entirely when phaseNum/phaseTotal missing', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v1.0',
|
|
status: 'planning',
|
|
});
|
|
assert.equal(out, 'v1.0 · planning');
|
|
});
|
|
|
|
test('handles milestone version only (no name)', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v1.9',
|
|
status: 'executing',
|
|
});
|
|
assert.equal(out, 'v1.9 · executing');
|
|
});
|
|
|
|
test('handles milestone name only (no version)', () => {
|
|
const out = formatGsdState({
|
|
milestoneName: 'Foundations',
|
|
status: 'planning',
|
|
});
|
|
assert.equal(out, 'Foundations · planning');
|
|
});
|
|
|
|
test('treats numeric 100 percent as milestone complete (#3153)', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
percent: 100,
|
|
});
|
|
assert.equal(out, 'v2.0 [██████████] 100% · milestone complete');
|
|
});
|
|
|
|
test('returns empty string for empty state', () => {
|
|
assert.equal(formatGsdState({}), '');
|
|
});
|
|
|
|
test('returns only available parts when everything else is missing', () => {
|
|
assert.equal(formatGsdState({ status: 'planning' }), 'planning');
|
|
});
|
|
|
|
test('renders observable "no active workstream" signal for the #2850 sentinel', () => {
|
|
assert.equal(formatGsdState({ noActiveWorkstream: true }), 'no active workstream');
|
|
});
|
|
});
|
|
|
|
describe('formatGsdStateCompact — #2850 sentinel', () => {
|
|
test('renders observable "no active workstream" signal', () => {
|
|
assert.equal(formatGsdStateCompact({ noActiveWorkstream: true }), 'no active workstream');
|
|
});
|
|
});
|
|
|
|
describe('isInstalledAheadOfLatest', () => {
|
|
test('treats prerelease patch increment as ahead of prior stable', () => {
|
|
assert.equal(isInstalledAheadOfLatest('1.2.1-beta.1', '1.2.0'), true);
|
|
});
|
|
|
|
test('treats equal base version prerelease as not ahead', () => {
|
|
assert.equal(isInstalledAheadOfLatest('1.2.0-rc.1', '1.2.0'), false);
|
|
});
|
|
});
|
|
|
|
// ─── readGsdState ───────────────────────────────────────────────────────────
|
|
|
|
describe('readGsdState', () => {
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-statusline-test-'));
|
|
|
|
test('finds STATE.md in the starting directory', () => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(proj, '.planning', 'STATE.md'),
|
|
'---\nstatus: executing\nmilestone: v1.0\n---\n'
|
|
);
|
|
|
|
const s = readGsdState(proj);
|
|
assert.equal(s.status, 'executing');
|
|
assert.equal(s.milestone, 'v1.0');
|
|
});
|
|
|
|
test('walks up to find STATE.md in a parent directory', () => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(proj, '.planning', 'STATE.md'),
|
|
'---\nstatus: planning\n---\n'
|
|
);
|
|
|
|
const nested = path.join(proj, 'src', 'components', 'deep');
|
|
fs.mkdirSync(nested, { recursive: true });
|
|
|
|
const s = readGsdState(nested);
|
|
assert.equal(s.status, 'planning');
|
|
});
|
|
|
|
test('returns null when no STATE.md exists in the walk-up chain', () => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
const nested = path.join(proj, 'src');
|
|
fs.mkdirSync(nested, { recursive: true });
|
|
|
|
assert.equal(readGsdState(nested), null);
|
|
});
|
|
|
|
test('returns null on malformed STATE.md without crashing', () => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning'), { recursive: true });
|
|
// Valid file (no content to crash on) — parseStateMd returns {}
|
|
fs.writeFileSync(path.join(proj, '.planning', 'STATE.md'), '');
|
|
|
|
const s = readGsdState(proj);
|
|
// Empty file yields an empty state object, not null — the function
|
|
// only returns null when no file is found.
|
|
assert.deepEqual(s, {});
|
|
});
|
|
|
|
// ─── Workstream mode (#2850) ──────────────────────────────────────────────
|
|
//
|
|
// readGsdState previously only ever walked up looking for a flat
|
|
// .planning/STATE.md — it never resolved an active workstream, so the
|
|
// GSD-state segment silently disappeared for any workstream-mode project
|
|
// without a root STATE.md (issue #2850). These cases exercise the fix,
|
|
// which reuses resolveActiveWorkstream (CLI>env>store precedence,
|
|
// active-workstream-store.cjs) and listAvailableWorkstreams/planningPaths
|
|
// (planning-workspace.cjs) rather than re-implementing that resolution.
|
|
//
|
|
// saveSessionEnv/restoreSessionEnv/clearSessionEnv come from tests/helpers.cjs
|
|
// (shared with tests/active-workstream-store.unit.test.cjs — see that file's
|
|
// comment; #2850 code review caught the two local copies had diverged).
|
|
|
|
test('resolves active workstream via GSD_WORKSTREAM env when no root STATE.md exists (#2850)', (t) => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(proj, '.planning', 'workstreams', 'bot', 'STATE.md'),
|
|
'---\nstatus: executing\nmilestone: v1.0\n---\n'
|
|
);
|
|
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
t.after(() => restoreSessionEnv(saved));
|
|
process.env.GSD_WORKSTREAM = 'bot';
|
|
|
|
const s = readGsdState(proj);
|
|
assert.notEqual(s, null, 'must not silently return null when a workstream resolves');
|
|
assert.equal(s.status, 'executing');
|
|
assert.equal(s.milestone, 'v1.0');
|
|
});
|
|
|
|
test('resolves via GSD_WORKSTREAM env with CRLF frontmatter (#2850)', (t) => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(proj, '.planning', 'workstreams', 'bot', 'STATE.md'),
|
|
'---\r\nstatus: planning\r\nmilestone: v2.0\r\n---\r\n'
|
|
);
|
|
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
t.after(() => restoreSessionEnv(saved));
|
|
process.env.GSD_WORKSTREAM = 'bot';
|
|
|
|
const s = readGsdState(proj);
|
|
assert.equal(s.status, 'planning');
|
|
assert.equal(s.milestone, 'v2.0');
|
|
});
|
|
|
|
test('flat root STATE.md takes precedence over workstream mode when both exist (#2850)', (t) => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(proj, '.planning', 'STATE.md'),
|
|
'---\nstatus: executing\nmilestone: v-flat\n---\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(proj, '.planning', 'workstreams', 'bot', 'STATE.md'),
|
|
'---\nstatus: planning\nmilestone: v-ws\n---\n'
|
|
);
|
|
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
t.after(() => restoreSessionEnv(saved));
|
|
process.env.GSD_WORKSTREAM = 'bot';
|
|
|
|
const s = readGsdState(proj);
|
|
assert.equal(s.milestone, 'v-flat', 'flat STATE.md must win — flat-mode behavior stays byte-for-byte unchanged');
|
|
});
|
|
|
|
test('returns an observable "no active workstream" signal when nothing resolves (#2850)', (t) => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'other'), { recursive: true });
|
|
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
t.after(() => restoreSessionEnv(saved));
|
|
|
|
const s = readGsdState(proj);
|
|
assert.deepEqual(s, { noActiveWorkstream: true });
|
|
});
|
|
|
|
test('resolved workstream with no STATE.md yet degrades to null without crashing (#2850)', (t) => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
|
// No STATE.md written under workstreams/bot/ — workstream exists, state doesn't yet.
|
|
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
t.after(() => restoreSessionEnv(saved));
|
|
process.env.GSD_WORKSTREAM = 'bot';
|
|
|
|
assert.equal(readGsdState(proj), null);
|
|
});
|
|
|
|
test('resolves active workstream via the stored shared pointer file (#2850)', (t) => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(proj, '.planning', 'workstreams', 'bot', 'STATE.md'),
|
|
'---\nstatus: verifying\nmilestone: v3.0\n---\n'
|
|
);
|
|
fs.writeFileSync(path.join(proj, '.planning', 'active-workstream'), 'bot\n');
|
|
|
|
const { _resetControllingTtyCacheForTests } = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
_resetControllingTtyCacheForTests();
|
|
t.after(() => {
|
|
restoreSessionEnv(saved);
|
|
_resetControllingTtyCacheForTests();
|
|
});
|
|
|
|
const s = readGsdState(proj);
|
|
assert.equal(s.status, 'verifying');
|
|
assert.equal(s.milestone, 'v3.0');
|
|
});
|
|
|
|
test('whitespace-only stored pointer file is treated as no active workstream (#2850)', (t) => {
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
|
fs.writeFileSync(path.join(proj, '.planning', 'active-workstream'), ' \n');
|
|
|
|
const { _resetControllingTtyCacheForTests } = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
_resetControllingTtyCacheForTests();
|
|
t.after(() => {
|
|
restoreSessionEnv(saved);
|
|
_resetControllingTtyCacheForTests();
|
|
});
|
|
|
|
assert.deepEqual(readGsdState(proj), { noActiveWorkstream: true });
|
|
});
|
|
|
|
test('stored pointer naming an absent workstream dir degrades to the sentinel WITHOUT deleting the pointer file (#2850)', (t) => {
|
|
// Regression for a review finding: readGsdState previously resolved via
|
|
// resolveActiveWorkstream's DEFAULT store lookup (getActiveWorkstream),
|
|
// which self-heals a stale pointer by deleting it (adapter.clear() in
|
|
// active-workstream-store.cts). The statusline renders once per prompt,
|
|
// so a merely-stale pointer (mid-rename, mid-cleanup, or any transient
|
|
// absence of the workstream dir) would be silently unlinked by a hook
|
|
// whose only job is to display text — violating issue #2850's AC4 ("the
|
|
// fix is purely additive to what's displayed"). The fix routes through
|
|
// peekActiveWorkstream, a read-only sibling that never calls clear().
|
|
// This test asserts BOTH halves: the render still degrades usefully,
|
|
// AND the pointer file survives the render untouched.
|
|
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
|
// workstream mode is detected via .planning/workstreams/ existing — but
|
|
// note it does NOT contain a 'ghost' directory, so the pointer below
|
|
// names a workstream that does not exist.
|
|
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'other'), { recursive: true });
|
|
const pointerPath = path.join(proj, '.planning', 'active-workstream');
|
|
fs.writeFileSync(pointerPath, 'ghost\n');
|
|
|
|
const { _resetControllingTtyCacheForTests } = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
_resetControllingTtyCacheForTests();
|
|
t.after(() => {
|
|
restoreSessionEnv(saved);
|
|
_resetControllingTtyCacheForTests();
|
|
});
|
|
|
|
assert.equal(fs.existsSync(pointerPath), true, 'precondition: pointer file must exist before the render');
|
|
|
|
const s = readGsdState(proj);
|
|
|
|
assert.deepEqual(s, { noActiveWorkstream: true }, 'a stale pointer must still degrade to the observable sentinel');
|
|
assert.equal(
|
|
fs.existsSync(pointerPath),
|
|
true,
|
|
'a read-only render must never delete the pointer file — that is a write, and the statusline must be purely additive to what is displayed (#2850 AC4)'
|
|
);
|
|
assert.equal(
|
|
fs.readFileSync(pointerPath, 'utf8'),
|
|
'ghost\n',
|
|
'the pointer file content must be byte-for-byte unchanged by the render, not just present'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── CLAUDE_CODE_AUTO_COMPACT_WINDOW context meter (#2219) ──────────────────
|
|
|
|
describe('context meter respects CLAUDE_CODE_AUTO_COMPACT_WINDOW (#2219)', () => {
|
|
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
|
|
const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
|
|
|
|
/**
|
|
* Run the statusline hook with a synthetic context_window payload.
|
|
* Returns { normalizedUsed, rawUsedPct } where:
|
|
* - normalizedUsed: the buffer-adjusted % shown in the statusline bar
|
|
* (parsed from the hook's stdout ANSI output, e.g. "60%")
|
|
* - rawUsedPct: the raw value written to the bridge file (100 - remaining,
|
|
* CC-consistent per #2451 fix)
|
|
*/
|
|
function runHook(remainingPct, totalTokens, acwEnv) {
|
|
const sessionId = `test-2219-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const payload = JSON.stringify({
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: os.tmpdir() },
|
|
session_id: sessionId,
|
|
context_window: {
|
|
remaining_percentage: remainingPct,
|
|
total_tokens: totalTokens,
|
|
},
|
|
});
|
|
|
|
const env = { ...process.env };
|
|
if (acwEnv != null) {
|
|
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(acwEnv);
|
|
} else {
|
|
delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
|
}
|
|
|
|
const r = runHookSeam(hookPath, [], { input: payload, env, timeoutMs: 4000 });
|
|
const stdout = r.stdout;
|
|
|
|
// Parse normalized used% from the statusline bar output (e.g. "60%")
|
|
// Strip ANSI escape codes then extract the percentage digit(s) before "%"
|
|
// 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 clean = stdout.replace(/\x1b\[[0-9;]*m/g, '');
|
|
const match = clean.match(/(\d+)%/);
|
|
const normalizedUsed = match ? parseInt(match[1], 10) : null;
|
|
|
|
// Read raw used_pct from the bridge file (#2451: bridge stores raw CC value)
|
|
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
|
let rawUsedPct = null;
|
|
try {
|
|
const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf8'));
|
|
rawUsedPct = bridge.used_pct;
|
|
fs.unlinkSync(bridgePath);
|
|
} catch { /* bridge may not exist if hook exited early */ }
|
|
|
|
return { normalizedUsed, rawUsedPct };
|
|
}
|
|
|
|
test('default buffer (no env var): 50% remaining → ~60% normalized bar display', () => {
|
|
// Default 16.5% buffer: usableRemaining = (50 - 16.5) / (100 - 16.5) * 100 ≈ 40.12%
|
|
// normalized used ≈ 100 - 40.12 = 59.88 → rounded 60 (shown in statusline bar)
|
|
const { normalizedUsed } = runHook(50, 1_000_000, null);
|
|
assert.strictEqual(normalizedUsed, 60);
|
|
});
|
|
|
|
test('CLAUDE_CODE_AUTO_COMPACT_WINDOW=400000: 50% remaining → 100% normalized bar display', () => {
|
|
// ACW = 400k usable tokens out of 1M total → usable fraction = 40%, buffer = 60%.
|
|
// (1 - 400000/1000000) * 100 = 60% buffer. With 50% remaining already below the
|
|
// 60% buffer threshold, usableRemaining = max(0, (50-60)/(100-60)*100) = 0%,
|
|
// normalized used = 100 (bar shows full — context is within the compact-trigger buffer).
|
|
const { normalizedUsed } = runHook(50, 1_000_000, 400_000);
|
|
assert.strictEqual(normalizedUsed, 100);
|
|
});
|
|
|
|
test('CLAUDE_CODE_AUTO_COMPACT_WINDOW=0 falls back to default buffer', () => {
|
|
// Explicit "0" means unset — should behave like no env var (16.5% buffer)
|
|
const { normalizedUsed } = runHook(50, 1_000_000, 0);
|
|
assert.strictEqual(normalizedUsed, 60);
|
|
});
|
|
|
|
test('ACW exceeds total context: buffer clamped to 0% — used reflects real remaining', () => {
|
|
// Pathological: ACW > totalCtx → (1 - 2M/1M) * 100 = -100% → clamped to 0%.
|
|
// With 0% buffer, usableRemaining = 50%, normalized used = 50.
|
|
// The Math.max(0, ...) clamp prevents negative buffer from inverting the display.
|
|
const { normalizedUsed } = runHook(50, 1_000_000, 2_000_000);
|
|
assert.strictEqual(normalizedUsed, 50);
|
|
});
|
|
|
|
test('bridge used_pct is raw (CC-consistent) regardless of ACW setting (#2451)', () => {
|
|
// Fix for #2451: bridge used_pct must be raw (100 - remaining), not normalized.
|
|
// This ensures gsd-context-monitor warning messages match CC native /context.
|
|
// The ACW normalization only affects the statusline bar display, not the bridge.
|
|
const { rawUsedPct } = runHook(50, 1_000_000, 400_000);
|
|
assert.strictEqual(rawUsedPct, 50,
|
|
'bridge used_pct must be raw (100-50=50) regardless of CLAUDE_CODE_AUTO_COMPACT_WINDOW');
|
|
});
|
|
});
|
|
|
|
// ─── auto-compact buffer boundary tests (#1194) ─────────────────────────────
|
|
|
|
describe('context meter boundary: acw at/near totalCtx does not pin used at 100% (#1194)', () => {
|
|
const { execFileSync } = require('node:child_process');
|
|
const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
|
|
|
|
/**
|
|
* Run the hook with a given acw and totalTokens; remaining fixed at 50%.
|
|
* Returns the normalizedUsed percentage shown in the statusline bar.
|
|
*/
|
|
function runBoundaryHook(remainingPct, totalTokens, acwEnv) {
|
|
const sessionId = `test-1194-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const payload = JSON.stringify({
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: os.tmpdir() },
|
|
session_id: sessionId,
|
|
context_window: {
|
|
remaining_percentage: remainingPct,
|
|
total_tokens: totalTokens,
|
|
},
|
|
});
|
|
|
|
const env = { ...process.env };
|
|
if (acwEnv != null) {
|
|
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(acwEnv);
|
|
} else {
|
|
delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
|
}
|
|
|
|
let stdout = '';
|
|
try {
|
|
stdout = execFileSync(process.execPath, [hookPath], {
|
|
input: payload,
|
|
env,
|
|
encoding: 'utf8',
|
|
timeout: 4000,
|
|
});
|
|
} catch (e) {
|
|
stdout = e.stdout || '';
|
|
}
|
|
|
|
// Strip ANSI escape codes then extract the percentage digit(s) before "%"
|
|
// eslint-disable-next-line no-control-regex -- \x1b is the required leading byte of ANSI SGR sequences
|
|
const clean = stdout.replace(/\x1b\[[0-9;]*m/g, '');
|
|
const match = clean.match(/(\d+)%/);
|
|
return match ? parseInt(match[1], 10) : null;
|
|
}
|
|
|
|
// acw == totalCtx - 1 (one token below total): buffer is near-zero (≈0%),
|
|
// so the full window is usable. With 50% remaining the bar should show ~50%.
|
|
test('acw = totalCtx - 1: used reflects actual remaining context (≈50%)', () => {
|
|
const totalCtx = 1_000_000;
|
|
const acw = totalCtx - 1; // 999999
|
|
const used = runBoundaryHook(50, totalCtx, acw);
|
|
// buffer ≈ 0% → usableRemaining ≈ 50% → used ≈ 50. Accept 49-51 for rounding.
|
|
assert.ok(
|
|
used !== null && used >= 49 && used <= 51,
|
|
`expected used ≈ 50 when acw=totalCtx-1, got: ${used}`
|
|
);
|
|
});
|
|
|
|
// acw == totalCtx (the triggering edge case): buffer should be 0%,
|
|
// NOT 100%. The "used" value must reflect real remaining context, not 100.
|
|
test('acw = totalCtx: used MUST NOT stick at 100 (division-by-zero boundary)', () => {
|
|
const totalCtx = 1_000_000;
|
|
const acw = totalCtx; // 1000000
|
|
const used = runBoundaryHook(50, totalCtx, acw);
|
|
// Buffer = 0% → usableRemaining = 50% → used ≈ 50. Must not be 100.
|
|
assert.ok(
|
|
used !== null && used !== 100,
|
|
`expected used != 100 when acw==totalCtx (div-by-zero boundary), got: ${used}`
|
|
);
|
|
// Also assert the bar is in a sane range (should be around 50%)
|
|
assert.ok(
|
|
used >= 0 && used <= 99,
|
|
`expected used in 0-99 when acw==totalCtx, got: ${used}`
|
|
);
|
|
});
|
|
|
|
// acw == totalCtx + 1 (exceeds total): buffer would be negative without a clamp;
|
|
// the Math.max(0,...) clamp should keep buffer=0%, not a negative value.
|
|
test('acw = totalCtx + 1: does not produce negative buffer (clamp prevents it)', () => {
|
|
const totalCtx = 1_000_000;
|
|
const acw = totalCtx + 1; // 1000001
|
|
const used = runBoundaryHook(50, totalCtx, acw);
|
|
// Buffer clamped to 0 → used ≈ 50 (reflects real remaining, not 100)
|
|
assert.ok(
|
|
used !== null && used !== 100,
|
|
`expected used != 100 when acw=totalCtx+1, got: ${used}`
|
|
);
|
|
assert.ok(
|
|
used >= 0 && used <= 99,
|
|
`expected used in 0-99 when acw=totalCtx+1, got: ${used}`
|
|
);
|
|
});
|
|
|
|
// Default path (no env var / acw==0): must be unchanged. 50% remaining → ~60%.
|
|
test('acw = 0 (default path): unchanged, ~60% normalized for 50% remaining', () => {
|
|
const used = runBoundaryHook(50, 1_000_000, 0);
|
|
assert.strictEqual(used, 60, `default path must still produce 60, got: ${used}`);
|
|
});
|
|
|
|
// Normal partial value: 93% remaining → ~usesd ≈ 7% with default buffer.
|
|
test('normal partial value: 93% remaining → ~7% normalized used', () => {
|
|
// Default 16.5% buffer: usableRemaining = (93 - 16.5) / (100 - 16.5) * 100 = 91.6%
|
|
// used ≈ 100 - 91.6 = 8.4 → rounded 8
|
|
const used = runBoundaryHook(93, 1_000_000, null);
|
|
assert.ok(
|
|
used !== null && used >= 7 && used <= 10,
|
|
`expected used ≈ 7-10 for 93% remaining with default buffer, got: ${used}`
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── todo-resolution path (#305) ────────────────────────────────────────────
|
|
|
|
describe('todo-resolution: resolves in_progress task from the newest matching todos file (#305)', () => {
|
|
const { execFileSync } = require('node:child_process');
|
|
const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
|
|
|
|
test('resolves in_progress task from the newest matching todos file (#305)', (t) => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-305-'));
|
|
t.after(() => {
|
|
cleanup(tempDir);
|
|
});
|
|
|
|
const todosDir = path.join(tempDir, 'todos');
|
|
fs.mkdirSync(todosDir, { recursive: true });
|
|
|
|
const session = `sess-305-${Math.random().toString(36).slice(2)}`;
|
|
const now = Date.now() / 1000; // seconds for utimesSync
|
|
|
|
// Older matching file — should NOT be selected
|
|
const olderPath = path.join(todosDir, `${session}-agent-A.json`);
|
|
fs.writeFileSync(olderPath, JSON.stringify([
|
|
{ content: 'old task', status: 'in_progress', activeForm: 'OLDER TASK 305' },
|
|
]));
|
|
const olderTime = now - 10000;
|
|
fs.utimesSync(olderPath, olderTime, olderTime);
|
|
|
|
// Newer matching file — should be selected
|
|
const newerPath = path.join(todosDir, `${session}-agent-B.json`);
|
|
fs.writeFileSync(newerPath, JSON.stringify([
|
|
{ content: 'new task', status: 'in_progress', activeForm: 'NEWER TASK 305' },
|
|
]));
|
|
const newerTime = now - 1000;
|
|
fs.utimesSync(newerPath, newerTime, newerTime);
|
|
|
|
// Distractor: different session prefix — must be ignored even with very-new mtime
|
|
const wrongSessPath = path.join(todosDir, 'other-sess-agent-Z.json');
|
|
fs.writeFileSync(wrongSessPath, JSON.stringify([
|
|
{ content: 'wrong session', status: 'in_progress', activeForm: 'WRONG SESSION 305' },
|
|
]));
|
|
fs.utimesSync(wrongSessPath, now, now);
|
|
|
|
// Distractor: matches session + .json but lacks -agent- — must be ignored
|
|
const notAgentPath = path.join(todosDir, `${session}-notagent.json`);
|
|
fs.writeFileSync(notAgentPath, JSON.stringify([
|
|
{ content: 'not agent', status: 'in_progress', activeForm: 'NOT AGENT 305' },
|
|
]));
|
|
fs.utimesSync(notAgentPath, now, now);
|
|
|
|
const payload = JSON.stringify({
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: os.tmpdir() },
|
|
session_id: session,
|
|
context_window: { remaining_percentage: 80, total_tokens: 1_000_000 },
|
|
});
|
|
|
|
const env = { ...process.env, CLAUDE_CONFIG_DIR: tempDir };
|
|
|
|
let stdout = '';
|
|
try {
|
|
stdout = execFileSync(process.execPath, [hookPath], {
|
|
input: payload,
|
|
env,
|
|
encoding: 'utf8',
|
|
timeout: 4000,
|
|
});
|
|
} catch (e) {
|
|
stdout = e.stdout || '';
|
|
}
|
|
|
|
assert.ok(stdout.includes('NEWER TASK 305'),
|
|
`expected stdout to contain "NEWER TASK 305", got: ${stdout}`);
|
|
assert.ok(!stdout.includes('OLDER TASK 305'),
|
|
`stdout must NOT contain "OLDER TASK 305", got: ${stdout}`);
|
|
assert.ok(!stdout.includes('WRONG SESSION 305'),
|
|
`stdout must NOT contain "WRONG SESSION 305", got: ${stdout}`);
|
|
assert.ok(!stdout.includes('NOT AGENT 305'),
|
|
`stdout must NOT contain "NOT AGENT 305", got: ${stdout}`);
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/enh-2538-statusline-last-command.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:enh-2538-statusline-last-command (consolidation epic #1969 B5 #1974)", () => {
|
|
'use strict';
|
|
|
|
/**
|
|
* Enhancement #2538 — statusline `last: /cmd` suffix.
|
|
*
|
|
* Asserts that:
|
|
* - default (flag absent) output does NOT include "last:" text
|
|
* - with statusline.show_last_command=true AND a transcript containing
|
|
* <command-name>/gsd-plan-phase</command-name>, output includes "last: /gsd-plan-phase"
|
|
* - a missing transcript_path does not throw and produces no "last:" suffix
|
|
* - an existing transcript with no slash commands produces no "last:" suffix
|
|
* - the config key is registered in the schema so /gsd-settings can surface it
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const statusline = require('../hooks/gsd-statusline.js');
|
|
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs');
|
|
|
|
function makeProject({ flag, transcript }) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'enh-2538-'));
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
if (flag !== undefined) {
|
|
fs.writeFileSync(
|
|
path.join(dir, '.planning', 'config.json'),
|
|
JSON.stringify({ statusline: { show_last_command: flag } }),
|
|
);
|
|
}
|
|
let transcriptPath = null;
|
|
if (transcript !== undefined) {
|
|
transcriptPath = path.join(dir, 'transcript.jsonl');
|
|
fs.writeFileSync(transcriptPath, transcript);
|
|
}
|
|
return { dir, transcriptPath, cleanup: () => cleanup(dir) };
|
|
}
|
|
|
|
function buildInput(dir, transcriptPath) {
|
|
return {
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: dir },
|
|
session_id: 'test-session',
|
|
transcript_path: transcriptPath,
|
|
};
|
|
}
|
|
|
|
test('config schema registers statusline.show_last_command', () => {
|
|
assert.ok(
|
|
VALID_CONFIG_KEYS.has('statusline.show_last_command'),
|
|
'statusline.show_last_command must be in VALID_CONFIG_KEYS',
|
|
);
|
|
});
|
|
|
|
test('default (flag absent) output has no "last:" suffix', () => {
|
|
const transcript =
|
|
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\n';
|
|
const { dir, transcriptPath, cleanup } = makeProject({ transcript });
|
|
try {
|
|
const out = statusline.renderStatusline(buildInput(dir, transcriptPath));
|
|
assert.ok(!out.includes('last:'), `expected no "last:" in output; got: ${out}`);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test('flag=true with recorded command yields "last: /<cmd>"', () => {
|
|
const transcript =
|
|
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\n' +
|
|
JSON.stringify({ type: 'assistant', message: { content: 'ok' } }) + '\n';
|
|
const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript });
|
|
try {
|
|
const out = statusline.renderStatusline(buildInput(dir, transcriptPath));
|
|
assert.ok(out.includes('last: /gsd-plan-phase'), `expected "last: /gsd-plan-phase" in output; got: ${out}`);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test('flag=true picks the MOST RECENT command when multiple are present', () => {
|
|
const transcript =
|
|
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-discuss-phase</command-name>' } }) + '\n' +
|
|
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\n' +
|
|
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-execute-phase</command-name>' } }) + '\n';
|
|
const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript });
|
|
try {
|
|
const out = statusline.renderStatusline(buildInput(dir, transcriptPath));
|
|
assert.ok(out.includes('last: /gsd-execute-phase'), `expected most-recent "gsd-execute-phase"; got: ${out}`);
|
|
assert.ok(!out.includes('last: /gsd-discuss-phase'), `should not show stale command; got: ${out}`);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test('flag=true with missing transcript_path does not throw and omits suffix', () => {
|
|
const { dir, cleanup } = makeProject({ flag: true });
|
|
try {
|
|
let out;
|
|
assert.doesNotThrow(() => {
|
|
out = statusline.renderStatusline(buildInput(dir, undefined));
|
|
});
|
|
assert.ok(!out.includes('last:'), `expected no "last:" suffix when transcript missing; got: ${out}`);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test('flag=true with transcript lacking command tags omits suffix', () => {
|
|
const transcript =
|
|
JSON.stringify({ type: 'user', message: { content: 'just a plain prompt' } }) + '\n';
|
|
const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript });
|
|
try {
|
|
const out = statusline.renderStatusline(buildInput(dir, transcriptPath));
|
|
assert.ok(!out.includes('last:'), `expected no "last:" suffix with no commands; got: ${out}`);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test('readLastSlashCommand returns null for nonexistent paths', () => {
|
|
assert.strictEqual(statusline.readLastSlashCommand('/nonexistent/path.jsonl'), null);
|
|
assert.strictEqual(statusline.readLastSlashCommand(null), null);
|
|
assert.strictEqual(statusline.readLastSlashCommand(undefined), null);
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/enh-2833-phase-lifecycle-statusline.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:enh-2833-phase-lifecycle-statusline (consolidation epic #1969 B5 #1974)", () => {
|
|
/**
|
|
* Tests for issue #2833 — phase-lifecycle status-line.
|
|
*
|
|
* Covers the additions made by the two preceding feat commits:
|
|
*
|
|
* 1. parseStateMd reads four new STATE.md frontmatter fields
|
|
* - active_phase
|
|
* - next_action
|
|
* - next_phases (YAML flow array)
|
|
* - progress (nested block: completed_phases / total_phases / percent)
|
|
*
|
|
* 2. formatGsdState renders three new scenes when those fields are populated
|
|
* - Scene 1: active_phase set → "Phase X.Y <stage>"
|
|
* - Scene 2: idle + next_action set → "next <action> <phases>"
|
|
* - Scene 3: percent 100 / all done → "milestone complete"
|
|
* - Scene 4: default fallback → unchanged "<status> · <phase>"
|
|
*
|
|
* 3. renderProgressBar() helper for the opt-in milestone bar.
|
|
*
|
|
* 4. Backward compatibility — existing STATE.md files (without any of the
|
|
* new fields) render byte-for-byte identically to v1.38.x.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const {
|
|
parseStateMd,
|
|
formatGsdState,
|
|
} = require('../hooks/gsd-statusline.js');
|
|
|
|
// ─── parseStateMd: new lifecycle fields ─────────────────────────────────────
|
|
|
|
describe('parseStateMd #2833 lifecycle fields', () => {
|
|
test('reads active_phase from frontmatter', () => {
|
|
const content = [
|
|
'---',
|
|
'milestone: v2.0',
|
|
'status: executing',
|
|
'active_phase: "4.5"',
|
|
'---',
|
|
].join('\n');
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.activePhase, '4.5');
|
|
});
|
|
|
|
test('reads next_action from frontmatter', () => {
|
|
const content = [
|
|
'---',
|
|
'milestone: v2.0',
|
|
'next_action: execute-phase',
|
|
'---',
|
|
].join('\n');
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.nextAction, 'execute-phase');
|
|
});
|
|
|
|
test('treats "null" literal as null for active_phase and next_action', () => {
|
|
const content = [
|
|
'---',
|
|
'active_phase: null',
|
|
'next_action: null',
|
|
'---',
|
|
].join('\n');
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.activePhase, null);
|
|
assert.equal(s.nextAction, null);
|
|
});
|
|
|
|
test('parses next_phases YAML flow array (single item)', () => {
|
|
const content = [
|
|
'---',
|
|
'next_phases: ["4.5"]',
|
|
'---',
|
|
].join('\n');
|
|
const s = parseStateMd(content);
|
|
assert.deepEqual(s.nextPhases, ['4.5']);
|
|
});
|
|
|
|
test('parses next_phases YAML flow array (multiple items)', () => {
|
|
const content = [
|
|
'---',
|
|
'next_phases: ["4.5", "4.6", "5"]',
|
|
'---',
|
|
].join('\n');
|
|
const s = parseStateMd(content);
|
|
assert.deepEqual(s.nextPhases, ['4.5', '4.6', '5']);
|
|
});
|
|
|
|
test('parses progress nested block — all three fields', () => {
|
|
const content = [
|
|
'---',
|
|
'progress:',
|
|
' total_phases: 17',
|
|
' completed_phases: 10',
|
|
' percent: 59',
|
|
'---',
|
|
].join('\n');
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.totalPhases, '17');
|
|
assert.equal(s.completedPhases, '10');
|
|
assert.equal(s.percent, '59');
|
|
});
|
|
|
|
test('returns undefined for absent lifecycle fields', () => {
|
|
const content = [
|
|
'---',
|
|
'milestone: v1.9',
|
|
'status: executing',
|
|
'---',
|
|
].join('\n');
|
|
const s = parseStateMd(content);
|
|
assert.equal(s.activePhase, undefined);
|
|
assert.equal(s.nextAction, undefined);
|
|
assert.equal(s.nextPhases, undefined);
|
|
assert.equal(s.percent, undefined);
|
|
});
|
|
});
|
|
|
|
// ─── formatGsdState: new scenes ─────────────────────────────────────────────
|
|
|
|
describe('formatGsdState #2833 lifecycle scenes', () => {
|
|
test('Scene 1 — active_phase set renders "Phase X.Y <stage>"', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
status: 'executing',
|
|
activePhase: '4.5',
|
|
percent: '59',
|
|
});
|
|
assert.equal(out, 'v2.0 [█████░░░░░] 59% · Phase 4.5 executing');
|
|
});
|
|
|
|
test('Scene 1 — active_phase without status renders "Phase X.Y"', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
activePhase: '4.5',
|
|
});
|
|
assert.equal(out, 'v2.0 · Phase 4.5');
|
|
});
|
|
|
|
test('Scene 2 — idle + next_action renders "next <action> <phases>"', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
activePhase: null,
|
|
nextAction: 'execute-phase',
|
|
nextPhases: ['4.5'],
|
|
percent: '59',
|
|
});
|
|
assert.equal(out, 'v2.0 [█████░░░░░] 59% · next execute-phase 4.5');
|
|
});
|
|
|
|
test('Scene 2 — multiple next_phases joined with /', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
nextAction: 'discuss-phase',
|
|
nextPhases: ['4.7', '6.5'],
|
|
});
|
|
assert.equal(out, 'v2.0 · next discuss-phase 4.7/6.5');
|
|
});
|
|
|
|
test('Scene 3 — percent=100 renders "milestone complete"', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
percent: '100',
|
|
});
|
|
assert.equal(out, 'v2.0 [██████████] 100% · milestone complete');
|
|
});
|
|
|
|
test('Scene 3 — completed_phases equals total_phases also triggers complete', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
completedPhases: '17',
|
|
totalPhases: '17',
|
|
});
|
|
assert.equal(out, 'v2.0 · milestone complete');
|
|
});
|
|
});
|
|
|
|
// ─── Backward compatibility — CRITICAL: existing STATE.md unchanged ─────────
|
|
|
|
describe('formatGsdState #2833 backward compatibility', () => {
|
|
test('legacy STATE.md (only status + milestone + phase) renders unchanged', () => {
|
|
// Identical to the format documented in #1989 (the foundation issue).
|
|
// No new lifecycle fields populated → must render exactly as v1.38.x did.
|
|
const out = formatGsdState({
|
|
status: 'executing',
|
|
milestone: 'v1.9',
|
|
milestoneName: 'Code Quality',
|
|
phaseNum: '1',
|
|
phaseTotal: '5',
|
|
phaseName: 'fix-graphiti-deployment',
|
|
});
|
|
assert.equal(out, 'v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)');
|
|
});
|
|
|
|
test('only status set (no phase, no lifecycle fields) renders just "<milestone> · <status>"', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v1.9',
|
|
status: 'executing',
|
|
});
|
|
assert.equal(out, 'v1.9 · executing');
|
|
});
|
|
|
|
test('empty state renders empty string', () => {
|
|
const out = formatGsdState({});
|
|
assert.equal(out, '');
|
|
});
|
|
|
|
test('progress.percent is opt-in — absent percent leaves milestone segment unchanged', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v1.9',
|
|
milestoneName: 'Code Quality',
|
|
status: 'executing',
|
|
});
|
|
// No bar rendered when percent is absent.
|
|
assert.equal(out, 'v1.9 Code Quality · executing');
|
|
});
|
|
});
|
|
|
|
// ─── renderProgressBar (exported indirectly via formatGsdState behavior) ────
|
|
|
|
describe('progress bar rendering', () => {
|
|
test('0% renders 10 empty segments', () => {
|
|
// percent=0 doesn't trigger Scene 3 (only percent='100' does), so
|
|
// Scene 4 fallback fires with no extra parts — just milestone + bar.
|
|
const out = formatGsdState({ milestone: 'v2.0', percent: '0' });
|
|
assert.ok(out.includes('[░░░░░░░░░░] 0%'));
|
|
});
|
|
|
|
test('50% renders 5 filled + 5 empty', () => {
|
|
const out = formatGsdState({ milestone: 'v2.0', percent: '50' });
|
|
assert.ok(out.includes('[█████░░░░░] 50%'));
|
|
});
|
|
|
|
test('100% renders 10 filled (and triggers Scene 3)', () => {
|
|
const out = formatGsdState({ milestone: 'v2.0', percent: '100' });
|
|
assert.equal(out, 'v2.0 [██████████] 100% · milestone complete');
|
|
});
|
|
|
|
test('percent absent → no bar rendered (opt-in)', () => {
|
|
const out = formatGsdState({ milestone: 'v2.0', status: 'executing' });
|
|
assert.ok(!out.includes('['));
|
|
assert.ok(!out.includes('░'));
|
|
assert.ok(!out.includes('█'));
|
|
});
|
|
|
|
test('percent over 100 clamps to 100', () => {
|
|
const out = formatGsdState({ milestone: 'v2.0', percent: '150' });
|
|
assert.ok(out.includes('[██████████] 100%'));
|
|
});
|
|
|
|
test('percent below 0 clamps to 0', () => {
|
|
const out = formatGsdState({ milestone: 'v2.0', percent: '-10' });
|
|
assert.ok(out.includes('[░░░░░░░░░░] 0%'));
|
|
});
|
|
});
|
|
|
|
// ─── Scene priority — first-match-wins guarantee ────────────────────────────
|
|
|
|
describe('formatGsdState #2833 scene priority', () => {
|
|
test('active_phase wins over next_action when both populated', () => {
|
|
// active_phase populated should win — orchestrator is in flight,
|
|
// any "next" recommendation would be misleading.
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
status: 'executing',
|
|
activePhase: '4.5',
|
|
nextAction: 'execute-phase',
|
|
nextPhases: ['4.5'],
|
|
});
|
|
assert.ok(out.includes('Phase 4.5 executing'));
|
|
assert.ok(!out.includes('next execute-phase'));
|
|
});
|
|
|
|
test('next_action wins over Scene 4 fallback when active_phase null', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
status: 'in_progress', // would be Scene 4 fallback alone
|
|
activePhase: null,
|
|
nextAction: 'execute-phase',
|
|
nextPhases: ['4.5'],
|
|
phaseNum: '1',
|
|
phaseTotal: '5',
|
|
});
|
|
assert.ok(out.includes('next execute-phase 4.5'));
|
|
assert.ok(!out.includes('in_progress'));
|
|
assert.ok(!out.includes('1/5'));
|
|
});
|
|
|
|
test('percent=100 wins over Scene 4 even with phase set', () => {
|
|
const out = formatGsdState({
|
|
milestone: 'v2.0',
|
|
percent: '100',
|
|
phaseNum: '1',
|
|
phaseTotal: '5',
|
|
});
|
|
assert.ok(out.includes('milestone complete'));
|
|
assert.ok(!out.includes('1/5'));
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/enh-2937-statusline-context-position.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:enh-2937-statusline-context-position (consolidation epic #1969 B5 #1974)", () => {
|
|
'use strict';
|
|
|
|
/**
|
|
* Enhancement #2937 — statusline opt-in `context_position` config.
|
|
*
|
|
* Asserts that:
|
|
* - VALID_CONFIG_KEYS registers statusline.context_position (parity guard)
|
|
* - Default (no config) renders ctx at tail — "end" layout
|
|
* - Explicit "end" is byte-identical to default (regression guard)
|
|
* - Explicit "front" puts ctx after model, before first " │ "
|
|
* - Empty ctx with "front" leaves no stray separator
|
|
* - Invalid value (e.g. "middle") silently falls back to "end" at runtime
|
|
* - gsdUpdate warning stays leftmost in both "front" and "end" modes
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { composeStatusline } = require('../hooks/gsd-statusline.js');
|
|
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
// ── Parity guard ─────────────────────────────────────────────────────────────
|
|
|
|
test('config schema registers statusline.context_position', () => {
|
|
assert.ok(
|
|
VALID_CONFIG_KEYS.has('statusline.context_position'),
|
|
'statusline.context_position must be in VALID_CONFIG_KEYS',
|
|
);
|
|
});
|
|
|
|
// ── Default / "end" layout ───────────────────────────────────────────────────
|
|
|
|
test('default (no position arg) renders ctx at tail — end layout', () => {
|
|
const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m';
|
|
const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx });
|
|
// ctx should appear after dirname, not before first │
|
|
const dirIdx = out.indexOf('myproject');
|
|
const ctxIdx = out.indexOf(ctx);
|
|
assert.ok(ctxIdx > dirIdx, `ctx should be after dirname; got: ${out}`);
|
|
});
|
|
|
|
test('explicit "end" is byte-identical to default', () => {
|
|
const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m';
|
|
const args = { model: 'Claude', dirname: 'myproject', ctx };
|
|
const defaultOut = composeStatusline(args);
|
|
const endOut = composeStatusline({ ...args, position: 'end' });
|
|
assert.strictEqual(endOut, defaultOut, 'explicit "end" must equal default output');
|
|
});
|
|
|
|
test('"end" with middle segment places ctx after dirname', () => {
|
|
const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m';
|
|
const out = composeStatusline({ model: 'Claude', ctx, middle: 'doing work', dirname: 'proj', position: 'end' });
|
|
const dirIdx = out.indexOf('proj');
|
|
const ctxIdx = out.indexOf(ctx);
|
|
assert.ok(ctxIdx > dirIdx, `ctx should be after dirname in end mode; got: ${out}`);
|
|
});
|
|
|
|
// ── "front" layout ───────────────────────────────────────────────────────────
|
|
|
|
test('"front" puts ctx after model name, before first │', () => {
|
|
const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m';
|
|
const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'front' });
|
|
const firstPipe = out.indexOf(' │ ');
|
|
const ctxIdx = out.indexOf(ctx);
|
|
assert.ok(ctxIdx !== -1, `ctx should appear in output; got: ${out}`);
|
|
assert.ok(ctxIdx < firstPipe, `ctx should come before first │ in front mode; got: ${out}`);
|
|
});
|
|
|
|
test('"front" with middle segment: ctx after model, before first │', () => {
|
|
const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m';
|
|
const out = composeStatusline({ model: 'Claude', ctx, middle: 'doing work', dirname: 'proj', position: 'front' });
|
|
const firstPipe = out.indexOf(' │ ');
|
|
const ctxIdx = out.indexOf(ctx);
|
|
assert.ok(ctxIdx < firstPipe, `ctx must precede first │; got: ${out}`);
|
|
});
|
|
|
|
// ── Empty ctx ────────────────────────────────────────────────────────────────
|
|
|
|
test('empty ctx + "front" renders no stray separator', () => {
|
|
const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx: '', position: 'front' });
|
|
// Should not have double-separator or leading │
|
|
assert.ok(!out.includes(' │ │ '), `stray separator found; got: ${out}`);
|
|
// Should still contain the single separator between model area and dirname
|
|
assert.ok(out.includes(' │ '), `expected at least one separator; got: ${out}`);
|
|
});
|
|
|
|
test('empty ctx + "end" renders no stray separator', () => {
|
|
const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx: '', position: 'end' });
|
|
assert.ok(!out.includes(' │ │ '), `stray separator found; got: ${out}`);
|
|
});
|
|
|
|
// ── Invalid value fallback ───────────────────────────────────────────────────
|
|
|
|
test('invalid position value silently falls back to "end" layout', () => {
|
|
const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m';
|
|
const invalid = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'middle' });
|
|
const end = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'end' });
|
|
assert.strictEqual(invalid, end, `invalid position should produce same output as "end"; got: ${invalid}`);
|
|
});
|
|
|
|
test('invalid position "banana" silently falls back to "end"', () => {
|
|
const ctx = ' \x1b[33m██████░░░░ 60%\x1b[0m';
|
|
const invalid = composeStatusline({ model: 'Claude', dirname: 'proj', ctx, position: 'banana' });
|
|
const end = composeStatusline({ model: 'Claude', dirname: 'proj', ctx, position: 'end' });
|
|
assert.strictEqual(invalid, end, `invalid "banana" should fall back to "end"; got: ${invalid}`);
|
|
});
|
|
|
|
// ── gsdUpdate leftmost invariant ─────────────────────────────────────────────
|
|
|
|
test('gsdUpdate warning is leftmost in "end" mode', () => {
|
|
const gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ ';
|
|
const out = composeStatusline({ gsdUpdate, model: 'Claude', dirname: 'proj', position: 'end' });
|
|
assert.ok(out.startsWith(gsdUpdate), `gsdUpdate should be leftmost in end mode; got: ${out}`);
|
|
});
|
|
|
|
test('gsdUpdate warning is leftmost in "front" mode', () => {
|
|
const gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ ';
|
|
const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m';
|
|
const out = composeStatusline({ gsdUpdate, model: 'Claude', dirname: 'proj', ctx, position: 'front' });
|
|
assert.ok(out.startsWith(gsdUpdate), `gsdUpdate should be leftmost in front mode; got: ${out}`);
|
|
});
|
|
|
|
// ── CLI write-path enforcement (config-set rejects invalid enum) ─────────────
|
|
// Locked design: hard reject at config-set time AND silent fallback at runtime.
|
|
// The runtime fallback is covered by the "Invalid position value silently falls
|
|
// back" tests above. This test covers the other half — that the CLI write path
|
|
// actually refuses to persist an invalid value in the first place.
|
|
|
|
test('config-set rejects invalid statusline.context_position', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const r = runGsdTools(
|
|
['config-set', 'statusline.context_position', 'middle'],
|
|
tmpDir,
|
|
);
|
|
assert.equal(
|
|
r.success,
|
|
false,
|
|
`config-set should exit non-zero on invalid enum; got success=${r.success}, output=${r.output}`,
|
|
);
|
|
assert.ok(
|
|
/statusline\.context_position|Invalid/i.test(r.error),
|
|
`stderr must reference key or "Invalid"; got: ${r.error}`,
|
|
);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// Same write-path enforcement for the boolean statusline.show_context_tokens
|
|
// key (#2161) — mirrors the workflow.post_planning_gaps precedent the issue's
|
|
// scope names (tests/post-planning-gaps-2493.test.cjs).
|
|
test('config-set statusline.show_context_tokens true → persisted as boolean', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const r = runGsdTools(['config-set', 'statusline.show_context_tokens', 'true'], tmpDir);
|
|
assert.ok(r.success, r.error);
|
|
const config = JSON.parse(
|
|
fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
assert.strictEqual(config.statusline.show_context_tokens, true);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
test('config-set statusline.show_context_tokens yes → rejected', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const r = runGsdTools(['config-set', 'statusline.show_context_tokens', 'yes'], tmpDir);
|
|
assert.equal(r.success, false, 'non-boolean value must be rejected');
|
|
assert.match(r.error || r.output, /boolean|true|false/i);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Context meter token count (statusline.show_context_tokens)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
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: runHookSeam } = require('./helpers/process-seam.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const { formatTokens, contextTokenSuffix } = require('../hooks/gsd-statusline.js');
|
|
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs');
|
|
|
|
const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
|
|
|
|
describe('config schema: statusline.show_context_tokens', () => {
|
|
test('registers statusline.show_context_tokens', () => {
|
|
assert.ok(
|
|
VALID_CONFIG_KEYS.has('statusline.show_context_tokens'),
|
|
'statusline.show_context_tokens must be in VALID_CONFIG_KEYS',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('formatTokens', () => {
|
|
test('passes small counts through', () => {
|
|
assert.equal(formatTokens(0), '0');
|
|
assert.equal(formatTokens(999), '999');
|
|
});
|
|
test('rounds thousands to k', () => {
|
|
assert.equal(formatTokens(1000), '1k');
|
|
assert.equal(formatTokens(156342), '156k');
|
|
assert.equal(formatTokens(156700), '157k');
|
|
});
|
|
test('formats millions with one decimal', () => {
|
|
assert.equal(formatTokens(1000000), '1.0M');
|
|
assert.equal(formatTokens(1234567), '1.2M');
|
|
});
|
|
test('k-to-M threshold boundary: limit-1 / limit / limit+1', () => {
|
|
// 999,999 k-rounds to 1000 — must promote to the M branch, never "1000k"
|
|
assert.equal(formatTokens(999999), '1.0M');
|
|
assert.equal(formatTokens(1000000), '1.0M');
|
|
assert.equal(formatTokens(1000001), '1.0M');
|
|
// 999,499 is the last value that still k-rounds below 1000
|
|
assert.equal(formatTokens(999499), '999k');
|
|
assert.equal(formatTokens(999500), '1.0M');
|
|
});
|
|
});
|
|
|
|
describe('contextTokenSuffix', () => {
|
|
test('returns empty string for absent/malformed usage', () => {
|
|
assert.equal(contextTokenSuffix(null), '');
|
|
assert.equal(contextTokenSuffix(undefined), '');
|
|
assert.equal(contextTokenSuffix('nope'), '');
|
|
assert.equal(contextTokenSuffix({}), '');
|
|
});
|
|
test('sums all four token dimensions', () => {
|
|
const suffix = contextTokenSuffix({
|
|
input_tokens: 1000,
|
|
cache_creation_input_tokens: 2000,
|
|
cache_read_input_tokens: 150000,
|
|
output_tokens: 3000,
|
|
});
|
|
assert.equal(suffix, ' (156k)');
|
|
});
|
|
test('tolerates missing dimensions', () => {
|
|
assert.equal(contextTokenSuffix({ input_tokens: 500 }), ' (500)');
|
|
});
|
|
});
|
|
|
|
describe('statusline output token suffix (e2e)', () => {
|
|
function makeProject(flag) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-tokens-'));
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
if (flag !== undefined) {
|
|
fs.writeFileSync(
|
|
path.join(dir, '.planning', 'config.json'),
|
|
JSON.stringify({ statusline: { show_context_tokens: flag } }),
|
|
);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function runHook(dir) {
|
|
const payload = JSON.stringify({
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: dir },
|
|
session_id: `test-tokens-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
context_window: {
|
|
remaining_percentage: 70,
|
|
total_tokens: 200000,
|
|
current_usage: {
|
|
input_tokens: 1000,
|
|
cache_read_input_tokens: 150000,
|
|
output_tokens: 5000,
|
|
},
|
|
},
|
|
});
|
|
const r = runHookSeam(hookPath, [], { input: payload, timeoutMs: 4000 });
|
|
// eslint-disable-next-line no-control-regex -- stripping ANSI SGR sequences from captured CLI output
|
|
return r.stdout.replace(/\x1b\[[0-9;]*m/g, '');
|
|
}
|
|
|
|
test('flag=true appends the token count after the percentage', () => {
|
|
const dir = makeProject(true);
|
|
try {
|
|
const out = runHook(dir);
|
|
assert.match(out, /% \(156k\)/, `expected "(156k)" after the meter %; got: ${out}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('default (flag absent) meter is unchanged — no token count', () => {
|
|
const dir = makeProject(undefined);
|
|
try {
|
|
const out = runHook(dir);
|
|
assert.doesNotMatch(out, /\(\d+(?:\.\d+)?[kM]?\)/, `expected no token suffix; got: ${out}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('flag=false meter is unchanged — no token count', () => {
|
|
const dir = makeProject(false);
|
|
try {
|
|
const out = runHook(dir);
|
|
assert.doesNotMatch(out, /\(\d+(?:\.\d+)?[kM]?\)/, `expected no token suffix; got: ${out}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Compact GSD-state format (statusline.state_format)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
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 { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
const statusline = require('../hooks/gsd-statusline.js');
|
|
const { shortGsdStatus, formatGsdStateCompact, formatGsdState } = statusline;
|
|
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs');
|
|
|
|
describe('config schema: statusline.state_format', () => {
|
|
test('registers statusline.state_format', () => {
|
|
assert.ok(
|
|
VALID_CONFIG_KEYS.has('statusline.state_format'),
|
|
'statusline.state_format must be in VALID_CONFIG_KEYS',
|
|
);
|
|
});
|
|
// Direct config-set write-path coverage, mirroring the sibling
|
|
// context_position tests (the ENUM_KEYS matrix covers only the JSON
|
|
// coercion-bypass shapes, not the plain-string paths).
|
|
test('config-set accepts "compact" and rejects an invalid plain string', () => {
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
const ok = runGsdTools(['config-set', 'statusline.state_format', 'compact'], tmpDir);
|
|
assert.ok(ok.success, ok.error);
|
|
const bad = runGsdTools(['config-set', 'statusline.state_format', 'tiny'], tmpDir);
|
|
assert.equal(bad.success, false, 'invalid enum value must be rejected');
|
|
assert.ok(
|
|
/statusline\.state_format|Invalid/i.test(bad.error),
|
|
`stderr must reference key or "Invalid"; got: ${bad.error}`,
|
|
);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('shortGsdStatus', () => {
|
|
test('returns null for empty input', () => {
|
|
assert.equal(shortGsdStatus(null), null);
|
|
assert.equal(shortGsdStatus(''), null);
|
|
assert.equal(shortGsdStatus(undefined), null);
|
|
});
|
|
test('paused — the canonical stuck state — wins and renders uppercase (#2162 condition)', () => {
|
|
assert.equal(shortGsdStatus('paused — waiting on credentials'), 'PAUSED');
|
|
assert.equal(shortGsdStatus('stopped by user'), 'PAUSED');
|
|
});
|
|
test('collapses lifecycle narratives to canonical keywords via normalizeStateStatus', () => {
|
|
assert.equal(shortGsdStatus('Executing phase 7 of the parser milestone'), 'executing');
|
|
assert.equal(shortGsdStatus('Ready to plan next phase'), 'planning');
|
|
assert.equal(shortGsdStatus('Discussing scope with user'), 'discussing');
|
|
assert.equal(shortGsdStatus('Verifying UAT criteria'), 'verifying');
|
|
assert.equal(shortGsdStatus('Work complete'), 'completed');
|
|
});
|
|
test('matches the canonical vocabulary exactly — no drift from normalizeStateStatus', () => {
|
|
const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
|
|
for (const canonical of ['discussing', 'planning', 'executing', 'verifying', 'completed', 'paused']) {
|
|
const rendered = shortGsdStatus(canonical);
|
|
const expected = canonical === 'paused' ? 'PAUSED' : canonical;
|
|
assert.equal(rendered, expected);
|
|
assert.equal(normalizeStateStatus(canonical, null), canonical,
|
|
`canonical vocabulary changed upstream: ${canonical}`);
|
|
}
|
|
});
|
|
test('unknown shapes fall back to the first word, capped at 16 chars', () => {
|
|
assert.equal(shortGsdStatus('reticulating splines'), 'reticulating');
|
|
assert.equal(shortGsdStatus('supercalifragilisticexpialidocious state'), 'supercalifragili');
|
|
});
|
|
test('16-char cap boundary: limit-1 / limit / limit+1', () => {
|
|
assert.equal(shortGsdStatus('x'.repeat(15)), 'x'.repeat(15));
|
|
assert.equal(shortGsdStatus('x'.repeat(16)), 'x'.repeat(16));
|
|
assert.equal(shortGsdStatus('x'.repeat(17)), 'x'.repeat(16));
|
|
});
|
|
});
|
|
|
|
describe('formatGsdStateCompact', () => {
|
|
test('renders version · phase/total · status', () => {
|
|
const out = formatGsdStateCompact({
|
|
milestone: 'v1.12', phaseNum: '7', phaseTotal: '12',
|
|
status: 'Executing phase 7 — building the parser',
|
|
});
|
|
assert.equal(out, 'v1.12 · P7/12 · executing');
|
|
});
|
|
test('prefers lifecycle active_phase over body phase number', () => {
|
|
const out = formatGsdStateCompact({
|
|
milestone: 'v2.0', activePhase: '4.5', phaseNum: '4', status: 'executing',
|
|
});
|
|
assert.equal(out, 'v2.0 · P4.5 · executing');
|
|
});
|
|
test('paused state renders uppercase in the compact line', () => {
|
|
const out = formatGsdStateCompact({
|
|
milestone: 'v2.0', activePhase: '4.5', status: 'paused — waiting on review',
|
|
});
|
|
assert.equal(out, 'v2.0 · P4.5 · PAUSED');
|
|
});
|
|
test('milestone completion renders "complete"', () => {
|
|
assert.equal(formatGsdStateCompact({ milestone: 'v2.0', percent: '100' }), 'v2.0 · complete');
|
|
assert.equal(
|
|
formatGsdStateCompact({ milestone: 'v2.0', completedPhases: '5', totalPhases: '5' }),
|
|
'v2.0 · complete');
|
|
});
|
|
test('scene exclusivity: an in-flight phase wins over milestone-complete', () => {
|
|
// Non-atomic STATE.md edits can leave active_phase populated alongside
|
|
// percent=100 — the compact format must mirror formatGsdState's
|
|
// if/else-chain precedence (Scene 1 beats Scene 3), never render both.
|
|
const state = {
|
|
milestone: 'v2.0', activePhase: '4.5', percent: '100', status: 'executing',
|
|
};
|
|
assert.equal(formatGsdStateCompact(state), 'v2.0 · P4.5 · executing');
|
|
const full = formatGsdState(state);
|
|
assert.ok(!/(complete)/.test(full) || !/4\.5/.test(full),
|
|
`full format must not co-render phase and complete either; got: ${full}`);
|
|
// The legacy body-phase shape (phaseNum, no activePhase) does NOT hold
|
|
// completion back — formatGsdState reaches Scene 3 on percent=100
|
|
// regardless of phaseNum, and compact must agree (#2175 re-review Major).
|
|
const legacyDone = { milestone: 'v2.0', phaseNum: '5', phaseTotal: '5', percent: '100', status: 'verifying' };
|
|
assert.equal(formatGsdStateCompact(legacyDone), 'v2.0 · P5/5 · complete');
|
|
assert.ok(formatGsdState(legacyDone).includes('milestone complete'),
|
|
'parity: full format must render Scene 3 for the same input');
|
|
});
|
|
test('parity: both renderers agree on completion for the same input', () => {
|
|
// Feed identical state objects to both renderers and require they agree
|
|
// on whether the milestone reads as complete — the drift guard for the
|
|
// parallel rendering surfaces.
|
|
const cases = [
|
|
{ milestone: 'v2.0', percent: '100' },
|
|
{ milestone: 'v2.0', phaseNum: '5', phaseTotal: '5', percent: '100', status: 'verifying' },
|
|
{ milestone: 'v2.0', completedPhases: '5', totalPhases: '5' },
|
|
{ milestone: 'v2.0', activePhase: '4.5', percent: '100', status: 'executing' },
|
|
{ milestone: 'v1.9', percent: '40', status: 'executing', phaseNum: '2', phaseTotal: '5' },
|
|
];
|
|
for (const s of cases) {
|
|
const fullDone = formatGsdState(s).includes('milestone complete');
|
|
const compactDone = / complete$|^complete$/.test(formatGsdStateCompact(s));
|
|
assert.equal(compactDone, fullDone,
|
|
`completion parity diverged for ${JSON.stringify(s)}`);
|
|
}
|
|
});
|
|
test('idle with queued next action renders "next <action> <phases>"', () => {
|
|
const out = formatGsdStateCompact({
|
|
milestone: 'v2.0', nextAction: 'execute-phase', nextPhases: ['4.5', '4.6'],
|
|
});
|
|
assert.equal(out, 'v2.0 · next execute-phase 4.5/4.6');
|
|
});
|
|
test('empty state renders empty string', () => {
|
|
assert.equal(formatGsdStateCompact({}), '');
|
|
});
|
|
test('drops the milestone name and progress bar the full format shows', () => {
|
|
const state = {
|
|
milestone: 'v1.9', milestoneName: 'Code Quality', percent: '40',
|
|
status: 'executing', phaseNum: '2', phaseTotal: '5',
|
|
};
|
|
const full = formatGsdState(state);
|
|
const compact = formatGsdStateCompact(state);
|
|
assert.ok(full.includes('Code Quality'), `full keeps name; got: ${full}`);
|
|
assert.ok(!compact.includes('Code Quality'), `compact drops name; got: ${compact}`);
|
|
assert.ok(!compact.includes('█'), `compact drops bar; got: ${compact}`);
|
|
});
|
|
});
|
|
|
|
describe('state_format via renderStatusline', () => {
|
|
function makeProject(stateFormat) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'state-fmt-'));
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
if (stateFormat !== undefined) {
|
|
fs.writeFileSync(
|
|
path.join(dir, '.planning', 'config.json'),
|
|
JSON.stringify({ statusline: { state_format: stateFormat } }),
|
|
);
|
|
}
|
|
fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), [
|
|
'---',
|
|
'milestone: v1.9',
|
|
'milestone_name: Code Quality',
|
|
'status: executing',
|
|
'---',
|
|
'',
|
|
'Phase: 2 of 5 (parser-rewrite)',
|
|
'',
|
|
].join('\n'));
|
|
return dir;
|
|
}
|
|
|
|
test('compact format drops the milestone name', () => {
|
|
const dir = makeProject('compact');
|
|
try {
|
|
const out = statusline.renderStatusline({
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: dir },
|
|
});
|
|
assert.ok(out.includes('v1.9 · P2/5 · executing'), `expected compact state; got: ${out}`);
|
|
assert.ok(!out.includes('Code Quality'), `expected no milestone name; got: ${out}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('default (key absent) keeps the full format unchanged', () => {
|
|
const dir = makeProject(undefined);
|
|
try {
|
|
const out = statusline.renderStatusline({
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: dir },
|
|
});
|
|
assert.ok(out.includes('Code Quality'), `expected full format; got: ${out}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('explicit "full" matches the default rendering', () => {
|
|
const dirDefault = makeProject(undefined);
|
|
const dirFull = makeProject('full');
|
|
try {
|
|
const input = (dir) => ({
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: dir },
|
|
});
|
|
const a = statusline.renderStatusline(input(dirDefault));
|
|
const b = statusline.renderStatusline(input(dirFull));
|
|
// Same STATE.md content → same rendered middle segment (the trailing
|
|
// directory basename differs per temp dir, so compare with it removed)
|
|
assert.equal(
|
|
a.replace(path.basename(dirDefault), ''),
|
|
b.replace(path.basename(dirFull), ''));
|
|
} finally {
|
|
cleanup(dirDefault);
|
|
cleanup(dirFull);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Compact 1M model badge
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const statusline = require('../hooks/gsd-statusline.js');
|
|
const { compactModelName } = statusline;
|
|
|
|
describe('compactModelName', () => {
|
|
test('collapses "(1M context)" to "(1M)"', () => {
|
|
assert.equal(compactModelName('Sonnet 4.5 (1M context)'), 'Sonnet 4.5 (1M)');
|
|
});
|
|
test('is case-insensitive on "context" and preserves the token case', () => {
|
|
assert.equal(compactModelName('Opus 4.6 (1m CONTEXT)'), 'Opus 4.6 (1m)');
|
|
});
|
|
test('tolerates future window sizes (#2160 approval condition)', () => {
|
|
assert.equal(compactModelName('Sonnet 5 (500K context)'), 'Sonnet 5 (500K)');
|
|
assert.equal(compactModelName('Opus 5 (2M context)'), 'Opus 5 (2M)');
|
|
});
|
|
test('handles the abbreviated "ctx" variant (#2160 approval condition)', () => {
|
|
assert.equal(compactModelName('Sonnet 4.5 (1M ctx)'), 'Sonnet 4.5 (1M)');
|
|
assert.equal(compactModelName('Opus 5 (2M CTX)'), 'Opus 5 (2M)');
|
|
});
|
|
test('leaves non-context parentheticals untouched', () => {
|
|
assert.equal(compactModelName('Sonnet 5 (beta)'), 'Sonnet 5 (beta)');
|
|
assert.equal(compactModelName('Opus 4 (deprecated)'), 'Opus 4 (deprecated)');
|
|
});
|
|
test('leaves ordinary names unchanged', () => {
|
|
assert.equal(compactModelName('Claude'), 'Claude');
|
|
assert.equal(compactModelName('Sonnet 4.5'), 'Sonnet 4.5');
|
|
});
|
|
test('only matches the suffix position', () => {
|
|
assert.equal(
|
|
compactModelName('(1M context) Special'), '(1M context) Special');
|
|
});
|
|
test('passes non-strings through', () => {
|
|
assert.equal(compactModelName(undefined), undefined);
|
|
assert.equal(compactModelName(null), null);
|
|
});
|
|
});
|
|
|
|
describe('renderStatusline uses the compact badge', () => {
|
|
test('rendered output carries (1M), not (1M context)', () => {
|
|
const out = statusline.renderStatusline({
|
|
model: { display_name: 'Sonnet 4.5 (1M context)' },
|
|
workspace: { current_dir: require('node:os').tmpdir() },
|
|
});
|
|
assert.ok(out.includes('Sonnet 4.5 (1M)'), `expected compact badge; got: ${out}`);
|
|
assert.ok(!out.includes('(1M context)'), `expected no verbose suffix; got: ${out}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Git segment (statusline.show_git)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
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: runHookSeam } = require('./helpers/process-seam.cjs');
|
|
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const statusline = require('../hooks/gsd-statusline.js');
|
|
const { parseGitStatus, buildGitSegment, readGitStatus, composeStatusline } = statusline;
|
|
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs');
|
|
|
|
describe('config schema: statusline.show_git', () => {
|
|
test('registers statusline.show_git', () => {
|
|
assert.ok(
|
|
VALID_CONFIG_KEYS.has('statusline.show_git'),
|
|
'statusline.show_git must be in VALID_CONFIG_KEYS',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('parseGitStatus', () => {
|
|
test('returns null for non-string / missing branch header', () => {
|
|
assert.equal(parseGitStatus(null), null);
|
|
assert.equal(parseGitStatus(undefined), null);
|
|
assert.equal(parseGitStatus(''), null);
|
|
assert.equal(parseGitStatus('? some-file\n'), null);
|
|
});
|
|
|
|
test('parses a clean, in-sync branch', () => {
|
|
const text = [
|
|
'# branch.oid abc123',
|
|
'# branch.head main',
|
|
'# branch.upstream origin/main',
|
|
'# branch.ab +0 -0',
|
|
'',
|
|
].join('\n');
|
|
assert.deepEqual(parseGitStatus(text), {
|
|
branch: 'main', ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0,
|
|
});
|
|
});
|
|
|
|
test('counts staged, unstaged, untracked, ahead, behind', () => {
|
|
const text = [
|
|
'# branch.oid abc123',
|
|
'# branch.head feat/x',
|
|
'# branch.upstream origin/feat/x',
|
|
'# branch.ab +2 -1',
|
|
'1 M. N... 100644 100644 100644 aaa bbb staged-only.txt',
|
|
'1 .M N... 100644 100644 100644 aaa bbb unstaged-only.txt',
|
|
'1 MM N... 100644 100644 100644 aaa bbb both.txt',
|
|
'2 R. N... 100644 100644 100644 aaa bbb R100 new.txt\told.txt',
|
|
'? untracked-1.txt',
|
|
'? untracked-2.txt',
|
|
'',
|
|
].join('\n');
|
|
assert.deepEqual(parseGitStatus(text), {
|
|
branch: 'feat/x', ahead: 2, behind: 1, staged: 3, unstaged: 2, untracked: 2,
|
|
});
|
|
});
|
|
|
|
test('counts unmerged (conflict) entries as unstaged', () => {
|
|
const text = [
|
|
'# branch.head main',
|
|
'u UU N... 100644 100644 100644 100644 aaa bbb ccc conflict.txt',
|
|
'',
|
|
].join('\n');
|
|
const info = parseGitStatus(text);
|
|
assert.equal(info.unstaged, 1);
|
|
assert.equal(info.staged, 0);
|
|
});
|
|
|
|
test('detached HEAD passes through as "(detached)"', () => {
|
|
const text = '# branch.head (detached)\n';
|
|
assert.equal(parseGitStatus(text).branch, '(detached)');
|
|
});
|
|
|
|
test('no upstream (no branch.ab line) leaves ahead/behind at 0', () => {
|
|
const text = '# branch.head local-only\n? new.txt\n';
|
|
const info = parseGitStatus(text);
|
|
assert.deepEqual([info.ahead, info.behind, info.untracked], [0, 0, 1]);
|
|
});
|
|
});
|
|
|
|
describe('buildGitSegment', () => {
|
|
const strip = (s) =>
|
|
// eslint-disable-next-line no-control-regex -- stripping ANSI SGR sequences to assert on visible text
|
|
s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
|
|
test('returns empty string for null info', () => {
|
|
assert.equal(buildGitSegment(null), '');
|
|
assert.equal(buildGitSegment({}), '');
|
|
});
|
|
|
|
test('clean repo renders branch with a check mark', () => {
|
|
const seg = buildGitSegment({ branch: 'main', ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0 });
|
|
assert.equal(strip(seg), ' │ main✓');
|
|
});
|
|
|
|
test('dirty repo renders each nonzero marker in order', () => {
|
|
const seg = buildGitSegment({ branch: 'feat/x', ahead: 2, behind: 1, staged: 3, unstaged: 2, untracked: 4 });
|
|
assert.equal(strip(seg), ' │ feat/x+3~2?4↑2↓1');
|
|
});
|
|
|
|
test('omits zero markers', () => {
|
|
const seg = buildGitSegment({ branch: 'main', ahead: 1, behind: 0, staged: 0, unstaged: 0, untracked: 0 });
|
|
assert.equal(strip(seg), ' │ main↑1');
|
|
});
|
|
});
|
|
|
|
describe('readGitStatus + parseGitStatus against a real repo', () => {
|
|
function makeGitRepo() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-seg-'));
|
|
const run = (args) => gitOrThrow(['-C', dir, ...args], {
|
|
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
|
|
});
|
|
run(['init', '-q', '-b', 'main']);
|
|
run(['config', 'user.email', 'test@test.invalid']);
|
|
run(['config', 'user.name', 'Test']);
|
|
return { dir, run };
|
|
}
|
|
|
|
test('non-repo directory yields null', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-seg-plain-'));
|
|
try {
|
|
assert.equal(parseGitStatus(readGitStatus(dir)), null);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// Deterministic IO-failure injection (repo convention, cf. the fs
|
|
// monkeypatch in ensure-runtime-build.test.cjs): readGitStatus shares the
|
|
// one cached child_process module object, so replacing execFileSync here
|
|
// injects the failure without a real hang or oversized repo.
|
|
test('maxBuffer overflow degrades to null (segment absent)', () => {
|
|
const childProcess = require('node:child_process');
|
|
const original = childProcess.execFileSync;
|
|
childProcess.execFileSync = () => {
|
|
const err = new RangeError('stdout maxBuffer length exceeded');
|
|
err.code = 'ERR_CHILD_PROCESS_STDOUT_MAXBUFFER';
|
|
throw err;
|
|
};
|
|
try {
|
|
assert.equal(readGitStatus('/tmp'), null);
|
|
} finally {
|
|
childProcess.execFileSync = original;
|
|
}
|
|
});
|
|
|
|
test('spawn timeout degrades to null (segment absent)', () => {
|
|
const childProcess = require('node:child_process');
|
|
const original = childProcess.execFileSync;
|
|
childProcess.execFileSync = () => {
|
|
const err = new Error('spawnSync git ETIMEDOUT');
|
|
err.code = 'ETIMEDOUT';
|
|
err.errno = -110;
|
|
throw err;
|
|
};
|
|
try {
|
|
assert.equal(readGitStatus('/tmp'), null);
|
|
} finally {
|
|
childProcess.execFileSync = original;
|
|
}
|
|
});
|
|
|
|
test('fresh repo with an untracked file is counted', () => {
|
|
const { dir } = makeGitRepo();
|
|
try {
|
|
fs.writeFileSync(path.join(dir, 'new.txt'), 'hello');
|
|
const info = parseGitStatus(readGitStatus(dir));
|
|
assert.equal(info.branch, 'main');
|
|
assert.equal(info.untracked, 1);
|
|
assert.equal(info.staged, 0);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('staged and committed states are reflected', () => {
|
|
const { dir, run } = makeGitRepo();
|
|
try {
|
|
fs.writeFileSync(path.join(dir, 'a.txt'), '1');
|
|
run(['add', 'a.txt']);
|
|
let info = parseGitStatus(readGitStatus(dir));
|
|
assert.equal(info.staged, 1);
|
|
run(['commit', '-q', '-m', 'init']);
|
|
info = parseGitStatus(readGitStatus(dir));
|
|
assert.deepEqual(
|
|
[info.staged, info.unstaged, info.untracked], [0, 0, 0]);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('composeStatusline gitSuffix placement', () => {
|
|
test('git segment renders after the directory in end layout', () => {
|
|
const out = composeStatusline({
|
|
model: 'Claude', dirname: 'proj',
|
|
gitSuffix: ' │ main✓', ctx: ' CTX', lastCmdSuffix: ' │ last: /foo',
|
|
});
|
|
assert.ok(
|
|
out.includes('proj\x1b[0m │ main✓ CTX │ last: /foo'),
|
|
`expected dir → git → ctx → last-cmd order; got: ${out}`,
|
|
);
|
|
});
|
|
test('git segment renders after the directory in front layout', () => {
|
|
const out = composeStatusline({
|
|
model: 'Claude', dirname: 'proj',
|
|
gitSuffix: ' │ main✓', position: 'front',
|
|
});
|
|
assert.ok(out.endsWith('proj\x1b[0m │ main✓'), `got: ${out}`);
|
|
});
|
|
test('default (no gitSuffix) output is unchanged', () => {
|
|
const a = composeStatusline({ model: 'Claude', dirname: 'proj' });
|
|
const b = composeStatusline({ model: 'Claude', dirname: 'proj', gitSuffix: '' });
|
|
assert.equal(a, b);
|
|
});
|
|
});
|
|
|
|
describe('show_git e2e through the hook', () => {
|
|
const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
|
|
|
|
function runHook(dir) {
|
|
const payload = JSON.stringify({
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: dir },
|
|
session_id: `test-git-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const r = runHookSeam(hookPath, [], { input: payload, timeoutMs: 4000 });
|
|
// eslint-disable-next-line no-control-regex -- stripping ANSI SGR sequences from captured CLI output
|
|
return r.stdout.replace(/\x1b\[[0-9;]*m/g, '');
|
|
}
|
|
|
|
test('flag=true renders the branch segment', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-seg-e2e-'));
|
|
try {
|
|
gitOrThrow(['-C', dir, 'init', '-q', '-b', 'main']);
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, '.planning', 'config.json'),
|
|
JSON.stringify({ statusline: { show_git: true } }),
|
|
);
|
|
const out = runHook(dir);
|
|
assert.ok(out.includes('│ main'), `expected branch segment; got: ${out}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('default (flag absent) has no git segment', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-seg-e2e-'));
|
|
try {
|
|
gitOrThrow(['-C', dir, 'init', '-q', '-b', 'main']);
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
const out = runHook(dir);
|
|
assert.ok(!out.includes('│ main'), `expected no git segment; got: ${out}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/issue-607-cache-lineage.test.cjs — H3 Wave 5 (#3337)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe('folded:issue-607-cache-lineage (H3 Wave 5 #3337)', () => {
|
|
'use strict';
|
|
|
|
/**
|
|
* Tests for cache lineage validation (issue #607).
|
|
*
|
|
* Verifies that per-package cache filenames and package_name lineage guards
|
|
* are correctly enforced across gsd-update-banner.js, gsd-statusline.js,
|
|
* and the worker result shape.
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
|
|
const { buildBannerOutput } = require('../hooks/gsd-update-banner.js');
|
|
const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js');
|
|
|
|
// ─── Package identity constants ──────────────────────────────────────────────
|
|
|
|
describe('package-identity exports', () => {
|
|
test('PACKAGE_NAME is @opengsd/gsd-core', () => {
|
|
assert.equal(PACKAGE_NAME, '@opengsd/gsd-core');
|
|
});
|
|
|
|
test('updateCacheFileName is per-package filename', () => {
|
|
assert.equal(updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json');
|
|
});
|
|
});
|
|
|
|
// ─── Worker result shape: package_name field ─────────────────────────────────
|
|
// The worker writes { ..., package_name: PACKAGE_NAME } to the cache.
|
|
// We assert the documented contract by confirming PACKAGE_NAME is correct
|
|
// and that it equals the value that the worker will embed.
|
|
|
|
describe('worker result shape contract', () => {
|
|
test('PACKAGE_NAME value matches the expected installed package', () => {
|
|
// The worker adds package_name: PACKAGE_NAME to its result object.
|
|
// This test asserts the value that will appear in the cache.
|
|
assert.equal(PACKAGE_NAME, '@opengsd/gsd-core');
|
|
});
|
|
});
|
|
|
|
// ─── buildBannerOutput: lineage guard ────────────────────────────────────────
|
|
|
|
describe('buildBannerOutput lineage guard', () => {
|
|
test('returns null when package_name is present but foreign', () => {
|
|
const out = buildBannerOutput({
|
|
cache: {
|
|
update_available: true,
|
|
installed: '1.2.0',
|
|
latest: '1.42.3',
|
|
package_name: 'get-shit-done-cc',
|
|
},
|
|
parseError: false,
|
|
suppressFailureWarning: false,
|
|
});
|
|
assert.equal(out, null, 'foreign lineage must be rejected');
|
|
});
|
|
|
|
test('returns banner when package_name matches PACKAGE_NAME', () => {
|
|
const out = buildBannerOutput({
|
|
cache: {
|
|
update_available: true,
|
|
installed: '1.2.0',
|
|
latest: '1.3.0',
|
|
package_name: '@opengsd/gsd-core',
|
|
},
|
|
parseError: false,
|
|
suppressFailureWarning: false,
|
|
});
|
|
assert.ok(out, 'expected banner envelope for matching lineage');
|
|
assert.equal(typeof out.systemMessage, 'string');
|
|
assert.ok(out.systemMessage.includes('1.2.0'));
|
|
assert.ok(out.systemMessage.includes('1.3.0'));
|
|
assert.ok(out.systemMessage.includes('/gsd:update'));
|
|
});
|
|
|
|
test('returns null when package_name is absent (untrusted cache)', () => {
|
|
const out = buildBannerOutput({
|
|
cache: {
|
|
update_available: true,
|
|
installed: '1.2.0',
|
|
latest: '1.3.0',
|
|
// no package_name field
|
|
},
|
|
parseError: false,
|
|
suppressFailureWarning: false,
|
|
});
|
|
assert.equal(out, null, 'absent package_name must be treated as untrusted → null');
|
|
});
|
|
});
|
|
|
|
// ─── evaluateUpdateCache: lineage guard in statusline ────────────────────────
|
|
|
|
describe('evaluateUpdateCache lineage guard', () => {
|
|
test('returns showUpdate=false when cache is null', () => {
|
|
const r = evaluateUpdateCache(null);
|
|
assert.equal(r.showUpdate, false);
|
|
assert.equal(r.staleWarning, 'none');
|
|
});
|
|
|
|
test('returns showUpdate=false when package_name is absent (untrusted)', () => {
|
|
const r = evaluateUpdateCache({
|
|
update_available: true,
|
|
installed: '1.2.0',
|
|
latest: '1.3.0',
|
|
});
|
|
assert.equal(r.showUpdate, false);
|
|
assert.equal(r.staleWarning, 'none');
|
|
});
|
|
|
|
test('returns showUpdate=false when package_name is foreign', () => {
|
|
const r = evaluateUpdateCache({
|
|
update_available: true,
|
|
installed: '1.2.0',
|
|
latest: '1.3.0',
|
|
package_name: 'some-other-package',
|
|
});
|
|
assert.equal(r.showUpdate, false);
|
|
assert.equal(r.staleWarning, 'none');
|
|
});
|
|
|
|
test('returns showUpdate=true when update_available and package_name matches', () => {
|
|
const r = evaluateUpdateCache({
|
|
update_available: true,
|
|
installed: '1.2.0',
|
|
latest: '1.3.0',
|
|
package_name: '@opengsd/gsd-core',
|
|
});
|
|
assert.equal(r.showUpdate, true);
|
|
assert.equal(r.staleWarning, 'none');
|
|
});
|
|
|
|
test('returns showUpdate=false when update_available=false', () => {
|
|
const r = evaluateUpdateCache({
|
|
update_available: false,
|
|
installed: '1.3.0',
|
|
latest: '1.3.0',
|
|
package_name: '@opengsd/gsd-core',
|
|
});
|
|
assert.equal(r.showUpdate, false);
|
|
assert.equal(r.staleWarning, 'none');
|
|
});
|
|
|
|
test('returns staleWarning=stale when stale_hooks present and matching package_name', () => {
|
|
const r = evaluateUpdateCache({
|
|
update_available: false,
|
|
installed: '1.3.0',
|
|
latest: '1.3.0',
|
|
package_name: '@opengsd/gsd-core',
|
|
stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }],
|
|
});
|
|
assert.equal(r.staleWarning, 'stale');
|
|
});
|
|
|
|
test('returns staleWarning=dev when installed > latest (dev install) and matching package_name', () => {
|
|
const r = evaluateUpdateCache({
|
|
update_available: false,
|
|
installed: '2.0.0',
|
|
latest: '1.3.0',
|
|
package_name: '@opengsd/gsd-core',
|
|
stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '2.0.0' }],
|
|
});
|
|
assert.equal(r.staleWarning, 'dev');
|
|
});
|
|
|
|
test('returns staleWarning=none when stale_hooks present but package_name is foreign', () => {
|
|
const r = evaluateUpdateCache({
|
|
update_available: false,
|
|
installed: '1.3.0',
|
|
latest: '1.2.0',
|
|
package_name: 'foreign-pkg',
|
|
stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }],
|
|
});
|
|
assert.equal(r.staleWarning, 'none');
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degrade, not crash ─────
|
|
//
|
|
// gsd-core/bin/lib/semver-compare.cjs, package-identity.cjs,
|
|
// state-document.cjs, active-workstream-store.cjs, and planning-workspace.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`.
|
|
// The statusline renders on EVERY prompt — before #3582 a missing library
|
|
// crashed the whole hook process at module load (bare "Cannot find module"),
|
|
// so Claude Code's statusline would show nothing AND emit a visible error on
|
|
// every single render. The fix: the spawned-as-a-script path
|
|
// (`require.main === module`) calls ensureRuntimeBuild() first and, on
|
|
// failure, writes empty stdout and exits 0 — the SAME quiet no-signal
|
|
// behavior every other internal failure in this hook already degrades to
|
|
// (see e.g. the `try { ... } catch (e) { /* Silent fail */ }` wrapping
|
|
// runStatusline's own body). 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 os = require('node:os');
|
|
const path = require('node:path');
|
|
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
|
|
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
|
|
|
|
describe('gsd-statusline.js: #3582 cold tree — degrade to empty output, exit 0', () => {
|
|
test('missing compiled runtime library -> empty stdout, exit 0, no crash', (t) => {
|
|
const cold = buildColdInstallTree();
|
|
t.after(cold.cleanup);
|
|
|
|
const payload = JSON.stringify({
|
|
model: { display_name: 'Claude' },
|
|
workspace: { current_dir: os.tmpdir() },
|
|
session_id: `test-3582-${Date.now()}`,
|
|
});
|
|
const r = runHookSeam(path.join(cold.hooksDir, 'gsd-statusline.js'), [], {
|
|
input: payload,
|
|
timeoutMs: 4000,
|
|
});
|
|
assert.equal(r.exitCode, 0, `must exit 0 on a build failure; stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '', 'must degrade to empty output, not throw a stack trace to stdout');
|
|
});
|
|
});
|
|
}
|