test(gsd): harden suite-level stability for RTK, worktree, and git bootstrap (#2786)

* test: harden web runtime auth token and lock retry tests

Teach the packaged web runtime harness to recover the auth token from the launcher stderr when the browser-open stub log is absent. Also widen the transient session-lock retry tests so they stay stable under full-suite CPU contention.

* test: harden suite-level RTK and worktree stability

Stabilize the RTK seam tests under full-suite load by using a faster fake RTK binary on Unix and allowing the tests to raise the rewrite timeout without changing the production default. Also widen the transient session-lock retry budget and give the heavy auto-worktree milestone merge suite an explicit timeout so it can complete under CI-level contention.

* test: harden git-service repo bootstrap under suite load

Switch repo bootstrap steps in git-service.test.ts to runGit(...) where the setup only needs direct git invocations.

This removes avoidable shell wrappers from the highest-churn repo setup paths, which makes the full unit suite less prone to child-process flake under load while keeping the test behavior unchanged.
This commit is contained in:
mastertyko 2026-03-27 03:02:41 +01:00 committed by GitHub
parent 4fe4dbf456
commit d7755e596c
7 changed files with 123 additions and 41 deletions

View file

@ -77,7 +77,7 @@ function addSliceToMilestone(
run(`git branch -d ${sliceBranch}`, wtPath);
}
describe("auto-worktree-milestone-merge", () => {
describe("auto-worktree-milestone-merge", { timeout: 300_000 }, () => {
const savedCwd = process.cwd();
const tempDirs: string[] = [];

View file

@ -287,9 +287,9 @@ describe('git-service', async () => {
const tempDir = mkdtempSync(join(tmpdir(), "gsd-git-service-test-"));
run("git init -b main", tempDir);
run('git config user.name "Pi Test"', tempDir);
run('git config user.email "pi@example.com"', tempDir);
runGit(tempDir, ["init", "-b", "main"]);
runGit(tempDir, ["config", "user.name", "Pi Test"]);
runGit(tempDir, ["config", "user.email", "pi@example.com"]);
// runGit should work on a valid repo
const branch = runGit(tempDir, ["branch", "--show-current"]);
@ -334,13 +334,13 @@ describe('git-service', async () => {
function initTempRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-git-t02-"));
run("git init -b main", dir);
run('git config user.name "Pi Test"', dir);
run('git config user.email "pi@example.com"', dir);
runGit(dir, ["init", "-b", "main"]);
runGit(dir, ["config", "user.name", "Pi Test"]);
runGit(dir, ["config", "user.email", "pi@example.com"]);
// Need an initial commit so HEAD exists
createFile(dir, ".gitkeep", "");
run("git add -A", dir);
run('git commit -m "init"', dir);
runGit(dir, ["add", "-A"]);
runGit(dir, ["commit", "-m", "init"]);
return dir;
}
@ -577,12 +577,12 @@ describe('git-service', async () => {
function initBranchTestRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-git-t03-"));
run("git init -b main", dir);
run('git config user.name "Pi Test"', dir);
run('git config user.email "pi@example.com"', dir);
runGit(dir, ["init", "-b", "main"]);
runGit(dir, ["config", "user.name", "Pi Test"]);
runGit(dir, ["config", "user.email", "pi@example.com"]);
createFile(dir, ".gitkeep", "");
run("git add -A", dir);
run('git commit -m "init"', dir);
runGit(dir, ["add", "-A"]);
runGit(dir, ["commit", "-m", "init"]);
return dir;
}
@ -618,12 +618,12 @@ describe('git-service', async () => {
{
// master-only repo
const repo = mkdtempSync(join(tmpdir(), "gsd-git-t03-master-"));
run("git init -b master", repo);
run('git config user.name "Pi Test"', repo);
run('git config user.email "pi@example.com"', repo);
runGit(repo, ["init", "-b", "master"]);
runGit(repo, ["config", "user.name", "Pi Test"]);
runGit(repo, ["config", "user.email", "pi@example.com"]);
createFile(repo, ".gitkeep", "");
run("git add -A", repo);
run('git commit -m "init"', repo);
runGit(repo, ["add", "-A"]);
runGit(repo, ["commit", "-m", "init"]);
const svc = new GitServiceImpl(repo);
assert.deepStrictEqual(svc.getMainBranch(), "master", "getMainBranch returns master when only master exists");
@ -1115,9 +1115,9 @@ describe('git-service', async () => {
test('untrackRuntimeFiles', async () => {
const { untrackRuntimeFiles } = await import("../gitignore.ts");
const repo = mkdtempSync(join(tmpdir(), "gsd-untrack-"));
run("git init -b main", repo);
run("git config user.email test@test.com", repo);
run("git config user.name Test", repo);
runGit(repo, ["init", "-b", "main"]);
runGit(repo, ["config", "user.email", "test@test.com"]);
runGit(repo, ["config", "user.name", "Test"]);
// Create and track runtime files (simulates pre-.gitignore state)
mkdirSync(join(repo, ".gsd", "activity"), { recursive: true });
@ -1128,8 +1128,8 @@ describe('git-service', async () => {
writeFileSync(join(repo, ".gsd", "activity", "log.jsonl"), "{}");
writeFileSync(join(repo, ".gsd", "runtime", "data.json"), "{}");
writeFileSync(join(repo, "src.ts"), "code");
run("git add -A", repo);
run("git commit -m init", repo);
runGit(repo, ["add", "-A"]);
runGit(repo, ["commit", "-m", "init"]);
// Precondition: runtime files are tracked
const trackedBefore = run("git ls-files .gsd/", repo);
@ -1164,11 +1164,12 @@ describe('git-service', async () => {
test('smartStage excludes runtime files, allows milestone artifacts', () => {
const repo = mkdtempSync(join(tmpdir(), "gsd-smart-stage-excludes-"));
run("git init -b main", repo);
run("git config user.email test@test.com", repo);
run("git config user.name Test", repo);
runGit(repo, ["init", "-b", "main"]);
runGit(repo, ["config", "user.email", "test@test.com"]);
runGit(repo, ["config", "user.name", "Test"]);
writeFileSync(join(repo, "README.md"), "init");
run("git add -A && git commit -m init", repo);
runGit(repo, ["add", "-A"]);
runGit(repo, ["commit", "-m", "init"]);
// Create .gsd/ runtime files + milestone artifacts + a normal source file
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });

View file

@ -95,13 +95,13 @@ async function main(): Promise<void> {
writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
// Simulate transient unavailability: move file away, spawn a child process
// to restore it after 100ms. The child runs outside our event loop so it
// fires even during busy-wait retries.
// to restore it shortly after. The child runs outside our event loop so it
// fires even during busy-wait retries. Give the test extra retry budget so
// it stays stable under full-suite CPU contention.
renameSync(lockFile, tmpFile);
spawn('bash', ['-c', `sleep 0.1 && mv "${tmpFile}" "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
spawn('bash', ['-c', `sleep 0.05 && mv "${tmpFile}" "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
// With retries (3 attempts, 200ms delay), it should recover on 2nd or 3rd attempt
const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 3, delayMs: 200 });
const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 8, delayMs: 400 });
assertTrue(result !== null, 'data recovered after transient unavailability');
if (result) {
assertEq(result.pid, process.pid, 'correct PID after recovery');
@ -131,11 +131,12 @@ async function main(): Promise<void> {
writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
// Remove read permission to simulate NFS/CIFS latency, then spawn a child
// to restore permissions after 100ms (runs outside our event loop).
// to restore permissions shortly after (runs outside our event loop).
// Use the same wider retry window as the rename case for full-suite stability.
chmodSync(lockFile, 0o000);
spawn('bash', ['-c', `sleep 0.1 && chmod 644 "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
spawn('bash', ['-c', `sleep 0.05 && chmod 644 "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 3, delayMs: 200 });
const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 8, delayMs: 400 });
assertTrue(result !== null, 'data recovered after transient permission error');
if (result) {
assertEq(result.pid, process.pid, 'correct PID after permission recovery');

View file

@ -5,6 +5,7 @@ import { delimiter, join } from "node:path";
const GSD_RTK_PATH_ENV = "GSD_RTK_PATH";
const GSD_RTK_DISABLED_ENV = "GSD_RTK_DISABLED";
const GSD_RTK_REWRITE_TIMEOUT_MS_ENV = "GSD_RTK_REWRITE_TIMEOUT_MS";
const RTK_TELEMETRY_DISABLED_ENV = "RTK_TELEMETRY_DISABLED";
const RTK_REWRITE_TIMEOUT_MS = 5_000;
@ -14,6 +15,14 @@ function isTruthy(value: string | undefined): boolean {
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function getRewriteTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const configured = Number.parseInt(env[GSD_RTK_REWRITE_TIMEOUT_MS_ENV] ?? "", 10);
if (Number.isFinite(configured) && configured > 0) {
return configured;
}
return RTK_REWRITE_TIMEOUT_MS;
}
export function isRtkEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return !isTruthy(env[GSD_RTK_DISABLED_ENV]);
}
@ -116,7 +125,7 @@ export function rewriteCommandWithRtk(command: string, options: RewriteCommandOp
encoding: "utf-8",
env: buildRtkEnv(env),
stdio: ["ignore", "pipe", "ignore"],
timeout: RTK_REWRITE_TIMEOUT_MS,
timeout: getRewriteTimeoutMs(env),
// .cmd/.bat wrappers (used by fake-rtk in tests) require shell:true on Windows
shell: /\.(cmd|bat)$/i.test(binaryPath),
});

View file

@ -116,6 +116,11 @@ export function parseStartedUrl(stderr: string): string {
return match[1]
}
function parseReadyAuthToken(stderr: string): string | null {
const match = stderr.match(/\[gsd\] Ready → http:\/\/[^\s]+\/#token=([a-f0-9]{64})/)
return match?.[1] ?? null
}
export async function launchPackagedWebHost(options: {
launchCwd: string
tempHome: string
@ -194,6 +199,9 @@ export async function launchPackagedWebHost(options: {
} catch {
// Non-fatal — tests that don't need the token can proceed without it
}
if (!authToken) {
authToken = parseReadyAuthToken(stderr)
}
finish({
exitCode: code,
stderr,

View file

@ -14,11 +14,35 @@ import { createFakeRtk } from "./rtk-test-utils.ts";
const noopSignal = new AbortController().signal;
async function waitFor(predicate: () => boolean, timeoutMs = 2_000, pollMs = 25): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(`condition not met within ${timeoutMs}ms`);
}
async function waitForOutputMatch(
getOutput: () => string,
pattern: RegExp,
timeoutMs = 2_000,
): Promise<string> {
let latest = getOutput();
await waitFor(() => {
latest = getOutput();
return pattern.test(latest);
}, timeoutMs);
return latest;
}
function withFakeRtk<T>(mapping: Record<string, string | { status?: number; stdout?: string }>, run: () => Promise<T> | T): Promise<T> | T {
const fake = createFakeRtk(mapping);
const previousPath = process.env.GSD_RTK_PATH;
const previousDisabled = process.env.GSD_RTK_DISABLED;
const previousTimeout = process.env.GSD_RTK_REWRITE_TIMEOUT_MS;
process.env.GSD_RTK_PATH = fake.path;
process.env.GSD_RTK_REWRITE_TIMEOUT_MS = "20000";
delete process.env.GSD_RTK_DISABLED;
const finalize = () => {
@ -26,6 +50,8 @@ function withFakeRtk<T>(mapping: Record<string, string | { status?: number; stdo
else process.env.GSD_RTK_PATH = previousPath;
if (previousDisabled === undefined) delete process.env.GSD_RTK_DISABLED;
else process.env.GSD_RTK_DISABLED = previousDisabled;
if (previousTimeout === undefined) delete process.env.GSD_RTK_REWRITE_TIMEOUT_MS;
else process.env.GSD_RTK_REWRITE_TIMEOUT_MS = previousTimeout;
fake.cleanup();
};
@ -56,13 +82,16 @@ function withManagedFakeRtk<T>(mapping: Record<string, string | { status?: numbe
const previousHome = process.env.GSD_HOME;
const previousPath = process.env.GSD_RTK_PATH;
const previousDisabled = process.env.GSD_RTK_DISABLED;
const previousTimeout = process.env.GSD_RTK_REWRITE_TIMEOUT_MS;
process.env.GSD_HOME = managedHome;
process.env.GSD_RTK_REWRITE_TIMEOUT_MS = "20000";
delete process.env.GSD_RTK_PATH;
delete process.env.GSD_RTK_DISABLED;
const env: NodeJS.ProcessEnv = {
...process.env,
GSD_HOME: managedHome,
GSD_RTK_REWRITE_TIMEOUT_MS: "20000",
};
delete env.GSD_RTK_PATH;
@ -73,6 +102,8 @@ function withManagedFakeRtk<T>(mapping: Record<string, string | { status?: numbe
else process.env.GSD_RTK_PATH = previousPath;
if (previousDisabled === undefined) delete process.env.GSD_RTK_DISABLED;
else process.env.GSD_RTK_DISABLED = previousDisabled;
if (previousTimeout === undefined) delete process.env.GSD_RTK_REWRITE_TIMEOUT_MS;
else process.env.GSD_RTK_REWRITE_TIMEOUT_MS = previousTimeout;
fake.cleanup();
rmSync(managedHome, { recursive: true, force: true });
};
@ -162,8 +193,10 @@ test("bg_shell start and runOnSession both execute RTK-rewritten commands", asyn
ownerSessionFile: "session-rtk",
});
await new Promise((resolve) => setTimeout(resolve, 300));
assert.match(oneshot.output.map((line) => line.line).join("\n"), /rewritten/);
assert.match(
await waitForOutputMatch(() => oneshot.output.map((line) => line.line).join("\n"), /rewritten/),
/rewritten/,
);
const shellSession = startProcess({
command: "",
@ -172,7 +205,7 @@ test("bg_shell start and runOnSession both execute RTK-rewritten commands", asyn
type: "shell",
});
await new Promise((resolve) => setTimeout(resolve, 300));
await waitFor(() => shellSession.status === "ready" || !shellSession.alive);
const result = await runOnSession(shellSession, "echo raw", 2_000);
assert.equal(result.exitCode, 0);
assert.match(result.output, /rewritten/);

View file

@ -4,6 +4,10 @@ import { join } from "node:path";
export type FakeRtkResponse = string | { status?: number; stdout?: string };
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
export function createFakeRtk(mapping: Record<string, FakeRtkResponse>): { path: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), "gsd-fake-rtk-"));
const payload = JSON.stringify(mapping);
@ -36,7 +40,33 @@ process.exit(match.status ?? 0);
}
const binaryPath = join(dir, "rtk");
writeFileSync(binaryPath, jsSource, "utf-8");
const cases = Object.entries(mapping).map(([key, response], index) => {
const output = typeof response === "string" ? response : (response.stdout ?? "");
const status = typeof response === "string" ? 0 : (response.status ?? 0);
return `
if [ "$full_input" = ${shellQuote(key)} ]; then
printf '%s' ${shellQuote(output)}
exit ${status}
fi
if [ -n "$rewrite_input" ] && [ "$rewrite_input" = ${shellQuote(key)} ]; then
printf '%s' ${shellQuote(output)}
exit ${status}
fi`.trimStart();
}).join("\n\n");
const shellSource = `#!/bin/sh
full_input="$*"
rewrite_input=""
if [ "$1" = "rewrite" ]; then
shift
rewrite_input="$*"
fi
${cases}
exit 1
`;
writeFileSync(binaryPath, shellSource, "utf-8");
chmodSync(binaryPath, 0o755);
return {
path: binaryPath,