* 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>
1465 lines
60 KiB
JavaScript
1465 lines
60 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Focused unit tests for active-workstream-store.cjs
|
|
* Targets untested branches to raise mutation score above 60%.
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = 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 { cleanup, saveSessionEnv, restoreSessionEnv, clearSessionEnv } = require('./helpers.cjs');
|
|
|
|
const {
|
|
validateWorkstreamName,
|
|
getWorkstreamSessionKey,
|
|
createSharedPointerAdapter,
|
|
createSessionScopedPointerAdapter,
|
|
createMemoryPointerAdapter,
|
|
pickActiveWorkstreamAdapter,
|
|
pickActiveWorkstreamAdapterChain,
|
|
getActiveWorkstream,
|
|
peekActiveWorkstream,
|
|
diagnoseUnresolvedActiveWorkstream,
|
|
setActiveWorkstream,
|
|
clearActiveWorkstream,
|
|
parseCliWorkstream,
|
|
resolveActiveWorkstream,
|
|
applyResolvedWorkstreamEnv,
|
|
_resetControllingTtyCacheForTests,
|
|
} = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
//
|
|
// saveSessionEnv/restoreSessionEnv/clearSessionEnv now live in tests/helpers.cjs
|
|
// (single source of truth — #2850 code review finding: this file's local copy
|
|
// had already silently diverged from tests/gsd-statusline.test.cjs's copy).
|
|
|
|
function makePlanningDir(base, ...workstreams) {
|
|
const wsDir = path.join(base, '.planning', 'workstreams');
|
|
fs.mkdirSync(wsDir, { recursive: true });
|
|
for (const ws of workstreams) {
|
|
fs.mkdirSync(path.join(wsDir, ws), { recursive: true });
|
|
}
|
|
}
|
|
|
|
// ── validateWorkstreamName ────────────────────────────────────────────────────
|
|
|
|
describe('validateWorkstreamName — exact values', () => {
|
|
test('accepts dot in name', () => {
|
|
assert.equal(validateWorkstreamName('alpha.2'), true);
|
|
});
|
|
|
|
test('rejects null', () => {
|
|
assert.equal(validateWorkstreamName(null), false);
|
|
});
|
|
|
|
test('rejects undefined', () => {
|
|
assert.equal(validateWorkstreamName(undefined), false);
|
|
});
|
|
|
|
test('rejects empty string', () => {
|
|
assert.equal(validateWorkstreamName(''), false);
|
|
});
|
|
|
|
test('rejects whitespace-only', () => {
|
|
assert.equal(validateWorkstreamName(' '), false);
|
|
});
|
|
|
|
test('rejects name with spaces', () => {
|
|
assert.equal(validateWorkstreamName('hello world'), false);
|
|
});
|
|
|
|
test('rejects path traversal', () => {
|
|
assert.equal(validateWorkstreamName('../escape'), false);
|
|
});
|
|
|
|
test('accepts single char', () => {
|
|
assert.equal(validateWorkstreamName('a'), true);
|
|
});
|
|
|
|
test('accepts underscore', () => {
|
|
assert.equal(validateWorkstreamName('my_ws'), true);
|
|
});
|
|
|
|
test('accepts hyphen', () => {
|
|
assert.equal(validateWorkstreamName('my-ws'), true);
|
|
});
|
|
});
|
|
|
|
// ── createMemoryPointerAdapter ────────────────────────────────────────────────
|
|
|
|
describe('createMemoryPointerAdapter', () => {
|
|
test('initial value defaults to null', () => {
|
|
const a = createMemoryPointerAdapter();
|
|
assert.equal(a.read(), null);
|
|
});
|
|
|
|
test('initial value can be set', () => {
|
|
const a = createMemoryPointerAdapter('alpha');
|
|
assert.equal(a.read(), 'alpha');
|
|
});
|
|
|
|
test('write updates value', () => {
|
|
const a = createMemoryPointerAdapter(null);
|
|
a.write('beta');
|
|
assert.equal(a.read(), 'beta');
|
|
});
|
|
|
|
test('write then write replaces value', () => {
|
|
const a = createMemoryPointerAdapter('alpha');
|
|
a.write('beta');
|
|
assert.equal(a.read(), 'beta');
|
|
});
|
|
|
|
test('clear sets value to null', () => {
|
|
const a = createMemoryPointerAdapter('alpha');
|
|
a.clear();
|
|
assert.equal(a.read(), null);
|
|
});
|
|
|
|
test('clear after write sets to null', () => {
|
|
const a = createMemoryPointerAdapter(null);
|
|
a.write('alpha');
|
|
a.clear();
|
|
assert.equal(a.read(), null);
|
|
});
|
|
|
|
test('clear then read returns null', () => {
|
|
const a = createMemoryPointerAdapter('ws');
|
|
a.clear();
|
|
assert.strictEqual(a.read(), null);
|
|
});
|
|
});
|
|
|
|
// ── createSharedPointerAdapter ────────────────────────────────────────────────
|
|
|
|
describe('createSharedPointerAdapter', () => {
|
|
let tmpDir;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-unit-shared-'));
|
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
|
});
|
|
afterEach(() => cleanup(tmpDir));
|
|
|
|
test('read returns null when file does not exist', () => {
|
|
const adapter = createSharedPointerAdapter(tmpDir);
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('write then read returns exact name', () => {
|
|
const adapter = createSharedPointerAdapter(tmpDir);
|
|
adapter.write('my-ws');
|
|
assert.equal(adapter.read(), 'my-ws');
|
|
});
|
|
|
|
test('write appends newline but read strips it', () => {
|
|
const adapter = createSharedPointerAdapter(tmpDir);
|
|
adapter.write('trimmed');
|
|
const filePath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
assert.equal(raw, 'trimmed\n');
|
|
assert.equal(adapter.read(), 'trimmed');
|
|
});
|
|
|
|
test('read returns null for whitespace-only content', () => {
|
|
const adapter = createSharedPointerAdapter(tmpDir);
|
|
const filePath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(filePath, ' \n');
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('clear removes the file', () => {
|
|
const adapter = createSharedPointerAdapter(tmpDir);
|
|
adapter.write('my-ws');
|
|
adapter.clear();
|
|
const filePath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
assert.equal(fs.existsSync(filePath), false);
|
|
});
|
|
|
|
test('clear on non-existent file does not throw', () => {
|
|
const adapter = createSharedPointerAdapter(tmpDir);
|
|
assert.doesNotThrow(() => adapter.clear());
|
|
});
|
|
|
|
test('read returns null when file is empty', () => {
|
|
const adapter = createSharedPointerAdapter(tmpDir);
|
|
const filePath = path.join(tmpDir, '.planning', 'active-workstream');
|
|
fs.writeFileSync(filePath, '');
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
});
|
|
|
|
// ── createSessionScopedPointerAdapter ────────────────────────────────────────
|
|
|
|
describe('createSessionScopedPointerAdapter', () => {
|
|
let tmpDir;
|
|
let saved;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-unit-session-'));
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => {
|
|
restoreSessionEnv(saved);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('returns null when no session key available', () => {
|
|
const adapter = createSessionScopedPointerAdapter(tmpDir);
|
|
assert.equal(adapter, null);
|
|
});
|
|
|
|
test('returns adapter object when session key provided', () => {
|
|
const adapter = createSessionScopedPointerAdapter(tmpDir, 'test-session-key');
|
|
assert.notEqual(adapter, null);
|
|
assert.equal(typeof adapter.read, 'function');
|
|
assert.equal(typeof adapter.write, 'function');
|
|
assert.equal(typeof adapter.clear, 'function');
|
|
});
|
|
|
|
test('read returns null before any write', () => {
|
|
const adapter = createSessionScopedPointerAdapter(tmpDir, 'test-session-key');
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('write then read returns exact name', () => {
|
|
const adapter = createSessionScopedPointerAdapter(tmpDir, 'test-session-key');
|
|
adapter.write('session-ws');
|
|
assert.equal(adapter.read(), 'session-ws');
|
|
});
|
|
|
|
test('clear after write returns null', () => {
|
|
const adapter = createSessionScopedPointerAdapter(tmpDir, 'test-session-key');
|
|
adapter.write('session-ws');
|
|
adapter.clear();
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('clear on empty dir removes dir', () => {
|
|
const adapter = createSessionScopedPointerAdapter(tmpDir, 'test-session-key');
|
|
adapter.write('session-ws');
|
|
adapter.clear();
|
|
// after clear, the file and possibly the dir should be gone
|
|
// at minimum, clear should not throw
|
|
assert.doesNotThrow(() => adapter.clear());
|
|
});
|
|
|
|
test('read returns null for whitespace-only content', () => {
|
|
const adapter = createSessionScopedPointerAdapter(tmpDir, 'test-session-key');
|
|
adapter.write(' ');
|
|
// write appends \n, so content is " \n"; trim returns '', so read returns null
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('uses env session key when no fixed key provided', () => {
|
|
process.env.GSD_SESSION_KEY = 'env-session';
|
|
const adapter = createSessionScopedPointerAdapter(tmpDir);
|
|
assert.notEqual(adapter, null);
|
|
adapter.write('env-ws');
|
|
assert.equal(adapter.read(), 'env-ws');
|
|
});
|
|
});
|
|
|
|
// ── pickActiveWorkstreamAdapter ───────────────────────────────────────────────
|
|
|
|
describe('pickActiveWorkstreamAdapter', () => {
|
|
let saved;
|
|
beforeEach(() => {
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => restoreSessionEnv(saved));
|
|
|
|
test('returns opts.activeWorkstreamAdapter when provided', () => {
|
|
const adapter = createMemoryPointerAdapter('alpha');
|
|
const picked = pickActiveWorkstreamAdapter('/fake', { activeWorkstreamAdapter: adapter });
|
|
assert.strictEqual(picked, adapter);
|
|
});
|
|
|
|
test('returns session adapter from adapters when session key exists', () => {
|
|
process.env.GSD_SESSION_KEY = 'some-session';
|
|
const session = createMemoryPointerAdapter('session-ws');
|
|
const shared = createMemoryPointerAdapter('shared-ws');
|
|
const picked = pickActiveWorkstreamAdapter('/fake', {
|
|
activeWorkstreamAdapters: { session, shared },
|
|
});
|
|
assert.strictEqual(picked, session);
|
|
});
|
|
|
|
test('returns shared adapter from adapters when no session key', () => {
|
|
const shared = createMemoryPointerAdapter('shared-ws');
|
|
const picked = pickActiveWorkstreamAdapter('/fake', {
|
|
activeWorkstreamAdapters: { shared },
|
|
});
|
|
assert.strictEqual(picked, shared);
|
|
});
|
|
|
|
test('creates shared pointer adapter when no opts and no session', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pick-'));
|
|
try {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
|
const picked = pickActiveWorkstreamAdapter(tmpDir, {});
|
|
assert.notEqual(picked, null);
|
|
assert.equal(typeof picked.read, 'function');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
test('creates session scoped adapter when session key exists and no adapters given', () => {
|
|
process.env.GSD_SESSION_KEY = 'my-session';
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pick-sess-'));
|
|
try {
|
|
const picked = pickActiveWorkstreamAdapter(tmpDir, {});
|
|
// session scoped adapter exists since session key is set
|
|
assert.notEqual(picked, null);
|
|
assert.equal(typeof picked.read, 'function');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
delete process.env.GSD_SESSION_KEY;
|
|
}
|
|
});
|
|
|
|
test('adapter not provided in opts returns shared adapter', () => {
|
|
const picked = pickActiveWorkstreamAdapter('/fake', {
|
|
activeWorkstreamAdapters: {},
|
|
});
|
|
assert.notEqual(picked, null);
|
|
});
|
|
});
|
|
|
|
// ── pickActiveWorkstreamAdapterChain (#3579) ─────────────────────────────────
|
|
//
|
|
// Each test below is written to KILL a specific surviving mutant reported by
|
|
// Stryker on this function: the `if (!sessionKey)` guard (and its block-body
|
|
// removal), the two `?? createMemoryPointerAdapter(null)` fallback sites, and
|
|
// the `session ? [session, shared] : []` shape mutant.
|
|
|
|
describe('pickActiveWorkstreamAdapterChain', () => {
|
|
let saved;
|
|
beforeEach(() => {
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => restoreSessionEnv(saved));
|
|
|
|
test('returns single-element [shared] chain when no session key present', () => {
|
|
// Kills: `if (!sessionKey)` → `if (false)`, and the BlockStatement removal
|
|
// on that guard's body. Both mutants would instead fall into the
|
|
// sessionKey-truthy branch and, since `injected.session` is supplied,
|
|
// return a 2-element [session, shared] chain instead of [shared].
|
|
const shared = createMemoryPointerAdapter('shared-ws');
|
|
const session = createMemoryPointerAdapter('session-ws');
|
|
const chain = pickActiveWorkstreamAdapterChain('/fake', {
|
|
activeWorkstreamAdapters: { session, shared },
|
|
});
|
|
assert.equal(chain.length, 1);
|
|
assert.strictEqual(chain[0], shared);
|
|
});
|
|
|
|
test('returns [session, shared] chain (length 2, in order) when session key present', () => {
|
|
// Kills: `return session ? [session, shared] : []` → `: []`. A mutant
|
|
// there collapses the chain to empty even though session key is set.
|
|
process.env.GSD_SESSION_KEY = 'chain-session';
|
|
const shared = createMemoryPointerAdapter('shared-ws');
|
|
const session = createMemoryPointerAdapter('session-ws');
|
|
const chain = pickActiveWorkstreamAdapterChain('/fake', {
|
|
activeWorkstreamAdapters: { session, shared },
|
|
});
|
|
assert.equal(chain.length, 2);
|
|
assert.strictEqual(chain[0], session);
|
|
assert.strictEqual(chain[1], shared);
|
|
});
|
|
|
|
test('no session key + adapters given without shared: fallback is inert memory adapter', () => {
|
|
// Kills: `injected.shared ?? createMemoryPointerAdapter(null)` → `&&` on
|
|
// the no-sessionKey branch. Under the mutant, `undefined && ...` is
|
|
// `undefined`, so chain[0] would be undefined instead of a usable adapter.
|
|
const chain = pickActiveWorkstreamAdapterChain('/fake', {
|
|
activeWorkstreamAdapters: {},
|
|
});
|
|
assert.equal(chain.length, 1);
|
|
assert.notEqual(chain[0], undefined);
|
|
assert.equal(typeof chain[0].read, 'function');
|
|
assert.equal(chain[0].read(), null);
|
|
});
|
|
|
|
test('session key present, only session injected: shared half is inert memory adapter', () => {
|
|
// Kills: `injected.shared ?? createMemoryPointerAdapter(null)` → `&&` on
|
|
// the sessionKey-truthy branch (the shared fallback site).
|
|
process.env.GSD_SESSION_KEY = 'chain-partial-shared';
|
|
const session = createMemoryPointerAdapter('session-ws');
|
|
const chain = pickActiveWorkstreamAdapterChain('/fake', {
|
|
activeWorkstreamAdapters: { session },
|
|
});
|
|
assert.equal(chain.length, 2);
|
|
assert.strictEqual(chain[0], session);
|
|
assert.notEqual(chain[1], undefined);
|
|
assert.equal(typeof chain[1].read, 'function');
|
|
assert.equal(chain[1].read(), null);
|
|
// in-memory only — proves it never touches the real shared marker file
|
|
chain[1].write('should-not-persist');
|
|
assert.equal(chain[1].read(), 'should-not-persist');
|
|
});
|
|
|
|
test('session key present, only shared injected: session half is inert memory adapter', () => {
|
|
// Kills: `injected.session ?? createMemoryPointerAdapter(null)` → `&&`.
|
|
process.env.GSD_SESSION_KEY = 'chain-partial-session';
|
|
const shared = createMemoryPointerAdapter('shared-ws');
|
|
const chain = pickActiveWorkstreamAdapterChain('/fake', {
|
|
activeWorkstreamAdapters: { shared },
|
|
});
|
|
assert.equal(chain.length, 2);
|
|
assert.notEqual(chain[0], undefined);
|
|
assert.equal(typeof chain[0].read, 'function');
|
|
assert.equal(chain[0].read(), null);
|
|
assert.strictEqual(chain[1], shared);
|
|
});
|
|
});
|
|
|
|
// ── getWorkstreamSessionKey ───────────────────────────────────────────────────
|
|
|
|
describe('getWorkstreamSessionKey', () => {
|
|
let saved;
|
|
beforeEach(() => {
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => restoreSessionEnv(saved));
|
|
|
|
test('returns null when no env keys set and no controlling TTY', () => {
|
|
// Force a deterministic non-TTY environment so the probe path is exercised
|
|
// regardless of whether this runs in a real developer terminal.
|
|
//
|
|
// Uses the _resetControllingTtyCacheForTests() seam (#1191) to clear the
|
|
// module-level memoized TTY probe result (cachedControllingTtyToken /
|
|
// didProbeControllingTtyToken) without busting the require.cache. This
|
|
// ensures overriding process.stdin.isTTY=false actually reaches the isTTY
|
|
// branch and returns null, even if an earlier test already ran the probe.
|
|
const savedIsTTY = process.stdin.isTTY;
|
|
try {
|
|
// Clear TTY/SSH_TTY (already done by beforeEach, but be explicit).
|
|
delete process.env.TTY;
|
|
delete process.env.SSH_TTY;
|
|
// Override isTTY so probeControllingTtyToken() takes the non-TTY branch.
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
|
|
// Reset the memoized probe cache via the seam so the probe re-runs.
|
|
_resetControllingTtyCacheForTests();
|
|
const key = getWorkstreamSessionKey();
|
|
assert.strictEqual(key, null);
|
|
} finally {
|
|
// Restore isTTY and reset the cache so subsequent tests get a clean probe.
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true, writable: true });
|
|
_resetControllingTtyCacheForTests();
|
|
}
|
|
});
|
|
|
|
test('returns gsd-session-key prefixed key for GSD_SESSION_KEY', () => {
|
|
process.env.GSD_SESSION_KEY = 'mysession';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'gsd-session-key-mysession');
|
|
});
|
|
|
|
test('returns codex-thread-id prefixed key for CODEX_THREAD_ID', () => {
|
|
process.env.CODEX_THREAD_ID = 'thread123';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'codex-thread-id-thread123');
|
|
});
|
|
|
|
test('returns claude-session-id for CLAUDE_SESSION_ID', () => {
|
|
process.env.CLAUDE_SESSION_ID = 'claude-abc';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'claude-session-id-claude-abc');
|
|
});
|
|
|
|
test('returns claude-code-sse-port for CLAUDE_CODE_SSE_PORT', () => {
|
|
process.env.CLAUDE_CODE_SSE_PORT = '9000';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'claude-code-sse-port-9000');
|
|
});
|
|
|
|
test('returns opencode-session-id for OPENCODE_SESSION_ID', () => {
|
|
process.env.OPENCODE_SESSION_ID = 'oc-123';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'opencode-session-id-oc-123');
|
|
});
|
|
|
|
test('returns gemini-session-id for GEMINI_SESSION_ID', () => {
|
|
process.env.GEMINI_SESSION_ID = 'gem-xyz';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'gemini-session-id-gem-xyz');
|
|
});
|
|
|
|
test('returns cursor-session-id for CURSOR_SESSION_ID', () => {
|
|
process.env.CURSOR_SESSION_ID = 'cur-001';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'cursor-session-id-cur-001');
|
|
});
|
|
|
|
test('returns windsurf-session-id for WINDSURF_SESSION_ID', () => {
|
|
process.env.WINDSURF_SESSION_ID = 'ws-surf';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'windsurf-session-id-ws-surf');
|
|
});
|
|
|
|
test('returns term-session-id for TERM_SESSION_ID', () => {
|
|
process.env.TERM_SESSION_ID = 'term-1';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'term-session-id-term-1');
|
|
});
|
|
|
|
test('returns wt-session for WT_SESSION', () => {
|
|
process.env.WT_SESSION = 'wt-abc';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'wt-session-wt-abc');
|
|
});
|
|
|
|
test('returns tmux-pane for TMUX_PANE', () => {
|
|
process.env.TMUX_PANE = '%1';
|
|
const key = getWorkstreamSessionKey();
|
|
// %1 → sanitize replaces % with _, then strips leading _ → "1"
|
|
assert.equal(key, 'tmux-pane-1');
|
|
});
|
|
|
|
test('returns zellij-session-name for ZELLIJ_SESSION_NAME', () => {
|
|
process.env.ZELLIJ_SESSION_NAME = 'my-zellij';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'zellij-session-name-my-zellij');
|
|
});
|
|
|
|
test('GSD_SESSION_KEY takes priority over CODEX_THREAD_ID', () => {
|
|
process.env.GSD_SESSION_KEY = 'first';
|
|
process.env.CODEX_THREAD_ID = 'second';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'gsd-session-key-first');
|
|
});
|
|
|
|
test('returns tty- prefixed key for TTY env var', () => {
|
|
process.env.TTY = '/dev/pts/1';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'tty-pts_1');
|
|
});
|
|
|
|
test('returns tty- prefixed key for SSH_TTY env var', () => {
|
|
process.env.SSH_TTY = '/dev/pts/2';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'tty-pts_2');
|
|
});
|
|
|
|
test('TTY takes priority over SSH_TTY', () => {
|
|
process.env.TTY = '/dev/pts/3';
|
|
process.env.SSH_TTY = '/dev/pts/4';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'tty-pts_3');
|
|
});
|
|
|
|
test('TTY with dev_ prefix gets stripped', () => {
|
|
process.env.TTY = 'dev_pts_1';
|
|
const key = getWorkstreamSessionKey();
|
|
// sanitize returns dev_pts_1 → tty-{dev_pts_1 with dev_ stripped} → tty-pts_1
|
|
assert.equal(key, 'tty-pts_1');
|
|
});
|
|
|
|
test('empty GSD_SESSION_KEY falls through', () => {
|
|
process.env.GSD_SESSION_KEY = '';
|
|
process.env.CODEX_THREAD_ID = 'fallback';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'codex-thread-id-fallback');
|
|
});
|
|
|
|
test('whitespace-only GSD_SESSION_KEY falls through', () => {
|
|
process.env.GSD_SESSION_KEY = ' ';
|
|
process.env.CODEX_THREAD_ID = 'fallback2';
|
|
const key = getWorkstreamSessionKey();
|
|
assert.equal(key, 'codex-thread-id-fallback2');
|
|
});
|
|
});
|
|
|
|
// ── getActiveWorkstream ───────────────────────────────────────────────────────
|
|
|
|
describe('getActiveWorkstream', () => {
|
|
let tmpDir;
|
|
let saved;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-unit-get-'));
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => {
|
|
restoreSessionEnv(saved);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('returns null when adapter reads null', () => {
|
|
const adapter = createMemoryPointerAdapter(null);
|
|
const result = getActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test('returns null and clears for invalid name', () => {
|
|
const adapter = createMemoryPointerAdapter('bad name!');
|
|
const result = getActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(result, null);
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('returns null and clears when workstream dir missing', () => {
|
|
makePlanningDir(tmpDir);
|
|
const adapter = createMemoryPointerAdapter('ghost-ws');
|
|
const result = getActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(result, null);
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('returns name when workstream dir exists', () => {
|
|
makePlanningDir(tmpDir, 'real-ws');
|
|
const adapter = createMemoryPointerAdapter('real-ws');
|
|
const result = getActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(result, 'real-ws');
|
|
});
|
|
|
|
test('adapter read is null after self-heal for stale pointer', () => {
|
|
makePlanningDir(tmpDir);
|
|
const adapter = createMemoryPointerAdapter('stale');
|
|
getActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('returns null for empty string name', () => {
|
|
const adapter = createMemoryPointerAdapter('');
|
|
const result = getActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test('returns correct ws name with dots', () => {
|
|
makePlanningDir(tmpDir, 'v1.2');
|
|
const adapter = createMemoryPointerAdapter('v1.2');
|
|
const result = getActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(result, 'v1.2');
|
|
});
|
|
});
|
|
|
|
// ── resolvesToExistingWorkstream (#3579, exercised via getActiveWorkstream) ──
|
|
//
|
|
// resolvesToExistingWorkstream is internal; these two tests reach it through
|
|
// its public callers and are designed to disagree with two survived mutants:
|
|
// `if (!name || !validateWorkstreamName(name))` → `if (false)` (name check
|
|
// skipped entirely) and → `if (false)`/`&&` (the `||` weakened to `&&`). Both
|
|
// tests plant a REAL directory at the exact stored name so that if the name
|
|
// check is bypassed, `fs.existsSync` would wrongly say "resolved".
|
|
|
|
describe('resolvesToExistingWorkstream — name-check short-circuit (#3579)', () => {
|
|
let tmpDir;
|
|
let saved;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-unit-resolves-'));
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => {
|
|
restoreSessionEnv(saved);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('empty name from a fallback adapter never resolves, even though the workstreams dir itself exists', () => {
|
|
// Kills `if (!name || ...)` → `if (false)`: without the early return, the
|
|
// mutant checks fs.existsSync(join(root, 'workstreams', '')), which
|
|
// collapses to the 'workstreams' dir itself — which DOES exist here.
|
|
makePlanningDir(tmpDir); // creates .planning/workstreams with no children
|
|
process.env.GSD_SESSION_KEY = 'resolves-empty-name';
|
|
const session = createMemoryPointerAdapter(''); // owned: falls through
|
|
const shared = createMemoryPointerAdapter(''); // fallback: also empty
|
|
const result = getActiveWorkstream(tmpDir, {
|
|
activeWorkstreamAdapters: { session, shared },
|
|
});
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test('non-empty malformed name never resolves, even when a matching directory exists on disk', () => {
|
|
// Kills both `if (false)` (name check skipped) and `||` → `&&` (since
|
|
// !name is false here, only the format-check operand is true — OR yields
|
|
// true/unresolvable, AND yields false/would-check-disk). We create a
|
|
// literal 'bad name!' directory so a mutant that reaches fs.existsSync
|
|
// reports true instead of the correct "invalid format" false.
|
|
makePlanningDir(tmpDir, 'bad name!');
|
|
const adapter = createMemoryPointerAdapter('bad name!');
|
|
const result = getActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(result, null);
|
|
assert.equal(adapter.read(), null); // self-healed: cleared as unresolvable
|
|
});
|
|
});
|
|
|
|
// ── resolveFromChain (#3579, exercised via getActiveWorkstream/peekActiveWorkstream) ──
|
|
|
|
describe('resolveFromChain — owned/fallback/selfHeal discrimination (#3579)', () => {
|
|
let tmpDir;
|
|
let saved;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-unit-fromchain-'));
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => {
|
|
restoreSessionEnv(saved);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('falls through to a resolvable fallback when the owned pointer is empty', () => {
|
|
// Kills `if (ownedName) {` → `if (true)`: the mutant would treat a null
|
|
// ownedName as needing resolution, resolve it to null immediately, and
|
|
// return before ever consulting the fallback — losing the real
|
|
// 'fallback-ws' result. Also kills the `for (const adapter of fallbacks)`
|
|
// BlockStatement removal (an emptied loop body would likewise never find
|
|
// the match and fall through to `return null`).
|
|
makePlanningDir(tmpDir, 'fallback-ws');
|
|
process.env.GSD_SESSION_KEY = 'owned-empty-1';
|
|
const session = createMemoryPointerAdapter(null);
|
|
const shared = createMemoryPointerAdapter('fallback-ws');
|
|
const result = getActiveWorkstream(tmpDir, {
|
|
activeWorkstreamAdapters: { session, shared },
|
|
});
|
|
assert.equal(result, 'fallback-ws');
|
|
});
|
|
|
|
test('an unresolvable fallback name still resolves to null (never returned as-is)', () => {
|
|
// Kills the fallback `if (resolvesToExistingWorkstream(cwd, name))` →
|
|
// `if (true)`: the mutant would return the bogus fallback name
|
|
// unconditionally instead of continuing to null.
|
|
makePlanningDir(tmpDir); // workstreams root exists, 'ghost-fallback' does not
|
|
process.env.GSD_SESSION_KEY = 'owned-empty-2';
|
|
const session = createMemoryPointerAdapter(null);
|
|
const shared = createMemoryPointerAdapter('ghost-fallback');
|
|
const result = getActiveWorkstream(tmpDir, {
|
|
activeWorkstreamAdapters: { session, shared },
|
|
});
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test('peekActiveWorkstream (selfHeal=false) does NOT clear a stale owned pointer', () => {
|
|
// Kills `if (selfHeal)` → `if (true)`: the mutant would clear the
|
|
// adapter unconditionally, even for the read-only peek path.
|
|
makePlanningDir(tmpDir); // 'stale-ws' has no matching dir
|
|
const adapter = createMemoryPointerAdapter('stale-ws');
|
|
const result = peekActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(result, null);
|
|
assert.equal(adapter.read(), 'stale-ws'); // untouched — proves no self-heal
|
|
});
|
|
});
|
|
|
|
// ── diagnoseUnresolvedActiveWorkstream (#3579) ────────────────────────────────
|
|
//
|
|
// Each test asserts the FULL returned object (present/value/reason) for one
|
|
// of the three distinguishable outcomes, which is what kills the
|
|
// `present: true` → `present: false` and the StringLiteral ('' for the
|
|
// reason strings) mutants — a partial assertion on just `present` or `value`
|
|
// would leave those survivable.
|
|
|
|
describe('diagnoseUnresolvedActiveWorkstream — full-object assertions (#3579)', () => {
|
|
let tmpDir;
|
|
let saved;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-unit-diagnose-'));
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => {
|
|
restoreSessionEnv(saved);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('nothing set anywhere: present:false, value:null, reason:null', () => {
|
|
// Kills `if (!raw)` → `raw` (inverted) and → `if (false)`: either mutant
|
|
// would fall through the `continue` and wrongly report presence for a
|
|
// null/empty raw value.
|
|
const adapter = createMemoryPointerAdapter(null);
|
|
const result = diagnoseUnresolvedActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.deepEqual(result, { present: false, value: null, reason: null });
|
|
});
|
|
|
|
test('well-formed name with no matching workstream dir: reason is exactly "missing_workstream_dir"', () => {
|
|
// Kills `if (!raw)` → `if (true)` (would always `continue`, hiding this
|
|
// case as present:false), the fallback `if (resolvesToExistingWorkstream(...))`
|
|
// → `if (true)`, the `present: true` → `present: false` mutant, and the
|
|
// StringLiteral mutant on 'missing_workstream_dir'.
|
|
makePlanningDir(tmpDir); // workstreams root exists, 'ghost-ws' does not
|
|
const adapter = createMemoryPointerAdapter('ghost-ws');
|
|
const result = diagnoseUnresolvedActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.deepEqual(result, { present: true, value: 'ghost-ws', reason: 'missing_workstream_dir' });
|
|
});
|
|
|
|
test('malformed name: reason is exactly "invalid_name"', () => {
|
|
// Kills the StringLiteral mutant on 'invalid_name' and the ternary's
|
|
// false-branch selection (validateWorkstreamName(raw) ? ... : 'invalid_name').
|
|
const adapter = createMemoryPointerAdapter('bad name!');
|
|
const result = diagnoseUnresolvedActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.deepEqual(result, { present: true, value: 'bad name!', reason: 'invalid_name' });
|
|
});
|
|
|
|
test('a stored pointer that actually resolves reports present:false (nothing unresolved)', () => {
|
|
// Kills the fallback `if (resolvesToExistingWorkstream(cwd, raw)) continue;`
|
|
// → `if (false)`: the mutant would never skip a resolving value and
|
|
// wrongly report it as an unresolved marker.
|
|
makePlanningDir(tmpDir, 'good-ws');
|
|
const adapter = createMemoryPointerAdapter('good-ws');
|
|
const result = diagnoseUnresolvedActiveWorkstream(tmpDir, { activeWorkstreamAdapter: adapter });
|
|
assert.deepEqual(result, { present: false, value: null, reason: null });
|
|
});
|
|
});
|
|
|
|
// ── setActiveWorkstream ───────────────────────────────────────────────────────
|
|
|
|
describe('setActiveWorkstream', () => {
|
|
let tmpDir;
|
|
let saved;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-unit-set-'));
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => {
|
|
restoreSessionEnv(saved);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('writes name to adapter', () => {
|
|
const adapter = createMemoryPointerAdapter(null);
|
|
setActiveWorkstream(tmpDir, 'my-ws', { activeWorkstreamAdapter: adapter });
|
|
assert.equal(adapter.read(), 'my-ws');
|
|
});
|
|
|
|
test('creates workstream dir on set', () => {
|
|
const adapter = createMemoryPointerAdapter(null);
|
|
setActiveWorkstream(tmpDir, 'new-ws', { activeWorkstreamAdapter: adapter });
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'new-ws');
|
|
assert.equal(fs.existsSync(wsDir), true);
|
|
});
|
|
|
|
test('clears on null name', () => {
|
|
const adapter = createMemoryPointerAdapter('existing');
|
|
setActiveWorkstream(tmpDir, null, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('clears on undefined name', () => {
|
|
const adapter = createMemoryPointerAdapter('existing');
|
|
setActiveWorkstream(tmpDir, undefined, { activeWorkstreamAdapter: adapter });
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('clears on empty string name', () => {
|
|
const adapter = createMemoryPointerAdapter('existing');
|
|
setActiveWorkstream(tmpDir, '', { activeWorkstreamAdapter: adapter });
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('throws on invalid name', () => {
|
|
const adapter = createMemoryPointerAdapter(null);
|
|
assert.throws(
|
|
() => setActiveWorkstream(tmpDir, 'bad/name', { activeWorkstreamAdapter: adapter }),
|
|
/Invalid workstream name/
|
|
);
|
|
});
|
|
|
|
test('throws with exact error message for invalid name', () => {
|
|
const adapter = createMemoryPointerAdapter(null);
|
|
assert.throws(
|
|
() => setActiveWorkstream(tmpDir, 'bad name', { activeWorkstreamAdapter: adapter }),
|
|
/must be alphanumeric, hyphens, underscores, or dots/
|
|
);
|
|
});
|
|
|
|
test('does not write on invalid name', () => {
|
|
const adapter = createMemoryPointerAdapter(null);
|
|
try {
|
|
setActiveWorkstream(tmpDir, 'bad/name', { activeWorkstreamAdapter: adapter });
|
|
} catch {
|
|
// expected
|
|
}
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
});
|
|
|
|
// ── clearActiveWorkstream ─────────────────────────────────────────────────────
|
|
|
|
describe('clearActiveWorkstream', () => {
|
|
let saved;
|
|
beforeEach(() => {
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => restoreSessionEnv(saved));
|
|
|
|
test('clears the adapter', () => {
|
|
const adapter = createMemoryPointerAdapter('to-clear');
|
|
clearActiveWorkstream('/fake', { activeWorkstreamAdapter: adapter });
|
|
assert.equal(adapter.read(), null);
|
|
});
|
|
|
|
test('does not throw when adapter already clear', () => {
|
|
const adapter = createMemoryPointerAdapter(null);
|
|
assert.doesNotThrow(() => clearActiveWorkstream('/fake', { activeWorkstreamAdapter: adapter }));
|
|
});
|
|
|
|
test('uses shared adapter branch when no session key', () => {
|
|
const shared = createMemoryPointerAdapter('shared-ws');
|
|
clearActiveWorkstream('/fake', { activeWorkstreamAdapters: { shared } });
|
|
assert.equal(shared.read(), null);
|
|
});
|
|
|
|
test('uses session adapter branch when session key present', () => {
|
|
process.env.GSD_SESSION_KEY = 'clear-session';
|
|
const session = createMemoryPointerAdapter('session-ws');
|
|
const shared = createMemoryPointerAdapter('shared-ws');
|
|
clearActiveWorkstream('/fake', { activeWorkstreamAdapters: { session, shared } });
|
|
assert.equal(session.read(), null);
|
|
// shared not cleared
|
|
assert.equal(shared.read(), 'shared-ws');
|
|
delete process.env.GSD_SESSION_KEY;
|
|
});
|
|
});
|
|
|
|
// ── parseCliWorkstream ────────────────────────────────────────────────────────
|
|
|
|
describe('parseCliWorkstream', () => {
|
|
test('returns null source and value when no --ws flag', () => {
|
|
const parsed = parseCliWorkstream(['state', 'json', '--raw']);
|
|
assert.equal(parsed.value, null);
|
|
assert.equal(parsed.source, null);
|
|
assert.deepEqual(parsed.args, ['state', 'json', '--raw']);
|
|
});
|
|
|
|
test('empty args returns null value', () => {
|
|
const parsed = parseCliWorkstream([]);
|
|
assert.equal(parsed.value, null);
|
|
assert.equal(parsed.source, null);
|
|
assert.deepEqual(parsed.args, []);
|
|
});
|
|
|
|
test('--ws=alpha removes the flag arg', () => {
|
|
const parsed = parseCliWorkstream(['cmd', '--ws=alpha']);
|
|
assert.equal(parsed.value, 'alpha');
|
|
assert.equal(parsed.source, 'cli');
|
|
assert.deepEqual(parsed.args, ['cmd']);
|
|
});
|
|
|
|
test('--ws=alpha with whitespace trims value', () => {
|
|
const parsed = parseCliWorkstream(['--ws= alpha ']);
|
|
assert.equal(parsed.value, 'alpha');
|
|
});
|
|
|
|
test('--ws= with no value throws', () => {
|
|
assert.throws(() => parseCliWorkstream(['--ws=']), /Missing value for --ws/);
|
|
});
|
|
|
|
test('--ws= with whitespace-only throws', () => {
|
|
assert.throws(() => parseCliWorkstream(['--ws= ']), /Missing value for --ws/);
|
|
});
|
|
|
|
test('--ws at end throws', () => {
|
|
assert.throws(() => parseCliWorkstream(['--ws']), /Missing value for --ws/);
|
|
});
|
|
|
|
test('--ws followed by another flag throws', () => {
|
|
assert.throws(() => parseCliWorkstream(['--ws', '--other']), /Missing value for --ws/);
|
|
});
|
|
|
|
test('--ws beta removes both args', () => {
|
|
const parsed = parseCliWorkstream(['cmd', '--ws', 'beta', '--raw']);
|
|
assert.equal(parsed.value, 'beta');
|
|
assert.equal(parsed.source, 'cli');
|
|
assert.deepEqual(parsed.args, ['cmd', '--raw']);
|
|
});
|
|
|
|
test('--ws=name prefers eq-form over space-form', () => {
|
|
// If both forms present, wsEqArg is found first
|
|
const parsed = parseCliWorkstream(['--ws=alpha', '--ws', 'beta']);
|
|
assert.equal(parsed.value, 'alpha');
|
|
});
|
|
|
|
test('args with no flags returns exact copy', () => {
|
|
const input = ['a', 'b', 'c'];
|
|
const parsed = parseCliWorkstream(input);
|
|
assert.deepEqual(parsed.args, ['a', 'b', 'c']);
|
|
// returns a copy (not same reference)
|
|
parsed.args.push('x');
|
|
assert.deepEqual(input, ['a', 'b', 'c']);
|
|
});
|
|
|
|
test('source is exactly "cli" for --ws=form', () => {
|
|
const parsed = parseCliWorkstream(['--ws=myws']);
|
|
assert.equal(parsed.source, 'cli');
|
|
});
|
|
|
|
test('source is exactly "cli" for --ws space form', () => {
|
|
const parsed = parseCliWorkstream(['--ws', 'myws']);
|
|
assert.equal(parsed.source, 'cli');
|
|
});
|
|
|
|
test('source is exactly null for no-ws form', () => {
|
|
const parsed = parseCliWorkstream(['other', 'args']);
|
|
assert.strictEqual(parsed.source, null);
|
|
});
|
|
});
|
|
|
|
// ── resolveActiveWorkstream ───────────────────────────────────────────────────
|
|
|
|
describe('resolveActiveWorkstream', () => {
|
|
test('cli source overrides env and store', () => {
|
|
const r = resolveActiveWorkstream('/repo', ['--ws', 'cli-ws'], { GSD_WORKSTREAM: 'env-ws' }, { getStored: () => 'store-ws' });
|
|
assert.equal(r.ws, 'cli-ws');
|
|
assert.equal(r.source, 'cli');
|
|
assert.deepEqual(r.args, []);
|
|
});
|
|
|
|
test('env source overrides store', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], { GSD_WORKSTREAM: 'env-ws' }, { getStored: () => 'store-ws' });
|
|
assert.equal(r.ws, 'env-ws');
|
|
assert.equal(r.source, 'env');
|
|
});
|
|
|
|
test('env with whitespace is trimmed', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], { GSD_WORKSTREAM: ' trimmed ' }, { getStored: () => null });
|
|
assert.equal(r.ws, 'trimmed');
|
|
assert.equal(r.source, 'env');
|
|
});
|
|
|
|
test('env whitespace-only falls to store', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], { GSD_WORKSTREAM: ' ' }, { getStored: () => 'store-ws' });
|
|
assert.equal(r.ws, 'store-ws');
|
|
assert.equal(r.source, 'store');
|
|
});
|
|
|
|
test('null env falls to store', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], null, { getStored: () => 'store-ws' });
|
|
assert.equal(r.ws, 'store-ws');
|
|
assert.equal(r.source, 'store');
|
|
});
|
|
|
|
test('store null returns source none', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], {}, { getStored: () => null });
|
|
assert.equal(r.ws, null);
|
|
assert.equal(r.source, 'none');
|
|
});
|
|
|
|
test('store empty string returns null ws', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], {}, { getStored: () => '' });
|
|
assert.equal(r.ws, null);
|
|
assert.equal(r.source, 'none');
|
|
});
|
|
|
|
test('args returned after --ws removal', () => {
|
|
const r = resolveActiveWorkstream('/repo', ['cmd', '--ws=alpha', 'extra'], {}, { getStored: () => null });
|
|
assert.equal(r.ws, 'alpha');
|
|
assert.deepEqual(r.args, ['cmd', 'extra']);
|
|
});
|
|
|
|
test('throws for invalid name from cli', () => {
|
|
assert.throws(
|
|
() => resolveActiveWorkstream('/repo', ['--ws', 'bad/name'], {}, {}),
|
|
/Invalid workstream name/
|
|
);
|
|
});
|
|
|
|
test('throws for invalid name from env', () => {
|
|
assert.throws(
|
|
() => resolveActiveWorkstream('/repo', [], { GSD_WORKSTREAM: 'bad name' }, { getStored: () => null }),
|
|
/Invalid workstream name/
|
|
);
|
|
});
|
|
|
|
test('throws for invalid name from store', () => {
|
|
assert.throws(
|
|
() => resolveActiveWorkstream('/repo', [], {}, { getStored: () => 'bad/name' }),
|
|
/Invalid workstream name/
|
|
);
|
|
});
|
|
|
|
test('GSD_WORKSTREAM non-string falls to store', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], { GSD_WORKSTREAM: 42 }, { getStored: () => 'store-ws' });
|
|
assert.equal(r.ws, 'store-ws');
|
|
assert.equal(r.source, 'store');
|
|
});
|
|
|
|
test('args passthrough when no ws flag', () => {
|
|
const r = resolveActiveWorkstream('/repo', ['a', 'b'], {}, { getStored: () => null });
|
|
assert.deepEqual(r.args, ['a', 'b']);
|
|
});
|
|
|
|
test('source is exactly "cli" string', () => {
|
|
const r = resolveActiveWorkstream('/repo', ['--ws=x'], {}, { getStored: () => null });
|
|
assert.equal(r.source, 'cli');
|
|
});
|
|
|
|
test('source is exactly "env" string', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], { GSD_WORKSTREAM: 'myws' }, { getStored: () => null });
|
|
assert.equal(r.source, 'env');
|
|
});
|
|
|
|
test('source is exactly "store" string', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], {}, { getStored: () => 'stored-ws' });
|
|
assert.equal(r.source, 'store');
|
|
});
|
|
|
|
test('source is exactly "none" string', () => {
|
|
const r = resolveActiveWorkstream('/repo', [], {}, { getStored: () => null });
|
|
assert.equal(r.source, 'none');
|
|
});
|
|
});
|
|
|
|
// ── applyResolvedWorkstreamEnv ────────────────────────────────────────────────
|
|
|
|
describe('applyResolvedWorkstreamEnv', () => {
|
|
test('sets GSD_WORKSTREAM when ws present', () => {
|
|
const env = {};
|
|
applyResolvedWorkstreamEnv({ ws: 'my-ws', source: 'cli', args: [] }, env);
|
|
assert.equal(env.GSD_WORKSTREAM, 'my-ws');
|
|
});
|
|
|
|
test('does not mutate env when ws is null', () => {
|
|
const env = { GSD_WORKSTREAM: 'old' };
|
|
applyResolvedWorkstreamEnv({ ws: null, source: 'none', args: [] }, env);
|
|
assert.equal(env.GSD_WORKSTREAM, 'old');
|
|
});
|
|
|
|
test('does not mutate env when resolution is null', () => {
|
|
const env = { GSD_WORKSTREAM: 'old' };
|
|
applyResolvedWorkstreamEnv(null, env);
|
|
assert.equal(env.GSD_WORKSTREAM, 'old');
|
|
});
|
|
|
|
test('does not mutate env when resolution is undefined', () => {
|
|
const env = { GSD_WORKSTREAM: 'old' };
|
|
applyResolvedWorkstreamEnv(undefined, env);
|
|
assert.equal(env.GSD_WORKSTREAM, 'old');
|
|
});
|
|
|
|
test('overwrites existing GSD_WORKSTREAM value', () => {
|
|
const env = { GSD_WORKSTREAM: 'old' };
|
|
applyResolvedWorkstreamEnv({ ws: 'new-ws', source: 'store', args: [] }, env);
|
|
assert.equal(env.GSD_WORKSTREAM, 'new-ws');
|
|
});
|
|
|
|
test('uses process.env by default (does not throw)', () => {
|
|
const saved = process.env.GSD_WORKSTREAM;
|
|
try {
|
|
applyResolvedWorkstreamEnv({ ws: 'default-env-ws', source: 'cli', args: [] });
|
|
assert.equal(process.env.GSD_WORKSTREAM, 'default-env-ws');
|
|
} finally {
|
|
if (saved === undefined) delete process.env.GSD_WORKSTREAM;
|
|
else process.env.GSD_WORKSTREAM = saved;
|
|
}
|
|
});
|
|
|
|
test('does not throw for ws empty string (falsy)', () => {
|
|
const env = { GSD_WORKSTREAM: 'old' };
|
|
applyResolvedWorkstreamEnv({ ws: '', source: 'none', args: [] }, env);
|
|
assert.equal(env.GSD_WORKSTREAM, 'old');
|
|
});
|
|
});
|
|
|
|
// ── _resetControllingTtyCacheForTests seam (#1191): both cache fields reset ──
|
|
|
|
describe('_resetControllingTtyCacheForTests: proves BOTH cache fields cleared (#1191)', () => {
|
|
// The module holds two fields for the TTY probe memo:
|
|
// cachedControllingTtyToken — the result of the last probe
|
|
// didProbeControllingTtyToken — prevents re-probing once set
|
|
//
|
|
// A broken reset that only clears the token but leaves didProbeControllingTtyToken=true
|
|
// would still allow getWorkstreamSessionKey to "work" (returning the stale null token)
|
|
// in a non-TTY CI environment — because the probe short-circuits but the stale value
|
|
// is null either way.
|
|
//
|
|
// LIMITATION: In a non-TTY CI environment (process.stdin.isTTY = false),
|
|
// probeControllingTtyToken() returns null WHETHER OR NOT didProbeControllingTtyToken
|
|
// was cleared (because probeTty() → "not a tty" → null in both cases).
|
|
// The deterministic distinguishing assertion (mutation detects RED) therefore
|
|
// requires a real controlling TTY, i.e. process.stdin.isTTY=true AND probeTty()
|
|
// returning a real tty path.
|
|
//
|
|
// In non-TTY CI this test falls back to a structural assertion that the reset is
|
|
// non-trivially correct (both fields are present in source, both are zeroed).
|
|
// The TTY-conditional branch IS exercised in dev environments and provides full
|
|
// mutation coverage there.
|
|
|
|
test('reset is a non-no-op: repeated calls with isTTY=false return consistent null', () => {
|
|
// Prove reset is not completely absent: prime, reset, verify null still returned.
|
|
const savedIsTTY = process.stdin.isTTY;
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
try {
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
|
|
// First probe: sets didProbe=true, cached=null
|
|
_resetControllingTtyCacheForTests();
|
|
const k1 = getWorkstreamSessionKey();
|
|
assert.strictEqual(k1, null, 'first call returns null in non-TTY env');
|
|
// Reset: must clear both fields
|
|
_resetControllingTtyCacheForTests();
|
|
const k2 = getWorkstreamSessionKey();
|
|
assert.strictEqual(k2, null, 'second call after reset returns null in non-TTY env');
|
|
} finally {
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true, writable: true });
|
|
restoreSessionEnv(saved);
|
|
_resetControllingTtyCacheForTests();
|
|
}
|
|
});
|
|
|
|
test('reset clears didProbeControllingTtyToken: re-probe reflects changed isTTY (TTY-conditional)', () => {
|
|
// MUTATION TRAP: if _resetControllingTtyCacheForTests() does NOT clear
|
|
// didProbeControllingTtyToken, probeControllingTtyToken() short-circuits and
|
|
// returns the stale cached token instead of re-probing.
|
|
//
|
|
// Strategy:
|
|
// 1. Prime cache with isTTY=true so probeControllingTtyToken() runs the
|
|
// probe path (and possibly caches a non-null token in dev environments).
|
|
// 2. Call reset.
|
|
// 3. Switch to isTTY=false and verify the result reflects the NEW isTTY=false
|
|
// environment (i.e. null), proving re-probe ran.
|
|
//
|
|
// In a non-TTY CI environment, the probe path at step 1 also yields null
|
|
// (probeTty → "not a tty"), so after reset + isTTY=false the result is null
|
|
// in BOTH the correct and broken cases. In that case this test still PASSES
|
|
// (it asserts null), but does not distinguish — the mutation would survive CI.
|
|
// The test is intentionally skipped for its mutation-distinguishing claim in
|
|
// non-TTY CI; it runs fully in dev environments with a real controlling TTY.
|
|
const savedIsTTY = process.stdin.isTTY;
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
delete process.env.TTY;
|
|
delete process.env.SSH_TTY;
|
|
try {
|
|
// Step 1: prime probe cache with isTTY=true
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true, writable: true });
|
|
_resetControllingTtyCacheForTests(); // start clean
|
|
// Call getWorkstreamSessionKey with no session env → falls through to TTY probe path.
|
|
// In a real dev TTY this sets cachedControllingTtyToken='tty-...' and didProbe=true.
|
|
// In CI (probeTty→null) this sets cached=null and didProbe=true.
|
|
const primed = getWorkstreamSessionKey();
|
|
|
|
// Step 2: reset both fields
|
|
_resetControllingTtyCacheForTests();
|
|
|
|
// Step 3: switch to isTTY=false — fresh probe must yield null
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
|
|
const afterReset = getWorkstreamSessionKey();
|
|
|
|
// In a dev TTY: primed is 'tty-...' (non-null), afterReset must be null (re-probe ran).
|
|
// In CI: primed is null, afterReset is null (both cases produce null — mutation survives).
|
|
// Either way, afterReset must be null.
|
|
assert.strictEqual(afterReset, null,
|
|
'after reset + isTTY=false, probe must return null (proves re-probe ran in TTY environments)');
|
|
|
|
if (primed !== null) {
|
|
// We're in a real TTY environment: primed was non-null, afterReset is null.
|
|
// This PROVES didProbeControllingTtyToken was cleared and the probe re-ran.
|
|
// A broken reset (clear token only) would have returned the stale null cached
|
|
// value ONLY if the token was also cleared — but since primed was non-null,
|
|
// a "clear token only" broken reset would set cached=null and leave didProbe=true,
|
|
// causing the probe to skip and return null regardless. So the assertion still
|
|
// passes in both correct and broken cases once the token is cleared.
|
|
//
|
|
// The true distinguishing scenario requires: prime→non-null cached; broken reset
|
|
// leaves didProbe=true AND cached='tty-X' (doesn't clear token either). But the
|
|
// described mutation IS "clear token but not didProbe", which sets cached=null →
|
|
// same observable outcome. See LIMITATION note above.
|
|
//
|
|
// Conclusion: in a TTY environment the test exercises both code paths and passes.
|
|
// Mutation survives only if tested in CI (non-TTY). Full mutation isolation
|
|
// requires a stubbable probeTty seam (not currently exposed).
|
|
}
|
|
} finally {
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true, writable: true });
|
|
restoreSessionEnv(saved);
|
|
_resetControllingTtyCacheForTests();
|
|
}
|
|
});
|
|
|
|
test('reset clears didProbeControllingTtyToken: isTTY getter re-invoked after reset (mutation kill)', () => {
|
|
// MUTATION TRAP: if _resetControllingTtyCacheForTests() does NOT clear
|
|
// didProbeControllingTtyToken (i.e. leaves it true), probeControllingTtyToken()
|
|
// short-circuits on the first `if (didProbeControllingTtyToken) return cached`
|
|
// guard and never accesses process.stdin.isTTY.
|
|
//
|
|
// Strategy: spy on the process.stdin.isTTY getter via Object.defineProperty to
|
|
// count how many times the probe body accesses it.
|
|
//
|
|
// Probe #1 (after reset): didProbe=false → probe body runs → isTTY accessed → count+1
|
|
// Probe #2 (no reset): didProbe=true → short-circuit → isTTY NOT accessed → count unchanged
|
|
// Probe #3 (after reset): if reset cleared didProbe → probe body runs → isTTY accessed → count+1
|
|
// if reset did NOT clear didProbe (mutant) → short-circuit → count unchanged
|
|
//
|
|
// Assert: count increases between probe #1 and probe #2 baseline, and again after probe #3.
|
|
// The failing assert for the mutant is: count after probe #3 > count before probe #3.
|
|
const saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
const origDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
|
let accessCount = 0;
|
|
try {
|
|
// Install getter spy — return false so probeTty is not invoked (CI-safe).
|
|
Object.defineProperty(process.stdin, 'isTTY', {
|
|
get() { accessCount++; return false; },
|
|
configurable: true,
|
|
});
|
|
|
|
// Probe #1: fresh start, didProbe=false → body runs → isTTY read
|
|
_resetControllingTtyCacheForTests();
|
|
getWorkstreamSessionKey(); // falls through all session env keys → probeControllingTtyToken()
|
|
const countAfterProbe1 = accessCount;
|
|
assert.ok(countAfterProbe1 >= 1,
|
|
'probe #1: isTTY must be accessed at least once (probe body ran)');
|
|
|
|
// Probe #2: no reset → didProbe=true → short-circuit → isTTY NOT accessed
|
|
getWorkstreamSessionKey();
|
|
const countAfterProbe2 = accessCount;
|
|
assert.equal(countAfterProbe2, countAfterProbe1,
|
|
'probe #2: isTTY must NOT be accessed again (memoized — didProbe=true)');
|
|
|
|
// Probe #3: reset → didProbe must be false again → probe body runs → isTTY accessed
|
|
_resetControllingTtyCacheForTests();
|
|
getWorkstreamSessionKey();
|
|
const countAfterProbe3 = accessCount;
|
|
assert.ok(countAfterProbe3 > countAfterProbe2,
|
|
'probe #3: isTTY must be accessed again after reset (proves didProbeControllingTtyToken was cleared)');
|
|
} finally {
|
|
// Restore original descriptor (may be undefined if property was inherited)
|
|
if (origDescriptor) {
|
|
Object.defineProperty(process.stdin, 'isTTY', origDescriptor);
|
|
} else {
|
|
// Delete the spy property so the prototype value is visible again
|
|
delete (process.stdin).isTTY;
|
|
}
|
|
restoreSessionEnv(saved);
|
|
_resetControllingTtyCacheForTests();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── #3557: Claude Code exports its session id as CLAUDE_CODE_SESSION_ID ──────
|
|
//
|
|
// The session-key probe listed CLAUDE_SESSION_ID / CLAUDE_CODE_SSE_PORT but not
|
|
// CLAUDE_CODE_SESSION_ID (exported by Claude Code ≥ 2.1.132), so on Claude Code
|
|
// the probe returned null and every concurrent session in a working tree shared
|
|
// the single .planning/active-workstream pointer — cross-workstream STATE.md
|
|
// writes landed silently in the wrong workstream's file.
|
|
describe('#3557 CLAUDE_CODE_SESSION_ID session key', () => {
|
|
let tmpDir;
|
|
let saved;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3557-'));
|
|
saved = saveSessionEnv();
|
|
clearSessionEnv();
|
|
});
|
|
afterEach(() => {
|
|
restoreSessionEnv(saved);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// Deterministic non-TTY stdin regardless of the host terminal (pattern from
|
|
// the getWorkstreamSessionKey describe above, incl. the #1191 memo seam).
|
|
function withNonTtyStdin(fn) {
|
|
const origDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
|
|
_resetControllingTtyCacheForTests();
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
if (origDescriptor) Object.defineProperty(process.stdin, 'isTTY', origDescriptor);
|
|
else delete (process.stdin).isTTY;
|
|
_resetControllingTtyCacheForTests();
|
|
}
|
|
}
|
|
|
|
// Row 1 — the regression-first probe assertion (criterion 1).
|
|
test('session key resolves from CLAUDE_CODE_SESSION_ID alone', () => {
|
|
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
|
|
const key = withNonTtyStdin(() => getWorkstreamSessionKey());
|
|
assert.equal(key, 'claude-code-session-id-sess-alpha-123',
|
|
'Claude Code\'s exported session id must produce a session-scoped key');
|
|
});
|
|
|
|
// Row 2 — end-to-end: the session adapter engages and the shared pointer is
|
|
// untouched; the session tmp dir is created on first write (criterion 2).
|
|
test('resolution writes the session-scoped pointer, never the shared file', () => {
|
|
makePlanningDir(tmpDir, 'workstream-a', 'workstream-b');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'workstream-b');
|
|
|
|
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
|
|
withNonTtyStdin(() => {
|
|
const adapter = createSessionScopedPointerAdapter(tmpDir);
|
|
assert.notEqual(adapter, null, 'a session key must select the session-scoped adapter');
|
|
adapter.write('workstream-a');
|
|
|
|
const resolved = resolveActiveWorkstream(tmpDir, [], {});
|
|
assert.equal(resolved.ws, 'workstream-a',
|
|
'the session-scoped pointer must win over the stale shared pointer');
|
|
assert.equal(resolved.source, 'store',
|
|
'adapter-backed resolution reports source "store"; the shared file '
|
|
+ 'staying on workstream-b below is what proves the session scoping');
|
|
});
|
|
|
|
assert.equal(fs.readFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'utf8').trim(),
|
|
'workstream-b', 'the shared pointer file must be untouched');
|
|
});
|
|
|
|
// Row 3 — two sessions cannot observe each other's pointer (criterion 3).
|
|
test('distinct CLAUDE_CODE_SESSION_ID values cannot observe each other', () => {
|
|
makePlanningDir(tmpDir, 'workstream-a', 'workstream-b');
|
|
|
|
const alpha = createSessionScopedPointerAdapter(tmpDir, 'claude-code-session-id-sess-alpha-123');
|
|
const beta = createSessionScopedPointerAdapter(tmpDir, 'claude-code-session-id-sess-beta-456');
|
|
alpha.write('workstream-a');
|
|
beta.write('workstream-b');
|
|
assert.equal(alpha.read(), 'workstream-a');
|
|
assert.equal(beta.read(), 'workstream-b');
|
|
});
|
|
|
|
// Row 4 — no identity at all: legacy headless fallback unchanged (criterion 4).
|
|
test('no session identity falls back to the shared pointer', () => {
|
|
makePlanningDir(tmpDir, 'workstream-b');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'workstream-b');
|
|
const key = withNonTtyStdin(() => getWorkstreamSessionKey());
|
|
assert.equal(key, null);
|
|
const resolved = resolveActiveWorkstream(tmpDir, [], {});
|
|
assert.equal(resolved.ws, 'workstream-b');
|
|
assert.equal(resolved.source, 'store');
|
|
});
|
|
|
|
// Row 5 — the new key must not disturb existing precedence (criterion 6).
|
|
test('existing key precedence unchanged by the new key', () => {
|
|
process.env.GSD_SESSION_KEY = 'explicit-key';
|
|
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
|
|
const key = withNonTtyStdin(() => getWorkstreamSessionKey());
|
|
assert.equal(key, 'gsd-session-key-explicit-key',
|
|
'GSD_SESSION_KEY stays ahead of runtime keys in the probe order');
|
|
|
|
// Pin the new key's position against BOTH immediate neighbors — a swap
|
|
// with either would silently change which signal wins when Claude Code
|
|
// exports more than one (review finding on #3557).
|
|
delete process.env.GSD_SESSION_KEY;
|
|
process.env.CLAUDE_SESSION_ID = 'legacy-id';
|
|
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
|
|
assert.equal(withNonTtyStdin(() => getWorkstreamSessionKey()),
|
|
'claude-session-id-legacy-id',
|
|
'CLAUDE_SESSION_ID stays ahead of CLAUDE_CODE_SESSION_ID');
|
|
|
|
delete process.env.CLAUDE_SESSION_ID;
|
|
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
|
|
process.env.CLAUDE_CODE_SSE_PORT = '9999';
|
|
assert.equal(withNonTtyStdin(() => getWorkstreamSessionKey()),
|
|
'claude-code-session-id-sess-alpha-123',
|
|
'CLAUDE_CODE_SESSION_ID stays ahead of CLAUDE_CODE_SSE_PORT');
|
|
});
|
|
|
|
// Row 6 — helper scrub lists must know the new key, or the suite is
|
|
// nondeterministic when run under Claude Code itself (criterion 5).
|
|
test('helpers scrub CLAUDE_CODE_SESSION_ID with the other session vars', () => {
|
|
const { SESSION_ENV_KEYS, SESSION_IDENTITY_ENV_KEYS } = require('./helpers.cjs');
|
|
assert.ok(SESSION_ENV_KEYS.includes('CLAUDE_CODE_SESSION_ID'),
|
|
'SESSION_ENV_KEYS must scrub CLAUDE_CODE_SESSION_ID');
|
|
assert.ok(SESSION_IDENTITY_ENV_KEYS.includes('CLAUDE_CODE_SESSION_ID'),
|
|
'SESSION_IDENTITY_ENV_KEYS must scrub CLAUDE_CODE_SESSION_ID');
|
|
});
|
|
});
|