fix(#4485): isolate hook E2E tests from ambient capabilities (#4529)

* test(#4485): isolate hook E2E capability homes

* test(#4485): isolate the remaining plan hook E2E

* test(#4485): make ambient-home assertions real

* test(#4485): remove stale helper imports

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Michel Moreira
2026-09-15 00:44:18 -03:00
committed by GitHub
parent e2bfc06558
commit e5c4941f2a
4 changed files with 83 additions and 11 deletions

View File

@@ -23,7 +23,7 @@ const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { runGsdTools, createTempProject, cleanup, installSpawnEnv, withAmbientCapabilityHome } = require('./helpers.cjs');
const { LOOP_HOOK_POINT_CLI_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
@@ -77,7 +77,7 @@ function spawnRenderHooks(point, cwd) {
cwd,
encoding: 'utf8',
timeout: LOOP_HOOK_POINT_CLI_TIMEOUT_MS,
env: { ...process.env, GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '' },
env: installSpawnEnv({ GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '' }),
});
return {
status: result.status,
@@ -137,6 +137,15 @@ describe('render-hooks plan:post — gate discovery', () => {
assert.ok(envelope.rendered.includes('gap-analysis.plan-post'), 'rendered must include check query');
});
test('#4485: render-hooks ignores capabilities installed in ambient user locations', (t) => {
withAmbientCapabilityHome(t, 'gsd-ambient-plan-post-', 'ambient-plan-post', 'plan:post');
const result = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero: ${result.stderr}`);
const activeHooks = JSON.parse(result.stdout).activeHooks;
assert.deepStrictEqual(activeHooks.map((hook) => [hook.capId, hook.check?.query]), [['gap-analysis', 'gap-analysis.plan-post']]);
});
test('[negative] render-hooks plan:post returns empty activeHooks when workflow.post_planning_gaps=false (gate deactivated)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),

View File

@@ -29,7 +29,7 @@ const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { cleanup, installSpawnEnv, withAmbientCapabilityHome } = require('./helpers.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const { LOOP_HOOK_POINT_CLI_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
@@ -61,14 +61,13 @@ function gitAddCommit(dir, message) {
* When raw=true the tool emits JSON; parsed is set on success.
*/
function runTool(args, { cwd, env = {} } = {}) {
const childEnv = {
...process.env,
const childEnv = installSpawnEnv({
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
...env,
};
});
const r = spawnSync(process.execPath, [GSD_TOOLS, ...args], {
cwd: cwd || os.tmpdir(),
encoding: 'utf8',
@@ -97,6 +96,18 @@ after(() => { for (const d of tmpDirs) { try { cleanup(d); } catch { /* best-eff
describe('A. loop render-hooks execute:wave:post — resolution', () => {
test('#4485: render-hooks ignores capabilities installed in ambient user locations', (t) => {
withAmbientCapabilityHome(t, 'gsd-ambient-wave-post-', 'ambient-wave-post', 'execute:wave:post');
const dir = makeTmpDir();
const result = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(
result.parsed.activeHooks.map((hook) => [hook.capId, hook.check?.query]),
[['drift', 'verify.schema-drift'], ['drift', 'verify.codebase-drift'], ['ui', 'ui.safety-gate']],
);
});
test('[happy] full resolution: all 3 gates present with default config', () => {
const dir = makeTmpDir();
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });

View File

@@ -1251,7 +1251,50 @@ function writePackageSourceMarkerFixture(configDir) {
return configDir;
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, writePackageSourceMarkerFixture, TEST_HOME_SANDBOX_MARKER, mockPartialWriteThenThrow, captureFdSync, suppressFdAsync };
/** Write one valid third-party gate into a synthetic user capability home. */
function writeAmbientCapabilityGate(home, id, point) {
const capDir = path.join(home, '.gsd', 'capabilities', id);
fs.mkdirSync(capDir, { recursive: true });
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify({
id,
title: 'Ambient test capability',
version: '1.0.0',
role: 'feature',
tier: 'full',
description: 'Capability outside the test fixture that must remain invisible.',
engines: { gsd: '>=1.7.0' },
requires: [],
runtimeCompat: { supported: ['claude'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [],
gates: [{ point, check: { query: 'ambient.check' }, blocking: false, onError: 'skip' }],
}), 'utf8');
}
/**
* Put a capability in the parent process's ambient home for one serial test.
* The child must still receive installSpawnEnv()'s different sandbox home.
*/
function withAmbientCapabilityHome(t, prefix, id, point) {
const home = createTempDir(prefix);
writeAmbientCapabilityGate(home, id, point);
const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
process.env.HOME = home;
process.env.USERPROFILE = home;
t.after(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
cleanup(home);
});
return home;
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, writePackageSourceMarkerFixture, writeAmbientCapabilityGate, withAmbientCapabilityHome, TEST_HOME_SANDBOX_MARKER, mockPartialWriteThenThrow, captureFdSync, suppressFdAsync };
// Lazy, for the reason builtLib() is lazy: reading either of these is what
// forces the built-lib require, so a test file that needs neither can still

View File

@@ -29,7 +29,7 @@ const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { cleanup, installSpawnEnv, withAmbientCapabilityHome } = require('./helpers.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
@@ -66,7 +66,7 @@ const CLEAN_ENV = {
/**
* Run gsd-tools via spawnSync. Returns { status, stdout, stderr }.
* Passes env overrides merged on top of process.env + CLEAN_ENV.
* Passes env overrides through the canonical sandboxed child environment.
*/
function runTools(args, cwd, envOverrides = {}) {
return spawnSync(
@@ -76,7 +76,7 @@ function runTools(args, cwd, envOverrides = {}) {
cwd: cwd || process.cwd(),
encoding: 'utf8',
timeout: PLAN_PRE_HOOK_CLI_TIMEOUT_MS,
env: { ...process.env, ...CLEAN_ENV, ...envOverrides },
env: installSpawnEnv({ ...CLEAN_ENV, ...envOverrides }),
},
);
}
@@ -278,11 +278,20 @@ describe('plan:pre all-off — empty resolution', () => {
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const envelope = parseEnvelope(result, 'all-off');
assert.deepEqual(envelope.activeHooks, [],
assert.deepStrictEqual(envelope.activeHooks, [],
`activeHooks must be empty when all flags false. Got: ${JSON.stringify(envelope.activeHooks.map(h=>h.capId))}`);
assert.strictEqual(envelope.rendered, '_No active hooks at plan:pre._',
'rendered must be placeholder when no active hooks');
});
test('#4485: an ambient plan:pre capability cannot perturb the empty resolution', (t) => {
withAmbientCapabilityHome(t, 'gsd-ambient-plan-pre-', 'ambient-plan-pre', 'plan:pre');
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const envelope = parseEnvelope(result, 'all-off-ambient');
assert.deepStrictEqual(envelope.activeHooks, []);
});
});
// ─── 6. check ui.plan-gate: frontend + no-spec → block:true ──────────────────