Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/readme-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1206,6 +1206,13 @@ node --experimental-strip-types --check extensions/startup-banner.ts
npm pack --dry-run
```

`pnpm test` isolates the `unit-tests` stage from inherited `GIT_*` and
`GENTLE_PI_AGENTS_*` variables and supplies an empty temporary
`GENTLE_PI_CONFIG_HOME`, removed when the stage exits. Other environment
variables and the provider-contract/runtime-harness stages remain unchanged.
Direct `node --test` invocations bypass this isolation. Product Git-environment
safety checks are unchanged.

### Cross-lane checks

`tests/crosslane/cross-lane.mjs` (run with `pnpm test:cross-lane`) is a single fixture parity check, not a live battery. It imports `decodeReviewLastEventClosureV1` from the pinned decoder lane, decodes the vendored fixture `tests/fixtures/devbinary/last-event-capture-result-approved.captured.json`, asserts the approved `review/capture-result` closure shape (operation, state, and the `sha256:` store revision), and exits. It needs no `gentle-ai` binary and runs offline; the pinned decoder lane only ever sees vendored fixtures.
Expand Down
30 changes: 22 additions & 8 deletions scripts/run-test-suite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
// POSIX and Windows CI.

import { spawn } from "node:child_process";
import { readFileSync, realpathSync } from "node:fs";
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

export const DEFAULT_STAGES = Object.freeze([
Expand All @@ -20,14 +22,26 @@ export const DEFAULT_STAGES = Object.freeze([
]);

export async function runStage(stage) {
return await new Promise((resolve) => {
const child = spawn(stage.command, { shell: true, stdio: "inherit" });
child.on("close", (code) => resolve({ name: stage.name, code: code ?? 1 }));
child.on("error", (error) => {
console.error(`\n[${stage.name}] spawn failed: ${error.message}`);
resolve({ name: stage.name, code: 1 });
// Unit fixtures must not inherit the terminal's Git overrides, child-session
// identity, or personal guardrails. Other stages retain their existing env.
const configHome = stage.name === "unit-tests" ? mkdtempSync(join(tmpdir(), "gentle-pi-unit-config-")) : undefined;
const env = configHome ? Object.fromEntries(Object.entries(process.env).filter(([key]) => {
const normalizedKey = key.toUpperCase();
return !normalizedKey.startsWith("GIT_") && !normalizedKey.startsWith("GENTLE_PI_AGENTS_") && normalizedKey !== "GENTLE_PI_CONFIG_HOME";
})) : process.env;
if (configHome) env.GENTLE_PI_CONFIG_HOME = configHome;
try {
return await new Promise((resolve) => {
const child = spawn(stage.command, { shell: true, stdio: "inherit", env });
child.on("close", (code) => resolve({ name: stage.name, code: code ?? 1 }));
child.on("error", (error) => {
console.error(`\n[${stage.name}] spawn failed: ${error.message}`);
resolve({ name: stage.name, code: 1 });
});
});
});
} finally {
if (configHome) rmSync(configHome, { recursive: true, force: true });
}
}

export async function runTestSuite(stages = DEFAULT_STAGES, { runStageImpl = runStage, write = (line) => console.log(line) } = {}) {
Expand Down
74 changes: 69 additions & 5 deletions tests/run-test-suite.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { DEFAULT_STAGES, runTestSuite } from "../scripts/run-test-suite.mjs";

// `pnpm test` chains its three stages; #1285 requires that a stage-1 failure
// never suppresses the later stages. These tests cover the runner's
// orchestration contract with fake stage implementations, so no real test
// process is spawned.
// #1285 requires that a stage-1 failure never suppresses later stages.
// Orchestration tests use fake stages; CLI tests exercise real child processes.

function fakeRunStage(codes) {
let index = 0;
Expand Down Expand Up @@ -113,6 +111,72 @@ test("direct invocation still runs stages when reached through a file symlink",
assert.match(result.stdout ?? "", /all stages passed/);
});

function probeStageEnvironment(t: test.TestContext, env: NodeJS.ProcessEnv) {
const stagesPath = writeStagesFile(t, []);
const probePath = join(dirname(stagesPath), "probe.mjs");
writeFileSync(probePath, `
import { readdirSync } from "node:fs";
const home = process.env.GENTLE_PI_CONFIG_HOME;
console.log("ENV_PROBE=" + JSON.stringify({
git: Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase().startsWith("GIT_"))),
agents: Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase().startsWith("GENTLE_PI_AGENTS_"))),
home,
files: home ? readdirSync(home) : null,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
sentinel: process.env.TEST_ENV_SENTINEL,
}));
`);
writeFileSync(stagesPath, JSON.stringify([
{ name: "unit-tests", command: `node "${probePath}"` },
{ name: "provider-contract", command: `node "${probePath}"` },
]));
const result = spawnSync(process.execPath, [runnerPath, stagesPath], {
encoding: "utf8", env: { ...process.env, ...env, TEST_ENV_SENTINEL: "preserved" },
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, "");
assert.match(result.stdout, /PASS unit-tests/);
assert.match(result.stdout, /PASS provider-contract/);
const probes = result.stdout.split(/\r?\n/).filter((line) => line.startsWith("ENV_PROBE="))
.map((line) => JSON.parse(line.slice("ENV_PROBE=".length)));
assert.equal(probes.length, 2);
for (const probe of probes) assert.equal(probe.sentinel, "preserved");
return probes;
}

test("unit stage strips inherited Git configuration without changing later stages", (t) => {
const env = {
GIT_CONFIG_COUNT: "2",
GIT_CONFIG_KEY_0: "credential.interactive", GIT_CONFIG_VALUE_0: "false",
GIT_CONFIG_KEY_1: "credential.guiPrompt", GIT_CONFIG_VALUE_1: "false",
GIT_DIR: "inherited-repository", GIT_AUTHOR_NAME: "inherited-author",
};
const [unit, provider] = probeStageEnvironment(t, env);
assert.deepEqual(unit.git, {});
for (const [key, value] of Object.entries(env)) assert.equal(provider.git[key], value);
});

test("unit stage strips inherited subagent context without changing later stages", (t) => {
const env = { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: "1" };
const [unit, provider] = probeStageEnvironment(t, env);
assert.deepEqual(unit.agents, {});
for (const [key, value] of Object.entries(env)) assert.equal(provider.agents[key], value);
});

test("unit stage uses an empty temporary config home and removes it after exit", (t) => {
const home = mkdtempSync(join(tmpdir(), "gentle-pi-caller-config-"));
t.after(() => rmSync(home, { recursive: true, force: true }));
const configPath = join(home, "runtime-guardrails.json");
const config = JSON.stringify({ autonomousMode: true, guardedCommands: { gitPush: "allow", gitRebase: "allow", gitBranchDeleteForce: "allow" } });
writeFileSync(configPath, config);
const [unit, provider] = probeStageEnvironment(t, { GENTLE_PI_CONFIG_HOME: home });
assert.notEqual(unit.home, home);
assert.deepEqual(unit.files, []);
assert.equal(existsSync(unit.home), false);
assert.equal(provider.home, home);
assert.deepEqual(provider.files, ["runtime-guardrails.json"]);
assert.equal(readFileSync(configPath, "utf8"), config);
});

test("direct invocation rejects a malformed stages file with a non-zero exit", (t) => {
const dir = mkdtempSync(join(tmpdir(), "gentle-pi-run-test-suite-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
Expand Down
Loading