extractCurrentMilestone reads STATE.md via planningDir(cwd), which is workstream-aware (honours GSD_PROJECT/GSD_WORKSTREAM). The fixtures write STATE.md to the plain <tmp>/.planning/STATE.md, so a developer shell inside a GSD workstream (GSD_WORKSTREAM exported) redirected the read to a non-existent workstream subdir -> version=null -> closed milestone sections leaked into the slice and assertions failed. Clean CI/Docker env never hit it. Not a Node-26 regex bug; reproduces identically on any Node with GSD_WORKSTREAM set. - scripts/run-tests.cjs: strip GSD_PROJECT/GSD_WORKSTREAM before spawning test children so the local runner env matches clean CI/Docker. - tests/roadmap-phase-fallback.test.cjs: file-level beforeEach/afterEach save/delete/restore of both vars; new regression test pinning workstream-aware STATE.md resolution. - tests/run-tests-harness.test.cjs: guard asserting the runner strips both vars (so removing the deletion fails clean CI). Closes #872 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -236,6 +236,14 @@ function main() {
|
||||
// Build the gitignored bin/lib artifact if absent, before any test requires it.
|
||||
ensureBuiltArtifacts();
|
||||
|
||||
// Hermeticity: in-process tests resolve `.planning` via planningDir(cwd), which
|
||||
// honours GSD_PROJECT/GSD_WORKSTREAM. A developer shell inside a GSD workstream
|
||||
// exports GSD_WORKSTREAM, which would redirect fixture STATE.md reads away from
|
||||
// each <tmp>/.planning and silently diverge from the clean CI/Docker env. Strip
|
||||
// them so the local runner matches CI; tests that need them set them explicitly.
|
||||
delete process.env.GSD_PROJECT;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
|
||||
// Log selected files to stderr for CI / harness-test visibility.
|
||||
// node:test default reporter doesn't echo filenames, so this gives
|
||||
// operators a single stable line they can grep.
|
||||
|
||||
@@ -11,6 +11,26 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// The planning-dir resolver (planningDir) is workstream-aware and honours
|
||||
// GSD_PROJECT / GSD_WORKSTREAM. These suites write STATE.md to <tmp>/.planning
|
||||
// and assume that is where it is read from, so a developer shell inside a GSD
|
||||
// workstream would otherwise redirect the read and break extractCurrentMilestone.
|
||||
// Isolate the vars so the file is hermetic when run directly via `node --test`.
|
||||
let savedGsdProject;
|
||||
let savedGsdWorkstream;
|
||||
beforeEach(() => {
|
||||
savedGsdProject = process.env.GSD_PROJECT;
|
||||
savedGsdWorkstream = process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (savedGsdProject !== undefined) process.env.GSD_PROJECT = savedGsdProject;
|
||||
else delete process.env.GSD_PROJECT;
|
||||
if (savedGsdWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedGsdWorkstream;
|
||||
else delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: write STATE.md with a milestone version so extractCurrentMilestone
|
||||
* will slice the roadmap to only that milestone's section.
|
||||
@@ -365,6 +385,51 @@ This is the active milestone body.
|
||||
);
|
||||
});
|
||||
|
||||
test('(7) workstream-aware: STATE.md under GSD_WORKSTREAM is read from the workstream subdir', () => {
|
||||
// Regression guard for the env-leak that made these suites pass in clean CI but
|
||||
// fail in a developer's GSD_WORKSTREAM shell. planningDir() is workstream-aware,
|
||||
// so STATE.md lives at <cwd>/.planning/workstreams/<ws>/STATE.md. Setting the env
|
||||
// here makes clean CI exercise the polluted-env resolution path.
|
||||
process.env.GSD_WORKSTREAM = 'guard-ws';
|
||||
try {
|
||||
const wsPlanning = path.join(tmpDir, '.planning', 'workstreams', 'guard-ws');
|
||||
fs.mkdirSync(wsPlanning, { recursive: true });
|
||||
fs.writeFileSync(path.join(wsPlanning, 'STATE.md'), '---\nmilestone: v8.0\n---\n');
|
||||
const roadmap = `# Project Roadmap
|
||||
|
||||
## v8.0 Overview — v8.0-F (CLOSED FAIL 2026-05-18)
|
||||
|
||||
This is the closed milestone body with some text.
|
||||
|
||||
### Phase 24: ARCHIVED
|
||||
**Goal:** This phase is done and archived.
|
||||
|
||||
## v8.0-B Overview (STARTED 2026-05-18)
|
||||
|
||||
This is the active milestone body.
|
||||
|
||||
### Phase 31: EVAL
|
||||
**Goal:** Evaluate the new system.
|
||||
|
||||
## v9.0 Future Milestone
|
||||
|
||||
### Phase 40: FUTURE
|
||||
**Goal:** Future work.
|
||||
`;
|
||||
const slice = core.extractCurrentMilestone(roadmap, tmpDir);
|
||||
assert.ok(
|
||||
slice.includes('Phase 31: EVAL'),
|
||||
'workstream-scoped STATE.md must select the active v8.0-B section',
|
||||
);
|
||||
assert.ok(
|
||||
!slice.includes('Phase 24: ARCHIVED'),
|
||||
'closed section must still be excluded under a workstream env',
|
||||
);
|
||||
} finally {
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
}
|
||||
});
|
||||
|
||||
test('(2) double-closed-skip: third sibling (active) selected when first two are closed', () => {
|
||||
writeState(tmpDir, 'v9.0');
|
||||
const roadmap = `# Project Roadmap
|
||||
|
||||
@@ -260,6 +260,32 @@ test('boom', () => { throw new Error('intentional'); });
|
||||
});
|
||||
});
|
||||
|
||||
describe('env hermeticity', () => {
|
||||
// Regression guard for the two `delete process.env.GSD_PROJECT/GSD_WORKSTREAM`
|
||||
// lines added in scripts/run-tests.cjs main() right after ensureBuiltArtifacts().
|
||||
// If those deletions are removed, the fixture's assertions fail inside the child
|
||||
// node:test process → non-zero harness exit → this test fails → CI catches it.
|
||||
test('harness strips GSD_PROJECT and GSD_WORKSTREAM before running child tests', () => {
|
||||
// Write a fixture that asserts both vars are absent in the child process env.
|
||||
const FIXTURE = `'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
test('ambient GSD workstream vars are stripped by the runner', () => {
|
||||
assert.strictEqual(process.env.GSD_PROJECT, undefined);
|
||||
assert.strictEqual(process.env.GSD_WORKSTREAM, undefined);
|
||||
});
|
||||
`;
|
||||
fs.writeFileSync(path.join(tmpDir, 'env-hermeticity.test.cjs'), FIXTURE, 'utf8');
|
||||
// Pass both vars in the ambient env given to the harness process.
|
||||
// The harness must delete them before spawning the child node:test process.
|
||||
const r = runHarness(tmpDir, [], {
|
||||
GSD_PROJECT: 'ambient-proj',
|
||||
GSD_WORKSTREAM: 'ambient-ws',
|
||||
});
|
||||
assert.strictEqual(r.status, 0, r.stderr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows argv-overflow chunking (issue #3597)', () => {
|
||||
// Windows CreateProcess caps lpCommandLine at 32,767 chars. With ~550
|
||||
// tests the unchunked spawn fails instantly on Windows with no test
|
||||
|
||||
Reference in New Issue
Block a user