diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index bb143a8c4..87af75fa0 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -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[] = []; diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index 701cc2ff0..2a5587d9b 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -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 }); diff --git a/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts b/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts index 85d0b93f4..60192527c 100644 --- a/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts +++ b/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts @@ -95,13 +95,13 @@ async function main(): Promise { 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 { 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'); diff --git a/src/resources/extensions/shared/rtk.ts b/src/resources/extensions/shared/rtk.ts index bf0d4880e..33bcb6609 100644 --- a/src/resources/extensions/shared/rtk.ts +++ b/src/resources/extensions/shared/rtk.ts @@ -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), }); diff --git a/src/tests/integration/web-mode-runtime-harness.ts b/src/tests/integration/web-mode-runtime-harness.ts index fed508e34..62c491cec 100644 --- a/src/tests/integration/web-mode-runtime-harness.ts +++ b/src/tests/integration/web-mode-runtime-harness.ts @@ -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, diff --git a/src/tests/rtk-execution-seams.test.ts b/src/tests/rtk-execution-seams.test.ts index ab1dda678..0d1a781ec 100644 --- a/src/tests/rtk-execution-seams.test.ts +++ b/src/tests/rtk-execution-seams.test.ts @@ -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 { + 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 { + let latest = getOutput(); + await waitFor(() => { + latest = getOutput(); + return pattern.test(latest); + }, timeoutMs); + return latest; +} + function withFakeRtk(mapping: Record, run: () => Promise | T): Promise | 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(mapping: Record(mapping: Record(mapping: Record 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/); diff --git a/src/tests/rtk-test-utils.ts b/src/tests/rtk-test-utils.ts index 76cf81072..bf3526081 100644 --- a/src/tests/rtk-test-utils.ts +++ b/src/tests/rtk-test-utils.ts @@ -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): { 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,