* test(3579): failing-first coverage for repo-marker inheritance A session that carries an identity but has never run 'workstream use' reads an absent session pointer, resolves null, and composes the flat .planning tree even when .planning/active-workstream names a live workstream. These tests fail on that and pin the invariants the fix must not break: a session with its own pointer is never repointed, and a session that merely lacked a pointer must never clear the shared marker on another session's behalf. * fix(3579): a pointer-less session inherits the repo active-workstream marker RED proven at 157cae26: the three inheritance tests failed while every isolation and negative control passed on base — the gap, and nothing else. pickActiveWorkstreamAdapter returned exactly ONE adapter: the session-scoped one whenever a session key existed, so the shared .planning/active-workstream marker was never consulted. getWorkstreamSessionKey resolves a key from ~13 env vars or the controlling TTY, so on any normal interactive terminal a key almost always exists — which is why a session that had never run 'workstream use' read an absent pointer, resolved null, and composed the FLAT planning tree even though the repo marker named a live workstream. Reads misreported; writes corrupted the superseded flat STATE. Silent, because the stale tree is well-formed. This was a genuine design fork, not an oversight: references/workstream-flag.md documented step 4 as a fallback 'when no session key exists', and the session isolation that buys is deliberate (#2850). The issue's Agent Brief left the choice open and said the reference doc should match whatever semantics ship. The maintainer ruled in chat for inheritance. Resolution now walks an ORDERED chain — session adapter first, shared second — and only a null from the session adapter falls through to the marker. Strictly additive: it can only turn a null into a name, never change a name that already resolves. The dangerous part is clear() ownership. resolveFromChain treats chain[0] as owned: only it is ever cleared, and only under selfHeal (getActiveWorkstream, never peek). An INHERITED marker is read-only — a stale value there resolves null and the file is left alone. Without that, one pointer-less session's read would delete the repo marker for every other session, which is a worse bug than the one being fixed. Covered by a test that asserts the marker still exists on disk after such a read. peekActiveWorkstream inherits but still mutates nothing (#2850 — the statusline draws on every render). references/workstream-flag.md's Resolution Priority is rewritten to match, keeping the session-isolation rationale and noting that inheritance does not weaken it: a session that owns a pointer is never repointed. Fixes #3579 * fix(3579): correct the guard diagnostics and lock the clear-semantics Three review passes; every finding fixed inline. MISSING ACCEPTANCE CRITERION (spec pass). The brief requires refusal diagnostics that distinguish 'marker present but the session lookup missed it' from 'no workstream set at all', and the two workstream-mode fail-safe guards were byte-for-byte untouched — still emitting a generic 'no active workstream is set' even when a marker exists and merely names a missing directory. Both guards (cmdPhaseComplete, cmdInitProgress) now branch on a new read-only diagnoseUnresolvedActiveWorkstream, which reuses the SAME resolvesToExistingWorkstream predicate resolveFromChain uses, so the diagnosis and the resolution cannot disagree. Two typed reasons added to ERROR_REASON; both arms still refuse — the fail-closed behavior is unchanged, only the message is now true. REAL TEST FAILURE, not a flake. The remote run failed 'clearing one session does not clear another session pointer'. That describe uses before() rather than beforeEach, so one tmpDir is shared and an earlier test writes active-workstream=beta into it; under inheritance the just-cleared session picks that marker up and resolves beta instead of null. The failure is a CORRECT consequence of Option A surfaced through an order-dependent fixture. The test now establishes its own marker state explicitly — its real intent (clearing A must not disturb B's pointer) is preserved and not weakened — and a new test pins the semantic deliberately: clearing a session pointer returns that session to INHERITING the marker, it does not force flat mode. Documented in references/workstream-flag.md, including how to actually get flat behavior. Also from review: partial activeWorkstreamAdapters injection no longer silently synthesizes a REAL filesystem adapter for the missing half (a latent test-isolation trap); the duplicated validate-then-existsSync logic is factored into one predicate; and the two try/finally test bodies are converted to t.after per CONTRIBUTING. New coverage: whitespace/empty shared marker; a session whose OWN pointer is stale while the marker names a different valid workstream (must self-heal to null, never inherit — the isolation guarantee at its sharpest); and both new diagnostic arms asserted on structured --json-errors output rather than prose. * fix(3579): read resolvability with the non-mutating peek, not the self-healing resolver Three of our own new tests failed on 7f5e706a. All three had ONE root cause, and none was fixed by relaxing an assertion. gsd-tools.cjs's bootstrap called the MUTATING getActiveWorkstream unconditionally on every invocation, purely to populate routing env. On an unresolvable pointer that self-healed — cleared it — BEFORE the dispatched command ran its own resolution. A second read in the same process then observed already-cleared state: - Isolation violation: a session whose own pointer was stale had it cleared by the bootstrap, so cmdWorkstreamGet's own resolution found a pointer-LESS session and inherited the shared marker ('beta' instead of null). Exactly the guarantee #2850 exists to protect, defeated across two calls rather than within one. - Guard diagnostics: the guards' own truthiness check also used the mutating resolver, so it cleared the invalid marker and the immediately-following read-only diagnosis found nothing and reported none_active instead of marker_unresolved. So a single invocation's answer depended on how many times it resolved. The bootstrap self-heal is PRE-EXISTING and was harmless while pointer-less meant flat — inheritance is what made it answer-changing, so this fix belongs here. Every call site that only CHECKS resolvability — the bootstrap, both fail-safe guards' truthiness check, and two informational init report fields — now uses the non-mutating peekActiveWorkstream. Self-heal is unchanged in active-workstream-store and still fires exactly once, at whichever site actually consumes the workstream. Verified by driving the real CLI against temp fixtures, since the suite cannot run locally: stale-own-pointer resolves null with the marker intact; both guard arms report marker_unresolved with missing_workstream_dir / invalid_name and the marker survives; no-marker still reports none_active; identity-less self-heal still deletes an invalid marker byte-identically to pre-#3579; and a session with a valid own pointer still wins. * chore(3579): backfill changeset PR number (#3616) * test(3579): kill the surviving mutants in the new resolution code CI's Stryker gate failed: active-workstream-store scored 79.45% against a break threshold of 80 — 259 killed, 67 survived, at 'Ran 1.00 tests per mutant on average'. The survivors cluster in the code this PR added (pickActiveWorkstreamAdapterChain, resolvesToExistingWorkstream, resolveFromChain, diagnoseUnresolvedActiveWorkstream): the CLI-level tests exercise those paths but do not DISCRIMINATE their branches, which is precisely what a surviving mutant means. Raised by strengthening assertions, never by touching the threshold. 21 unit tests added to the existing unit suite, each written to fail under a specific named mutant, using the module's injected adapter seams and createMemoryPointerAdapter so they stay hermetic under Stryker's per-mutant reruns: - chain shape with and without a session key, asserting length AND element identity (kills the if(false), the ': []' array mutant, and the block removal) - partial adapter injection, asserting the missing half is an inert memory adapter that never touches the filesystem (kills the three '??' -> '&&' mutants) - both arms of '!name || !validateWorkstreamName(name)' as SEPARATE tests — an absent name and a non-empty invalid one — which is what kills the '||' -> '&&' mutant - self-heal discrimination: getActiveWorkstream must clear an unresolvable owned pointer and peekActiveWorkstream must not, asserted on adapter state after each (kills if(selfHeal) -> if(true)) - fallback arm both ways: a fallback that resolves and one that does not - diagnoseUnresolvedActiveWorkstream asserted as a full object per case, with the reason strings compared exactly (kills present:true -> false and both StringLiteral mutants) One mutant is deliberately left: 'if (chain.length === 0)' -> 'if (false)'. The branch is structurally unreachable — the only chain source always returns a 1- or 2-element array literal — and resolveFromChain is not exported. Killing it would mean exporting an internal or deleting a defensive guard; neither is worth doing for a mutant, and the score clears 80 without it. Recorded here rather than left unexplained. Every new assertion was evaluated against the built module with real fixtures before committing, since the suite cannot run locally. --------- Co-authored-by: sim <sim@local>
1174 lines
53 KiB
JavaScript
1174 lines
53 KiB
JavaScript
/**
|
|
* Workstream Tests — CRUD, env-var routing, collision detection
|
|
*/
|
|
|
|
const { describe, test, before, after, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
|
const { createFixture, seedWorkstream } = require('./fixtures/index.cjs');
|
|
const { migrateToWorkstreams, getOtherActiveWorkstreams } = require('../gsd-core/bin/lib/workstream.cjs');
|
|
|
|
// ─── Helper ──────────────────────────────────────────────────────────────────
|
|
|
|
function createFailingTtyEnv(tmpDir) {
|
|
const binDir = path.join(tmpDir, 'fake-bin');
|
|
const markerFile = path.join(tmpDir, 'tty-invoked.log');
|
|
const inheritedPath = process.env.PATH || process.env.Path || '';
|
|
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(binDir, 'tty'),
|
|
'#!/bin/sh\nif [ -n "$GSD_TTY_MARKER" ]; then printf "tty\\n" >> "$GSD_TTY_MARKER"; fi\nexit 99\n',
|
|
'utf-8'
|
|
);
|
|
fs.chmodSync(path.join(binDir, 'tty'), 0o755);
|
|
fs.writeFileSync(
|
|
path.join(binDir, 'tty.cmd'),
|
|
'@echo off\r\nif not "%GSD_TTY_MARKER%"=="" echo tty>>"%GSD_TTY_MARKER%"\r\nexit /b 99\r\n',
|
|
'utf-8'
|
|
);
|
|
|
|
return {
|
|
markerFile,
|
|
env: {
|
|
PATH: `${binDir}${path.delimiter}${inheritedPath}`,
|
|
GSD_TTY_MARKER: markerFile,
|
|
},
|
|
};
|
|
}
|
|
|
|
function getSessionPointerDir(tmpDir) {
|
|
const planningPath = fs.realpathSync.native(path.join(tmpDir, '.planning'));
|
|
const projectId = crypto
|
|
.createHash('sha1')
|
|
.update(planningPath)
|
|
.digest('hex')
|
|
.slice(0, 16);
|
|
return path.join(os.tmpdir(), 'gsd-workstream-sessions', projectId);
|
|
}
|
|
|
|
function sanitizeSessionToken(value) {
|
|
const token = String(value).trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
return token ? token.slice(0, 160) : null;
|
|
}
|
|
|
|
function getSessionPointerFileName(envKey, value) {
|
|
const token = sanitizeSessionToken(value);
|
|
return `${envKey.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${token}`;
|
|
}
|
|
|
|
// ─── planningDir / planningPaths env-var awareness ──────────────────────────
|
|
|
|
describe('planningDir workstream awareness via env var', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
seedWorkstream(tmpDir, {
|
|
name: 'alpha',
|
|
state: '# State\n**Status:** In progress\n**Current Phase:** 1\n',
|
|
roadmap: '## Roadmap v1.0: Alpha\n### Phase 1: Setup\n',
|
|
active: true,
|
|
});
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('state json returns workstream-scoped state when GSD_WORKSTREAM is set', () => {
|
|
const result = runGsdTools(['state', 'json', '--raw'], tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
|
assert.ok(result.success, `state json failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.ok(data.status || data.current_phase !== undefined, 'should return state data');
|
|
});
|
|
|
|
test('state json reads from flat .planning when no workstream set', () => {
|
|
// Clear active-workstream so no auto-detection
|
|
try { fs.unlinkSync(path.join(tmpDir, '.planning', 'active-workstream')); } catch {}
|
|
const result = runGsdTools(['state', 'json', '--raw'], tmpDir, { GSD_WORKSTREAM: '' });
|
|
// Should fail or return empty state since flat .planning/ has no STATE.md
|
|
assert.ok(!result.success || result.output.includes('not found') || result.output === '{}',
|
|
'should read from flat .planning/');
|
|
// Restore
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'alpha\n');
|
|
});
|
|
|
|
test('--ws flag overrides GSD_WORKSTREAM env var', () => {
|
|
// Create a second workstream
|
|
const betaDir = path.join(tmpDir, '.planning', 'workstreams', 'beta');
|
|
fs.mkdirSync(path.join(betaDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(betaDir, 'STATE.md'), '# State\n**Status:** Beta active\n');
|
|
|
|
const result = runGsdTools(['state', 'json', '--raw', '--ws', 'beta'], tmpDir, { GSD_WORKSTREAM: 'alpha' });
|
|
assert.ok(result.success, `state json --ws beta failed: ${result.error}`);
|
|
});
|
|
});
|
|
|
|
describe('session-scoped active workstream routing', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
|
|
for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) {
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws);
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** ${status}\n`);
|
|
}
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('stores active workstream per session instead of mutating shared pointer', () => {
|
|
const alphaSet = runGsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
const betaSet = runGsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-beta' });
|
|
|
|
assert.ok(alphaSet.success, `alpha set failed: ${alphaSet.error}`);
|
|
assert.ok(betaSet.success, `beta set failed: ${betaSet.error}`);
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'active-workstream')),
|
|
'shared active-workstream file should not be used when session keys are available');
|
|
});
|
|
|
|
test('different sessions resolve different active workstreams without --ws', () => {
|
|
const alpha = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
const beta = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-beta' });
|
|
|
|
assert.ok(alpha.success, `alpha get failed: ${alpha.error}`);
|
|
assert.ok(beta.success, `beta get failed: ${beta.error}`);
|
|
assert.strictEqual(alpha.output, 'alpha');
|
|
assert.strictEqual(beta.output, 'beta');
|
|
});
|
|
|
|
test('session-scoped pointer ignores legacy shared active-workstream file', () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'beta\n');
|
|
|
|
const alpha = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
const shared = runGsdTools(['workstream', 'get', '--raw'], tmpDir);
|
|
|
|
assert.ok(alpha.success, `session-scoped get failed: ${alpha.error}`);
|
|
assert.ok(shared.success, `legacy get failed: ${shared.error}`);
|
|
assert.strictEqual(alpha.output, 'alpha');
|
|
assert.strictEqual(shared.output, 'beta');
|
|
});
|
|
|
|
test('state commands route to the session-scoped workstream automatically', () => {
|
|
const alpha = runGsdTools(['state', 'json', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
const beta = runGsdTools(['state', 'json', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-beta' });
|
|
|
|
assert.ok(alpha.success, `alpha state failed: ${alpha.error}`);
|
|
assert.ok(beta.success, `beta state failed: ${beta.error}`);
|
|
const alphaState = JSON.parse(alpha.output);
|
|
const betaState = JSON.parse(beta.output);
|
|
assert.strictEqual(alphaState.status, 'Alpha active');
|
|
assert.strictEqual(betaState.status, 'Beta active');
|
|
});
|
|
|
|
test('clearing one session does not clear another session pointer', () => {
|
|
// A prior test in this shared-tmpDir block ('session-scoped pointer ignores
|
|
// legacy shared active-workstream file') writes 'beta' into the shared
|
|
// .planning/active-workstream marker. Under #3579 semantics, a cleared
|
|
// session INHERITS that marker instead of resolving flat — so leaving the
|
|
// marker in place here would make this test assert marker-inheritance
|
|
// behavior it never intended to cover (that is pinned separately below).
|
|
// This test's real intent is sibling-pointer isolation, not inheritance,
|
|
// so remove the marker to make alpha's post-clear resolution independent
|
|
// of whatever an earlier test in this block left on disk.
|
|
try { fs.unlinkSync(path.join(tmpDir, '.planning', 'active-workstream')); } catch {}
|
|
|
|
const clearAlpha = runGsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
const alpha = runGsdTools(['workstream', 'get'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
const beta = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-beta' });
|
|
|
|
assert.ok(clearAlpha.success, `clear alpha failed: ${clearAlpha.error}`);
|
|
assert.ok(alpha.success, `alpha get after clear failed: ${alpha.error}`);
|
|
assert.ok(beta.success, `beta get after alpha clear failed: ${beta.error}`);
|
|
|
|
const cleared = JSON.parse(alpha.output);
|
|
assert.strictEqual(cleared.active, null);
|
|
assert.strictEqual(beta.output, 'beta');
|
|
});
|
|
});
|
|
|
|
describe('session resolution hardening', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
|
|
for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) {
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws);
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** ${status}\n`);
|
|
}
|
|
});
|
|
|
|
afterEach(() => cleanup(tmpDir));
|
|
|
|
test('headless runs skip tty probing and use the shared active-workstream fallback', () => {
|
|
const { markerFile, env } = createFailingTtyEnv(tmpDir);
|
|
const set = runGsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, env);
|
|
const get = runGsdTools(['workstream', 'get', '--raw'], tmpDir, env);
|
|
|
|
assert.ok(set.success, `headless set failed: ${set.error}`);
|
|
assert.ok(get.success, `headless get failed: ${get.error}`);
|
|
assert.ok(!fs.existsSync(markerFile), 'headless fallback should not invoke the tty subprocess');
|
|
assert.strictEqual(get.output, 'alpha');
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'utf-8').trim(),
|
|
'alpha'
|
|
);
|
|
assert.ok(!fs.existsSync(getSessionPointerDir(tmpDir)), 'headless fallback should not create session tmp pointers');
|
|
});
|
|
|
|
test('explicit runtime session ids outrank tty-derived identities', () => {
|
|
const set = runGsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, {
|
|
GSD_SESSION_KEY: 'shared-session',
|
|
TTY: '/dev/pts/42',
|
|
});
|
|
const get = runGsdTools(['workstream', 'get', '--raw'], tmpDir, {
|
|
GSD_SESSION_KEY: 'shared-session',
|
|
TTY: '/dev/pts/99',
|
|
});
|
|
|
|
assert.ok(set.success, `session-key set failed: ${set.error}`);
|
|
assert.ok(get.success, `session-key get failed: ${get.error}`);
|
|
assert.strictEqual(get.output, 'alpha');
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'active-workstream')));
|
|
});
|
|
|
|
test('TTY environment variables provide a session-scoped pointer without spawning tty', () => {
|
|
const { markerFile, env } = createFailingTtyEnv(tmpDir);
|
|
const ttyEnv = { ...env, TTY: '/dev/pts/42' };
|
|
const set = runGsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, ttyEnv);
|
|
const get = runGsdTools(['workstream', 'get', '--raw'], tmpDir, ttyEnv);
|
|
|
|
assert.ok(set.success, `TTY set failed: ${set.error}`);
|
|
assert.ok(get.success, `TTY get failed: ${get.error}`);
|
|
assert.ok(!fs.existsSync(markerFile), 'TTY env should be used directly without invoking the tty subprocess');
|
|
assert.strictEqual(get.output, 'beta');
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'active-workstream')));
|
|
});
|
|
});
|
|
|
|
// #3579: a session that has an identity (session key) but has never run
|
|
// `workstream use` must inherit the repo-wide `.planning/active-workstream`
|
|
// marker instead of resolving to nothing. peekActiveWorkstream has no CLI
|
|
// surface (only the statusline hook calls it), so these tests require the
|
|
// built module directly — same pattern as the planning-workspace.cjs require
|
|
// used elsewhere in this file.
|
|
describe('session inherits shared marker when pointer-less (#3579)', () => {
|
|
let tmpDir;
|
|
const {
|
|
getActiveWorkstream,
|
|
peekActiveWorkstream,
|
|
} = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
|
|
for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) {
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws);
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** ${status}\n`);
|
|
}
|
|
});
|
|
|
|
afterEach(() => cleanup(tmpDir));
|
|
|
|
test('session key present, no session pointer, marker names an existing workstream -> inherits the marker', () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'alpha\n');
|
|
|
|
const result = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'no-pointer-session' });
|
|
|
|
assert.ok(result.success, `get failed: ${result.error}`);
|
|
assert.strictEqual(result.output, 'alpha');
|
|
});
|
|
|
|
test('peekActiveWorkstream inherits the marker and mutates nothing', (t) => {
|
|
const markerPath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(markerPath, 'alpha\n');
|
|
const sessionDir = getSessionPointerDir(tmpDir);
|
|
const savedSessionKey = process.env.GSD_SESSION_KEY;
|
|
process.env.GSD_SESSION_KEY = 'peek-no-pointer';
|
|
t.after(() => {
|
|
if (savedSessionKey !== undefined) process.env.GSD_SESSION_KEY = savedSessionKey;
|
|
else delete process.env.GSD_SESSION_KEY;
|
|
});
|
|
|
|
const resolved = peekActiveWorkstream(tmpDir);
|
|
assert.strictEqual(resolved, 'alpha');
|
|
assert.strictEqual(fs.readFileSync(markerPath, 'utf-8'), 'alpha\n', 'peek must not mutate the shared marker');
|
|
assert.ok(!fs.existsSync(sessionDir), 'peek must not create a session pointer file');
|
|
});
|
|
|
|
test('session pointer names beta while marker names alpha -> resolves beta, marker untouched (isolation)', () => {
|
|
const markerPath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(markerPath, 'alpha\n');
|
|
runGsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { GSD_SESSION_KEY: 'has-own-pointer' });
|
|
|
|
const result = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'has-own-pointer' });
|
|
|
|
assert.ok(result.success, `get failed: ${result.error}`);
|
|
assert.strictEqual(result.output, 'beta');
|
|
assert.strictEqual(fs.readFileSync(markerPath, 'utf-8'), 'alpha\n', 'session with its own pointer must never repoint the shared marker');
|
|
});
|
|
|
|
test('workstream set --clear with a valid repo marker present -> session returns to inheriting the marker, not flat', () => {
|
|
const markerPath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(markerPath, 'alpha\n');
|
|
runGsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { GSD_SESSION_KEY: 'clear-then-inherit-session' });
|
|
|
|
const clear = runGsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir, { GSD_SESSION_KEY: 'clear-then-inherit-session' });
|
|
const result = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'clear-then-inherit-session' });
|
|
|
|
assert.ok(clear.success, `clear failed: ${clear.error}`);
|
|
assert.ok(result.success, `get after clear failed: ${result.error}`);
|
|
assert.strictEqual(
|
|
result.output,
|
|
'alpha',
|
|
'clearing a session pointer must fall back to inheriting the shared marker (step 4), not resolve to null/flat',
|
|
);
|
|
});
|
|
|
|
test('no session key, marker present -> resolves the marker (pre-existing path stays green)', () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'alpha\n');
|
|
|
|
const result = runGsdTools(['workstream', 'get', '--raw'], tmpDir, {});
|
|
|
|
assert.ok(result.success, `get failed: ${result.error}`);
|
|
assert.strictEqual(result.output, 'alpha');
|
|
});
|
|
|
|
test('no workstreams/ dir -> flat/null, unchanged', (t) => {
|
|
// createFixture() only creates .planning/phases, not .planning/workstreams.
|
|
const flatDir = createFixture();
|
|
t.after(() => cleanup(flatDir));
|
|
|
|
const savedSessionKey = process.env.GSD_SESSION_KEY;
|
|
process.env.GSD_SESSION_KEY = 'flat-no-workstreams-dir';
|
|
t.after(() => {
|
|
if (savedSessionKey !== undefined) process.env.GSD_SESSION_KEY = savedSessionKey;
|
|
else delete process.env.GSD_SESSION_KEY;
|
|
});
|
|
|
|
assert.strictEqual(getActiveWorkstream(flatDir), null);
|
|
assert.strictEqual(peekActiveWorkstream(flatDir), null);
|
|
});
|
|
|
|
test('marker names a non-existent workstream, session has no pointer -> resolves null and the marker file is NOT deleted', () => {
|
|
const markerPath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(markerPath, 'ghost-workstream\n');
|
|
|
|
const result = runGsdTools(['workstream', 'get'], tmpDir, { GSD_SESSION_KEY: 'stale-marker-session' });
|
|
|
|
assert.ok(result.success, `get failed: ${result.error}`);
|
|
assert.strictEqual(JSON.parse(result.output).active, null);
|
|
assert.ok(fs.existsSync(markerPath), 'a pointer-less session read must never delete the shared marker');
|
|
assert.strictEqual(fs.readFileSync(markerPath, 'utf-8'), 'ghost-workstream\n');
|
|
});
|
|
|
|
test('session pointer file exists but is whitespace-only -> treated as absent, inherits the marker', () => {
|
|
const markerPath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(markerPath, 'beta\n');
|
|
|
|
runGsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { GSD_SESSION_KEY: 'whitespace-pointer-session' });
|
|
const sessionDir = getSessionPointerDir(tmpDir);
|
|
const sessionFile = getSessionPointerFileName('GSD_SESSION_KEY', 'whitespace-pointer-session');
|
|
fs.writeFileSync(path.join(sessionDir, sessionFile), ' \n');
|
|
|
|
const result = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'whitespace-pointer-session' });
|
|
|
|
assert.ok(result.success, `get failed: ${result.error}`);
|
|
assert.strictEqual(result.output, 'beta');
|
|
});
|
|
|
|
test('shared marker is whitespace-only, session pointer-less -> resolves null', () => {
|
|
const markerPath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(markerPath, ' \n');
|
|
|
|
const result = runGsdTools(['workstream', 'get'], tmpDir, { GSD_SESSION_KEY: 'whitespace-marker-session' });
|
|
|
|
assert.ok(result.success, `get failed: ${result.error}`);
|
|
assert.strictEqual(JSON.parse(result.output).active, null);
|
|
});
|
|
|
|
test('shared marker file is empty, session pointer-less -> resolves null', () => {
|
|
const markerPath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(markerPath, '');
|
|
|
|
const result = runGsdTools(['workstream', 'get'], tmpDir, { GSD_SESSION_KEY: 'empty-marker-session' });
|
|
|
|
assert.ok(result.success, `get failed: ${result.error}`);
|
|
assert.strictEqual(JSON.parse(result.output).active, null);
|
|
});
|
|
|
|
test('session pointer is stale (its workstream is deleted) while marker names a different, still-valid workstream -> resolves null, never falls through to inherit the marker', () => {
|
|
const markerPath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(markerPath, 'beta\n');
|
|
runGsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { GSD_SESSION_KEY: 'stale-own-pointer-session' });
|
|
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- mid-test fault injection: simulates a deleted workstream to exercise stale-pointer self-cleanup
|
|
fs.rmSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true, force: true });
|
|
|
|
const result = runGsdTools(['workstream', 'get'], tmpDir, { GSD_SESSION_KEY: 'stale-own-pointer-session' });
|
|
|
|
assert.ok(result.success, `get failed: ${result.error}`);
|
|
assert.strictEqual(
|
|
JSON.parse(result.output).active,
|
|
null,
|
|
'a stale OWNED pointer must self-heal to null — it must not fall through to the shared marker just because it failed to resolve',
|
|
);
|
|
assert.strictEqual(fs.readFileSync(markerPath, 'utf-8'), 'beta\n', 'the shared marker must remain untouched');
|
|
});
|
|
|
|
// #3579 item 1: the #1912/#2028 workstream-mode fail-safe guards must
|
|
// distinguish "no marker/pointer anywhere" from "a marker exists but did
|
|
// not resolve" — asserted structurally (via --json-errors) per CONTRIBUTING,
|
|
// not by regexing the human-readable message.
|
|
test('init.progress fail-safe guard: no marker at all -> reason workstream_mode_none_active', () => {
|
|
const result = runGsdTools(['--json-errors', 'init', 'progress'], tmpDir);
|
|
|
|
assert.equal(result.success, false, 'must still refuse');
|
|
const payload = JSON.parse(result.error);
|
|
assert.equal(payload.reason, 'workstream_mode_none_active');
|
|
});
|
|
|
|
test('init.progress fail-safe guard: marker present but names a missing workstream dir -> reason workstream_mode_marker_unresolved, distinct message', () => {
|
|
const noneResult = runGsdTools(['--json-errors', 'init', 'progress'], tmpDir);
|
|
const nonePayload = JSON.parse(noneResult.error);
|
|
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'ghost-workstream\n');
|
|
const unresolvedResult = runGsdTools(['--json-errors', 'init', 'progress'], tmpDir);
|
|
|
|
assert.equal(unresolvedResult.success, false, 'must still refuse');
|
|
const unresolvedPayload = JSON.parse(unresolvedResult.error);
|
|
assert.equal(unresolvedPayload.reason, 'workstream_mode_marker_unresolved');
|
|
assert.equal(unresolvedPayload.marker_value, 'ghost-workstream');
|
|
assert.equal(unresolvedPayload.marker_reason, 'missing_workstream_dir');
|
|
assert.notEqual(
|
|
unresolvedPayload.message,
|
|
nonePayload.message,
|
|
'the marker-present-but-unresolved diagnostic must differ from the no-marker-at-all diagnostic',
|
|
);
|
|
});
|
|
|
|
test('init.progress fail-safe guard: marker present but names an invalid workstream name -> reason workstream_mode_marker_unresolved, invalid_name', () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'bad/name\n');
|
|
|
|
const result = runGsdTools(['--json-errors', 'init', 'progress'], tmpDir);
|
|
|
|
assert.equal(result.success, false, 'must still refuse');
|
|
const payload = JSON.parse(result.error);
|
|
assert.equal(payload.reason, 'workstream_mode_marker_unresolved');
|
|
assert.equal(payload.marker_value, 'bad/name');
|
|
assert.equal(payload.marker_reason, 'invalid_name');
|
|
});
|
|
});
|
|
|
|
describe('pointer lifecycle hardening', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
|
|
for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) {
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws);
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** ${status}\n`);
|
|
}
|
|
});
|
|
|
|
afterEach(() => cleanup(tmpDir));
|
|
|
|
test('clearing one session pointer leaves sibling session pointers intact', () => {
|
|
const sessionDir = getSessionPointerDir(tmpDir);
|
|
const alphaFile = getSessionPointerFileName('GSD_SESSION_KEY', 'session-alpha');
|
|
const betaFile = getSessionPointerFileName('GSD_SESSION_KEY', 'session-beta');
|
|
|
|
runGsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
runGsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-beta' });
|
|
|
|
const clearAlpha = runGsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
const beta = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-beta' });
|
|
|
|
assert.ok(clearAlpha.success, `clear alpha failed: ${clearAlpha.error}`);
|
|
assert.ok(beta.success, `beta get failed: ${beta.error}`);
|
|
assert.strictEqual(beta.output, 'beta');
|
|
assert.ok(fs.existsSync(sessionDir), 'session tmp directory should remain while a sibling pointer exists');
|
|
assert.deepStrictEqual(fs.readdirSync(sessionDir).sort(), [betaFile]);
|
|
assert.ok(!fs.existsSync(path.join(sessionDir, alphaFile)));
|
|
});
|
|
|
|
test('stale pointers self-clean without deleting sibling session pointers', () => {
|
|
const sessionDir = getSessionPointerDir(tmpDir);
|
|
const betaFile = getSessionPointerFileName('GSD_SESSION_KEY', 'session-beta');
|
|
|
|
runGsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
runGsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-beta' });
|
|
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- mid-test fault injection: simulates a deleted workstream to exercise stale-pointer self-cleanup
|
|
fs.rmSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true, force: true });
|
|
|
|
const alpha = runGsdTools(['workstream', 'get'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
const beta = runGsdTools(['workstream', 'get', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-beta' });
|
|
|
|
assert.ok(alpha.success, `stale alpha get failed: ${alpha.error}`);
|
|
assert.ok(beta.success, `beta get after stale cleanup failed: ${beta.error}`);
|
|
assert.strictEqual(JSON.parse(alpha.output).active, null);
|
|
assert.strictEqual(beta.output, 'beta');
|
|
assert.ok(fs.existsSync(sessionDir), 'sibling pointer should keep the session tmp directory alive');
|
|
assert.deepStrictEqual(fs.readdirSync(sessionDir).sort(), [betaFile]);
|
|
});
|
|
|
|
test('clearing the last session pointer removes the empty session tmp directory', () => {
|
|
const sessionDir = getSessionPointerDir(tmpDir);
|
|
const set = runGsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
|
|
assert.ok(set.success, `set alpha failed: ${set.error}`);
|
|
assert.ok(fs.existsSync(sessionDir), 'session tmp directory should exist after storing a session-scoped pointer');
|
|
|
|
const clear = runGsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir, { GSD_SESSION_KEY: 'session-alpha' });
|
|
|
|
assert.ok(clear.success, `clear alpha failed: ${clear.error}`);
|
|
assert.ok(!fs.existsSync(sessionDir), 'last-pointer cleanup should remove the empty session tmp directory');
|
|
});
|
|
});
|
|
|
|
// ─── Workstream CRUD ────────────────────────────────────────────────────────
|
|
|
|
describe('workstream create', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('creates a new workstream in clean project', () => {
|
|
const result = runGsdTools(['workstream', 'create', 'feature-x', '--raw'], tmpDir);
|
|
assert.ok(result.success, `create failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.created, true);
|
|
assert.strictEqual(data.workstream, 'feature-x');
|
|
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-x', 'STATE.md')));
|
|
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-x', 'phases')));
|
|
});
|
|
|
|
test('sets created workstream as active', () => {
|
|
const active = fs.readFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'utf-8').trim();
|
|
assert.strictEqual(active, 'feature-x');
|
|
});
|
|
|
|
test('rejects duplicate workstream', () => {
|
|
const result = runGsdTools(['workstream', 'create', 'feature-x', '--raw'], tmpDir);
|
|
assert.ok(result.success); // returns success with error field
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.created, false);
|
|
assert.strictEqual(data.error, 'already_exists');
|
|
});
|
|
|
|
test('creates second workstream', () => {
|
|
const result = runGsdTools(['workstream', 'create', 'feature-y', '--raw'], tmpDir);
|
|
assert.ok(result.success);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.created, true);
|
|
assert.strictEqual(data.workstream, 'feature-y');
|
|
});
|
|
});
|
|
|
|
describe('workstream create with migration', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n');
|
|
// Existing flat-mode work
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '## Roadmap v1.0: Existing\n### Phase 1: A\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n**Status:** In progress\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('migrates existing flat work to named workstream', () => {
|
|
const result = runGsdTools(['workstream', 'create', 'new-feature', '--migrate-name', 'existing-work', '--raw'], tmpDir);
|
|
assert.ok(result.success, `create with migration failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.created, true);
|
|
assert.ok(data.migration, 'should include migration info');
|
|
assert.strictEqual(data.migration.workstream, 'existing-work');
|
|
// Old flat files moved to workstream dir
|
|
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'existing-work', 'ROADMAP.md')));
|
|
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'existing-work', 'STATE.md')));
|
|
// Shared files stay
|
|
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'PROJECT.md')));
|
|
});
|
|
|
|
test('normalizes --migrate-name to a valid workstream slug', () => {
|
|
const isolatedDir = createFixture();
|
|
try {
|
|
fs.writeFileSync(path.join(isolatedDir, '.planning', 'PROJECT.md'), '# Project\n');
|
|
fs.writeFileSync(path.join(isolatedDir, '.planning', 'ROADMAP.md'), '## Roadmap v1.0: Existing\n### Phase 1: A\n');
|
|
fs.writeFileSync(path.join(isolatedDir, '.planning', 'STATE.md'), '# State\n**Status:** In progress\n');
|
|
|
|
const result = runGsdTools(
|
|
['workstream', 'create', 'new-feature', '--migrate-name', 'Bad Name', '--raw'],
|
|
isolatedDir
|
|
);
|
|
assert.ok(result.success, `create with migrate-name normalization failed: ${result.error}`);
|
|
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.created, true);
|
|
assert.strictEqual(data.migration.workstream, 'bad-name');
|
|
assert.ok(fs.existsSync(path.join(isolatedDir, '.planning', 'workstreams', 'bad-name', 'ROADMAP.md')));
|
|
assert.ok(!fs.existsSync(path.join(isolatedDir, '.planning', 'workstreams', 'Bad Name')));
|
|
} finally {
|
|
cleanup(isolatedDir);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('migrateToWorkstreams', () => {
|
|
test('rejects invalid workstream names for migration', () => {
|
|
const tmpDir = createFixture();
|
|
try {
|
|
assert.throws(
|
|
() => migrateToWorkstreams(tmpDir, 'bad/name'),
|
|
/Invalid workstream name for migration/
|
|
);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
test('fails when already in workstream mode', () => {
|
|
const tmpDir = createFixture();
|
|
try {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'existing'), { recursive: true });
|
|
assert.throws(
|
|
() => migrateToWorkstreams(tmpDir, 'new-stream'),
|
|
/Already in workstream mode/
|
|
);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('workstream list', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
// Create two workstreams
|
|
for (const ws of ['alpha', 'beta']) {
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws);
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** Working on ${ws}\n**Current Phase:** 1\n`);
|
|
}
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'beta\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('lists all workstreams with active first, then lexical name', () => {
|
|
const result = runGsdTools(['workstream', 'list', '--raw'], tmpDir);
|
|
assert.ok(result.success, `list failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.mode, 'workstream');
|
|
assert.strictEqual(data.count, 2);
|
|
assert.deepStrictEqual(data.workstreams.map(w => w.name), ['beta', 'alpha']);
|
|
});
|
|
|
|
describe('flat mode', () => {
|
|
let flatDir;
|
|
|
|
beforeEach(() => {
|
|
flatDir = createFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(flatDir);
|
|
});
|
|
|
|
test('reports flat mode when no workstreams exist', () => {
|
|
const result = runGsdTools(['workstream', 'list', '--raw'], flatDir);
|
|
assert.ok(result.success);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.mode, 'flat');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('workstream status', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha');
|
|
fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'phases', '01-setup', 'PLAN.md'), '# Plan\n');
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 1 — Setup\n');
|
|
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('returns detailed status for workstream', () => {
|
|
const result = runGsdTools(['workstream', 'status', 'alpha', '--raw'], tmpDir);
|
|
assert.ok(result.success, `status failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.found, true);
|
|
assert.strictEqual(data.workstream, 'alpha');
|
|
assert.strictEqual(data.files.roadmap, true);
|
|
assert.strictEqual(data.files.state, true);
|
|
assert.strictEqual(data.phase_count, 1);
|
|
});
|
|
|
|
test('returns not found for missing workstream', () => {
|
|
const result = runGsdTools(['workstream', 'status', 'nonexistent', '--raw'], tmpDir);
|
|
assert.ok(result.success);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.found, false);
|
|
});
|
|
});
|
|
|
|
describe('workstream complete', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'done-ws');
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** Complete\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'done-ws\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('archives workstream to milestones/', () => {
|
|
const result = runGsdTools(['workstream', 'complete', 'done-ws', '--raw'], tmpDir);
|
|
assert.ok(result.success, `complete failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.completed, true);
|
|
assert.ok(data.archived_to.startsWith('.planning/milestones/ws-done-ws'));
|
|
// Workstream dir should be gone
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'done-ws')));
|
|
});
|
|
|
|
test('clears active-workstream when completing active one', () => {
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'active-workstream')));
|
|
});
|
|
});
|
|
|
|
describe('workstream set/get', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
for (const ws of ['ws-a', 'ws-b']) {
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws);
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n');
|
|
}
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('sets active workstream', () => {
|
|
const result = runGsdTools(['workstream', 'set', 'ws-a', '--raw'], tmpDir);
|
|
assert.ok(result.success);
|
|
assert.strictEqual(result.output, 'ws-a');
|
|
});
|
|
|
|
test('gets active workstream', () => {
|
|
const result = runGsdTools(['workstream', 'get', '--raw'], tmpDir);
|
|
assert.ok(result.success);
|
|
assert.strictEqual(result.output, 'ws-a');
|
|
});
|
|
|
|
test('errors when set called with no name (#1527)', () => {
|
|
const result = runGsdTools(['workstream', 'set', '--raw'], tmpDir);
|
|
assert.ok(!result.success, 'should fail when no name provided');
|
|
assert.ok(result.error.includes('name required'), 'error should mention name required');
|
|
});
|
|
|
|
test('--clear explicitly unsets active workstream', () => {
|
|
// First set one
|
|
runGsdTools(['workstream', 'set', 'ws-b', '--raw'], tmpDir);
|
|
// Then clear
|
|
const result = runGsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir);
|
|
assert.ok(result.success);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.active, null);
|
|
assert.strictEqual(data.cleared, true);
|
|
assert.strictEqual(data.previous, 'ws-b');
|
|
});
|
|
});
|
|
|
|
// ─── Collision Detection ────────────────────────────────────────────────────
|
|
|
|
describe('getOtherActiveWorkstreams', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
// Create 3 workstreams: alpha (active), beta (active), gamma (completed)
|
|
for (const ws of ['alpha', 'beta', 'gamma']) {
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws);
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
}
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'STATE.md'),
|
|
'# State\n**Status:** In progress\n**Current Phase:** 3\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'beta', 'STATE.md'),
|
|
'# State\n**Status:** In progress\n**Current Phase:** 5\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'gamma', 'STATE.md'),
|
|
'# State\n**Status:** 1.0 milestone complete\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('workstream list excludes completed workstreams from active count', () => {
|
|
const result = runGsdTools(['workstream', 'list', '--raw'], tmpDir);
|
|
assert.ok(result.success);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.count, 3); // all listed
|
|
const activeWs = data.workstreams.filter(w =>
|
|
!w.status.toLowerCase().includes('milestone complete'));
|
|
assert.strictEqual(activeWs.length, 2); // alpha and beta active
|
|
});
|
|
|
|
test('returns only non-complete siblings with phase progress metadata', () => {
|
|
const alphaPlan = path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'phases', '01-alpha', 'PLAN.md');
|
|
const betaPlan = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases', '01-beta', 'PLAN.md');
|
|
const betaSummary = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases', '01-beta', 'SUMMARY.md');
|
|
fs.mkdirSync(path.dirname(alphaPlan), { recursive: true });
|
|
fs.mkdirSync(path.dirname(betaPlan), { recursive: true });
|
|
fs.writeFileSync(alphaPlan, '# Plan\n');
|
|
fs.writeFileSync(betaPlan, '# Plan\n');
|
|
fs.writeFileSync(betaSummary, '# Summary\n');
|
|
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
|
|
// *-VERIFICATION.md is what makes beta's phase count as complete now.
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases', '01-beta', '01-VERIFICATION.md'),
|
|
'---\nstatus: passed\n---\n# Verification\n',
|
|
);
|
|
|
|
const others = getOtherActiveWorkstreams(tmpDir, 'alpha');
|
|
assert.strictEqual(others.length, 1);
|
|
assert.strictEqual(others[0].name, 'beta');
|
|
assert.strictEqual(others[0].phases, '1/1');
|
|
});
|
|
});
|
|
|
|
describe('workstream progress', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
const alphaDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha');
|
|
fs.mkdirSync(path.join(alphaDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(alphaDir, 'STATE.md'), '# State\n**Status:** In progress\n');
|
|
fs.writeFileSync(path.join(alphaDir, 'ROADMAP.md'), '## Roadmap\n');
|
|
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'feature');
|
|
fs.mkdirSync(path.join(wsDir, 'phases', '01-init'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'phases', '01-init', 'PLAN.md'), '# Plan\n');
|
|
fs.writeFileSync(path.join(wsDir, 'phases', '01-init', 'SUMMARY.md'), '# Summary\n');
|
|
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
|
|
// *-VERIFICATION.md is what makes phase 01 count as complete now.
|
|
fs.writeFileSync(path.join(wsDir, 'phases', '01-init', '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 2\n');
|
|
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap\n### Phase 1: Init\n### Phase 2: Build\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'feature\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('returns progress summary in deterministic order', () => {
|
|
const result = runGsdTools(['workstream', 'progress', '--raw'], tmpDir);
|
|
assert.ok(result.success, `progress failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.mode, 'workstream');
|
|
assert.strictEqual(data.count, 2);
|
|
assert.deepStrictEqual(data.workstreams.map(w => w.name), ['feature', 'alpha']);
|
|
assert.strictEqual(data.workstreams[0].name, 'feature');
|
|
assert.strictEqual(data.workstreams[0].active, true);
|
|
assert.strictEqual(data.workstreams[0].progress_percent, 50);
|
|
});
|
|
|
|
test('clamps progress percent when completed phase dirs exceed roadmap count', () => {
|
|
const isolatedDir = createFixture();
|
|
try {
|
|
const wsDir = path.join(isolatedDir, '.planning', 'workstreams', 'overflow');
|
|
for (const phase of ['01-one', '02-two']) {
|
|
const phaseDir = path.join(wsDir, 'phases', phase);
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), '# Plan\n');
|
|
fs.writeFileSync(path.join(phaseDir, 'SUMMARY.md'), '# Summary\n');
|
|
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
|
|
// *-VERIFICATION.md is what makes each phase count as complete now.
|
|
fs.writeFileSync(path.join(phaseDir, `${phase.slice(0, 2)}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n');
|
|
}
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n');
|
|
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '# Roadmap\n### Phase 1: One\n');
|
|
|
|
const result = runGsdTools(['workstream', 'progress', '--raw'], isolatedDir);
|
|
assert.ok(result.success, `progress failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.workstreams[0].progress_percent, 100);
|
|
} finally {
|
|
cleanup(isolatedDir);
|
|
}
|
|
});
|
|
|
|
test('returns flat mode when no workstreams exist', () => {
|
|
const emptyDir = createFixture();
|
|
try {
|
|
const result = runGsdTools(['workstream', 'progress', '--raw'], emptyDir);
|
|
assert.ok(result.success, `progress in flat mode failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.mode, 'flat');
|
|
} finally {
|
|
cleanup(emptyDir);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Integration: gsd-tools --ws flag ────────────────────────────────────────
|
|
|
|
describe('gsd-tools --ws flag integration', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
// Create a workstream with roadmap
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'test-ws');
|
|
fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'),
|
|
'## Roadmap v1.0: Test\n### Phase 1: Setup\nDo setup things.\n');
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'),
|
|
'---\nmilestone: v1.0\n---\n# State\n**Status:** In progress\n**Current Phase:** 1 — Setup\n');
|
|
fs.writeFileSync(path.join(wsDir, 'phases', '01-setup', 'PLAN.md'), '# Plan\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('find-phase resolves to workstream-scoped phases via --ws', () => {
|
|
const result = runGsdTools(['find-phase', '1', '--raw', '--ws', 'test-ws'], tmpDir);
|
|
assert.ok(result.success, `find-phase failed: ${result.error}`);
|
|
assert.ok(result.output.includes('workstreams/test-ws'), `path should be workstream-scoped: ${result.output}`);
|
|
});
|
|
|
|
test('find-phase returns JSON with workstream path when not raw', () => {
|
|
const result = runGsdTools(['find-phase', '1', '--ws', 'test-ws'], tmpDir);
|
|
assert.ok(result.success, `find-phase failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.ok(data.found, 'phase should be found');
|
|
assert.ok(data.directory.includes('workstreams/test-ws'), `path should be workstream-scoped: ${data.directory}`);
|
|
});
|
|
});
|
|
|
|
// ─── Path Traversal Rejection ────────────────────────────────────────────────
|
|
|
|
describe('path traversal rejection', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n');
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'legit');
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
const maliciousNames = [
|
|
'../../etc',
|
|
'../foo',
|
|
'ws/../../../passwd',
|
|
'a/b',
|
|
'ws name with spaces',
|
|
'..',
|
|
'.',
|
|
'ws..traversal',
|
|
];
|
|
|
|
describe('--ws flag rejects traversal attempts', () => {
|
|
for (const name of maliciousNames) {
|
|
test(`rejects --ws=${name}`, () => {
|
|
const result = runGsdTools(['workstream', 'list', '--raw', '--ws', name], tmpDir);
|
|
assert.ok(!result.success, `should reject --ws=${name}`);
|
|
assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('GSD_WORKSTREAM env var rejects traversal attempts', () => {
|
|
for (const name of maliciousNames) {
|
|
test(`rejects GSD_WORKSTREAM=${name}`, () => {
|
|
const result = runGsdTools(['workstream', 'list', '--raw'], tmpDir, { GSD_WORKSTREAM: name });
|
|
assert.ok(!result.success, `should reject GSD_WORKSTREAM=${name}`);
|
|
assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('cmdWorkstreamSet rejects traversal attempts', () => {
|
|
for (const name of maliciousNames) {
|
|
test(`rejects set ${name}`, () => {
|
|
const result = runGsdTools(['workstream', 'set', name, '--raw'], tmpDir);
|
|
// cmdWorkstreamSet validates the positional arg and returns invalid_name error
|
|
assert.ok(result.success, `command should exit cleanly for: ${name}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.error, 'invalid_name', `should return invalid_name error for: ${name}`);
|
|
assert.strictEqual(data.active, null, `active should be null for: ${name}`);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('cmdWorkstreamStatus rejects invalid names consistently', () => {
|
|
for (const name of maliciousNames) {
|
|
test(`rejects status ${name}`, () => {
|
|
const result = runGsdTools(['workstream', 'status', name, '--raw'], tmpDir);
|
|
assert.ok(!result.success, `status should reject invalid name: ${name}`);
|
|
assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('cmdWorkstreamComplete rejects invalid names consistently', () => {
|
|
for (const name of maliciousNames) {
|
|
test(`rejects complete ${name}`, () => {
|
|
const result = runGsdTools(['workstream', 'complete', name, '--raw'], tmpDir);
|
|
assert.ok(!result.success, `complete should reject invalid name: ${name}`);
|
|
assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('getActiveWorkstream rejects poisoned active-workstream file', () => {
|
|
for (const name of maliciousNames) {
|
|
test(`rejects poisoned file containing ${name}`, () => {
|
|
// Write malicious name directly to the active-workstream file
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), name + '\n');
|
|
const result = runGsdTools(['workstream', 'get'], tmpDir, { GSD_WORKSTREAM: '' });
|
|
assert.ok(result.success, 'get should succeed');
|
|
const data = JSON.parse(result.output);
|
|
// getActiveWorkstream should return null for invalid names
|
|
assert.strictEqual(data.active, null, `should return null for poisoned name: ${name}`);
|
|
});
|
|
}
|
|
|
|
// Cleanup: remove poisoned file
|
|
test('cleanup: remove active-workstream file', () => {
|
|
try { fs.unlinkSync(path.join(tmpDir, '.planning', 'active-workstream')); } catch {}
|
|
});
|
|
});
|
|
|
|
describe('setActiveWorkstream rejects invalid names directly', () => {
|
|
const { setActiveWorkstream } = require('../gsd-core/bin/lib/planning-workspace.cjs');
|
|
for (const name of maliciousNames) {
|
|
test(`throws for ${name}`, () => {
|
|
assert.throws(
|
|
() => setActiveWorkstream(tmpDir, name),
|
|
{ message: /Invalid workstream name/ },
|
|
`should throw for: ${name}`
|
|
);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// #2562: the inventory can refuse a shipped marker its artifacts contradict, but
|
|
// a refusal that no command projects is the same silent collapse the issue is
|
|
// about — the operator sees a fallback `status` and nothing saying a marker was
|
|
// seen and rejected. These assert at the CLI, the surface that was missing it,
|
|
// not at the builder that already had the field.
|
|
describe('#2562 — a refused shipped marker reaches every workstream command', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = createFixture();
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'dirty-archive');
|
|
// A milestone that was archived (snapshot present) and then reopened: phase 1
|
|
// is complete and still on disk, phase 2 is declared with no directory. The
|
|
// archive is therefore not clean AND the ratio is short.
|
|
fs.mkdirSync(path.join(wsDir, 'phases', '1-foo'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'phases', '1-foo', '01-PLAN.md'), '# Plan\n');
|
|
fs.writeFileSync(path.join(wsDir, 'phases', '1-foo', '01-SUMMARY.md'), '# Summary\n');
|
|
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
|
|
// *-VERIFICATION.md is what makes phase 1 count as complete now.
|
|
fs.writeFileSync(path.join(wsDir, 'phases', '1-foo', '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), 'milestone: v2.0\nstatus: executing\n');
|
|
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), [
|
|
'# Roadmap', '', '## Milestone v2.0 — Two', '', '## Progress', '',
|
|
'| Phase | Milestone | Plans | Status | Done |',
|
|
'| --- | --- | --- | --- | --- |',
|
|
'| 1. Foo | v2.0 | 1/1 | Complete | - |',
|
|
'| 2. Bar | v2.0 | 0/1 | Not started | - |', '',
|
|
].join('\n'));
|
|
fs.mkdirSync(path.join(wsDir, 'milestones'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'milestones', 'v2.0-ROADMAP.md'), '# v2.0 archived\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'dirty-archive\n');
|
|
});
|
|
|
|
after(() => cleanup(tmpDir));
|
|
|
|
test('workstream progress projects the refusal beside the percentage', () => {
|
|
const result = runGsdTools(['workstream', 'progress', '--raw'], tmpDir);
|
|
assert.ok(result.success, `progress failed: ${result.error}`);
|
|
const ws = JSON.parse(result.output).workstreams.find(w => w.name === 'dirty-archive');
|
|
assert.ok(ws, 'workstream missing from progress output');
|
|
assert.strictEqual(ws.milestone_shipped_unverified, true);
|
|
assert.notStrictEqual(ws.status, 'milestone complete', 'status must not contradict the percentage');
|
|
assert.strictEqual(ws.progress_percent, 50);
|
|
});
|
|
|
|
test('workstream status projects the refusal', () => {
|
|
const result = runGsdTools(['workstream', 'status', 'dirty-archive', '--raw'], tmpDir);
|
|
assert.ok(result.success, `status failed: ${result.error}`);
|
|
const data = JSON.parse(result.output);
|
|
assert.strictEqual(data.found, true);
|
|
assert.strictEqual(data.milestone_shipped_unverified, true);
|
|
});
|
|
|
|
test('workstream list projects the refusal', () => {
|
|
const result = runGsdTools(['workstream', 'list', '--raw'], tmpDir);
|
|
assert.ok(result.success, `list failed: ${result.error}`);
|
|
const ws = JSON.parse(result.output).workstreams.find(w => w.name === 'dirty-archive');
|
|
assert.ok(ws, 'workstream missing from list output');
|
|
assert.strictEqual(ws.milestone_shipped_unverified, true);
|
|
});
|
|
|
|
test('a clean archive reports no refusal at the CLI', () => {
|
|
const isolatedDir = createFixture();
|
|
try {
|
|
const wsDir = path.join(isolatedDir, '.planning', 'workstreams', 'clean-archive');
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); // dirs moved out by the archive
|
|
fs.writeFileSync(path.join(wsDir, 'STATE.md'), 'milestone: v2.0\nstatus: executing\n');
|
|
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), [
|
|
'# Roadmap', '', '## Milestone v2.0 — Two', '', '## Progress', '',
|
|
'| Phase | Milestone | Plans | Status | Done |',
|
|
'| --- | --- | --- | --- | --- |',
|
|
'| 1. Foo | v2.0 | 1/1 | Complete | - |',
|
|
'| 2. Bar | v2.0 | 1/1 | Complete | - |', '',
|
|
].join('\n'));
|
|
fs.mkdirSync(path.join(wsDir, 'milestones'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'milestones', 'v2.0-ROADMAP.md'), '# v2.0 archived\n');
|
|
|
|
const result = runGsdTools(['workstream', 'progress', '--raw'], isolatedDir);
|
|
assert.ok(result.success, `progress failed: ${result.error}`);
|
|
const ws = JSON.parse(result.output).workstreams.find(w => w.name === 'clean-archive');
|
|
assert.ok(ws, 'workstream missing from progress output');
|
|
assert.strictEqual(ws.milestone_shipped_unverified, false);
|
|
assert.strictEqual(ws.status, 'milestone complete');
|
|
} finally {
|
|
cleanup(isolatedDir);
|
|
}
|
|
});
|
|
});
|