diff --git a/packages/coding-agent/test/bash-session-metadata.test.ts b/packages/coding-agent/test/bash-session-metadata.test.ts index 519e268e6..3affa8fd7 100644 --- a/packages/coding-agent/test/bash-session-metadata.test.ts +++ b/packages/coding-agent/test/bash-session-metadata.test.ts @@ -45,7 +45,17 @@ async function createSession(options: { persisted?: boolean; stage?: string } = } afterEach(() => { - for (const root of roots.splice(0)) if (existsSync(root)) rmSync(root, { recursive: true, force: true }); + // Windows locks a directory while a spawned shell still has it as its cwd, so a + // detached async job can outlive its test and make cleanup fail with EBUSY. + // Retry, then give up: reclaiming an OS temp dir is never the assertion. + for (const root of roots.splice(0)) { + if (!existsSync(root)) continue; + try { + rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + } catch { + // Left for the OS to reclaim. + } + } }); describe("session-aware bash environment", () => { diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index 53877dc98..5f62f25b3 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -1,3 +1,4 @@ +import { sep } from "node:path"; import { visibleWidth } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, it } from "vitest"; import type { AgentSession } from "../src/core/agent-session.ts"; @@ -115,7 +116,7 @@ describe("formatCwdForFooter", () => { it("abbreviates the home directory and descendants", () => { expect(formatCwdForFooter("/home/user", "/home/user")).toBe("~"); - expect(formatCwdForFooter("/home/user/project", "/home/user")).toBe("~/project"); + expect(formatCwdForFooter("/home/user/project", "/home/user")).toBe(`~${sep}project`); }); }); diff --git a/packages/coding-agent/test/interactive-auth-login.test.ts b/packages/coding-agent/test/interactive-auth-login.test.ts index 3b6821992..44ccf5ee4 100644 --- a/packages/coding-agent/test/interactive-auth-login.test.ts +++ b/packages/coding-agent/test/interactive-auth-login.test.ts @@ -4,6 +4,14 @@ import { LoginDialogComponent } from "../src/modes/interactive/components/login- import { InteractiveModeBase } from "../src/modes/interactive/interactive-mode-base.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; import "../src/modes/interactive/interactive-auth-login.ts"; +import { openBrowser } from "../src/utils/open-browser.ts"; + +// Driving the real LoginDialogComponent through onAuth opens the platform browser. +// On Windows that spawns a detached `rundll32 url.dll,FileProtocolHandler`, which +// inherits this process's stdout/stderr write handles; on a runner with no browser +// it never exits, the suite's pipes never reach EOF, and the CI step hangs until the +// job budget expires. Mock the launcher, as pi's login-dialog suites do. +vi.mock("../src/utils/open-browser.ts", () => ({ openBrowser: vi.fn() })); beforeAll(() => { initTheme("dark"); @@ -153,6 +161,7 @@ describe("interactive OAuth cancellation", () => { const dialog = addedChildren[0] as LoginDialogComponent; expect(dialog.render(100).join("\n")).toContain("Sign in to Corp"); expect(completeProviderAuthentication).toHaveBeenCalledOnce(); + expect(vi.mocked(openBrowser)).toHaveBeenCalledWith("https://corp.invalid/login"); }, 1_000); it("keeps a post-login refresh AbortError visible", async () => { diff --git a/packages/coding-agent/test/sdk-session-manager.test.ts b/packages/coding-agent/test/sdk-session-manager.test.ts index bfdd3c2f9..d72475a52 100644 --- a/packages/coding-agent/test/sdk-session-manager.test.ts +++ b/packages/coding-agent/test/sdk-session-manager.test.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import { getModel } from "@earendil-works/pi-ai/compat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -92,7 +92,7 @@ describe("createAgentSession session manager defaults", () => { const sessionFile = session.sessionManager.getSessionFile(); expect(sessionDir).toBe(expectedSessionDir); - expect(sessionFile?.startsWith(`${expectedSessionDir}/`)).toBe(true); + expect(sessionFile?.startsWith(`${expectedSessionDir}${sep}`)).toBe(true); session.dispose(); }); @@ -129,17 +129,22 @@ describe("createAgentSession session manager defaults", () => { }); expect(session.sessionManager).toBe(sessionManager); - expect(session.systemPrompt).toContain(`Current working directory: ${sessionCwd}`); + // The system prompt always renders the cwd with POSIX separators (system-prompt.ts). + expect(session.systemPrompt).toContain(`Current working directory: ${sessionCwd.replaceAll("\\", "/")}`); const bashTool = session.agent.state.tools.find((tool) => tool.name === "bash"); expect(bashTool).toBeTruthy(); - const result = await bashTool!.execute("test", { command: "pwd" }); + // Prove the tool's working directory by reading a marker only resolvable from + // sessionCwd. Comparing `pwd` text is not portable: on Windows the bash tool runs + // under Git Bash, which reports MSYS paths (/tmp/...) that Node cannot resolve. + writeFileSync(join(sessionCwd, "cwd-marker.txt"), "session-cwd-marker"); + const result = await bashTool!.execute("test", { command: "cat cwd-marker.txt" }); const output = result.content .filter((item): item is { type: "text"; text: string } => item.type === "text") .map((item) => item.text) .join(""); - expect(realpathSync(output.trim())).toBe(realpathSync(sessionCwd)); + expect(output.trim()).toBe("session-cwd-marker"); session.dispose(); }); diff --git a/scripts/run-flaky-test-suite.ts b/scripts/run-flaky-test-suite.ts index 1d7807123..e69798489 100755 --- a/scripts/run-flaky-test-suite.ts +++ b/scripts/run-flaky-test-suite.ts @@ -33,16 +33,39 @@ function safeName(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "tests"; } +/** Tee one child stream to the live step log while capturing it for diagnostics. */ +async function pump(stream: ReadableStream | undefined, sink: NodeJS.WriteStream): Promise { + if (!stream) return ""; + const decoder = new TextDecoder(); + let text = ""; + for await (const chunk of stream) { + const piece = decoder.decode(chunk, { stream: true }); + text += piece; + sink.write(piece); + } + const tail = decoder.decode(); + if (tail) { + text += tail; + sink.write(tail); + } + return text; +} + +/** + * Stream child output as it arrives instead of buffering it until the child exits. + * A suite that overruns the job's `timeout-minutes` budget is killed mid-run, and a + * buffered attempt discards every line it had already collected, leaving the cancelled + * step with no record of which test stalled. Teeing preserves the retry and diagnostics + * contract while making a timed-out attempt self-describing in the step log. + */ async function runAttempt(command: string[], logPath: string, persist: boolean): Promise<{ code: number; output: string }> { const child = Bun.spawn(command, { stdout: "pipe", stderr: "pipe", env: process.env }); const [stdout, stderr, code] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), + pump(child.stdout, process.stdout), + pump(child.stderr, process.stderr), child.exited, ]); const output = `${stdout}${stderr}`; - process.stdout.write(stdout); - process.stderr.write(stderr); if (persist) writeFileSync(logPath, output); return { code, output }; }