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:
parent
4fe4dbf456
commit
d7755e596c
7 changed files with 123 additions and 41 deletions
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue