* 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:
@@ -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'),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──────────────────
|
||||
|
||||
Reference in New Issue
Block a user