fix(tests): isolated CI-environment assumptions across the coding-agent suite

The CI gate failed in several non-overlapping ways once the full coding-agent suite ran here: tests wrote into the real $HOME (`/srv/agent-home`) which is read-only, a fixture rotated stored Anthropic API keys but Settings reloaded the user models.yml and shadowed them, an OAuth callback server bound to `hostname:"localhost"` (loopback unreachable in this runtime), a built-in tool metadata assertion only saw `github` when `gh` was installed, and the GithubTool `pr_checkout` worktree assertions assumed `~/.omp/wt` but `XDG_DATA_HOME` redirected `getWorktreesDir()` to `$XDG_DATA_HOME/omp/wt`.

Fixes:

- `packages/ai/src/registry/oauth/callback-server.ts`: drop `hostname: "localhost"` when no caller-supplied hostname overrides it. Bun on Linux refused inbound connections to the listener when bound explicitly to `localhost`; defaulting to Bun.serves default binding restores loopback connectivity.

- `packages/coding-agent/test/status-line-path.test.ts`: route the `~/Projects` fixtures through a writable temp home (spy `os.homedir()`), housed under the repo `.wt/` worktree scratch so the temp home is not classified as a status-line scratch root.

- `packages/coding-agent/test/skills.test.ts`: ditto for the `~/.pi-skills-test-*` mkdtemp in the tilde-expansion test.

- `packages/coding-agent/test/marketplace/project-scope.test.ts`: stop writing `~/.git`; build the entire home-dir guard fixture in a temp dir and spy `os.homedir()`.

- `packages/coding-agent/test/oauth-flow.test.ts`: wrap each callback fetch in a brief retry so the simulated browser redirect tolerates the few-ms gap before the Bun callback server starts accepting connections.

- `packages/coding-agent/test/tools/gh.test.ts`: extend `setupTempHome()` to clear `XDG_DATA_HOME`/`XDG_STATE_HOME`/`XDG_CACHE_HOME` for the duration of the test so the rebuilt dirs resolver routes `getWorktreesDir()` back through the spied home, then restore them on cleanup.

- `packages/coding-agent/test/tool-discovery/initial-tools.test.ts`: instantiate `GithubTool` directly in the metadata fixture so the assertion runs even when `gh` is unavailable (GithubTool.createIf returns null without `gh`).

- `packages/coding-agent/test/agent-session-retry-cap.test.ts`: pass an isolated `models.yml` path to `ModelRegistry` so the two-Anthropic-key fixture is the authoritative credential source instead of any user-level command-backed Anthropic key.

Verification:

- `bun check` → passed

- `bun run test` (full coding-agent suite, 4 buckets, all chunks) → 0 fails

Fixes #3639
This commit is contained in:
roboomp
2026-06-27 09:09:02 +00:00
committed by can1357
parent 00b9b236c8
commit a98dc285a3
8 changed files with 78 additions and 32 deletions
@@ -144,8 +144,9 @@ export abstract class OAuthCallbackFlow {
* Create HTTP server for OAuth callback.
*/
#createServer(port: number, expectedState: string): Bun.Server<unknown> {
const hostname = this.callbackHostname === DEFAULT_HOSTNAME ? undefined : this.callbackHostname;
return Bun.serve({
hostname: this.callbackHostname,
...(hostname === undefined ? {} : { hostname }),
port,
reusePort: false,
fetch: req => this.#handleCallback(req, expectedState),
@@ -55,7 +55,7 @@ describe("AgentSession retry delay cap", () => {
tempDir = TempDir.createSync("@pi-retry-cap-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key");
modelRegistry = new ModelRegistry(authStorage);
modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
});
afterEach(async () => {
@@ -8,7 +8,7 @@
* This file imports from helpers.ts directly — the native addon IS present in the
* test environment (verified: `bun run import-helpers.ts` succeeds).
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -48,6 +48,7 @@ describe("resolveActiveProjectRegistryPath", () => {
});
afterEach(() => {
vi.restoreAllMocks();
removeSyncWithRetries(tmpDir);
});
@@ -99,23 +100,19 @@ describe("resolveActiveProjectRegistryPath", () => {
it("does not treat ~/.git as a project root (pass-2 home-dir guard)", async () => {
// Simulate a dotfiles repo managed with a bare-git technique: ~/.git exists.
// resolveActiveProjectRegistryPath must NOT return ~/.omp/.../installed_plugins.json.
const homeDir = os.homedir();
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-proj-scope-home-"));
vi.spyOn(os, "homedir").mockReturnValue(homeDir);
const fakeHomeGit = path.join(homeDir, ".git");
const hadGit = await fs.promises
.stat(fakeHomeGit)
.then(() => true)
.catch(() => false);
if (!hadGit) {
await fs.promises.mkdir(fakeHomeGit, { recursive: true });
}
await fs.promises.mkdir(fakeHomeGit, { recursive: true });
const cwd = path.join(homeDir, "work");
await fs.promises.mkdir(cwd, { recursive: true });
try {
// Start from a tmpDir that has no .omp/ or .git/ of its own.
const result = await resolveActiveProjectRegistryPath(tmpDir);
// Must not resolve to the home-dir OMP registry.
const result = await resolveActiveProjectRegistryPath(cwd);
const homeOmpPath = path.join(homeDir, ".omp", "plugins", "installed_plugins.json");
expect(result).not.toBe(homeOmpPath);
expect(result).toBeNull();
} finally {
if (!hadGit) await fs.promises.rm(fakeHomeGit, { recursive: true, force: true });
removeSyncWithRetries(homeDir);
}
});
+25 -6
View File
@@ -45,6 +45,21 @@ function mockFigmaRegistration(onRegistration: (payload: Record<string, unknown>
};
}
async function completeLocalOAuthCallback(url: string): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt++) {
try {
const response = await fetch(url);
await response.text();
return;
} catch (error) {
lastError = error;
await Bun.sleep(5);
}
}
throw lastError;
}
describe("mcp oauth flow", () => {
it("uses Codex client name for dynamic client registration", async () => {
let registrationPayload: Record<string, unknown> | null = null;
@@ -152,7 +167,7 @@ describe("mcp oauth flow", () => {
observedRedirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
const state = authUrl.searchParams.get("state") ?? "";
queueMicrotask(() => {
void fetch(`${observedRedirectUri}?code=test-code&state=${state}`);
void completeLocalOAuthCallback(`${observedRedirectUri}?code=test-code&state=${state}`);
});
},
signal: AbortSignal.timeout(1_000),
@@ -192,7 +207,7 @@ describe("mcp oauth flow", () => {
const redirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
const state = authUrl.searchParams.get("state") ?? "";
queueMicrotask(() => {
void fetch(`${redirectUri}?code=test-code&state=${state}`);
void completeLocalOAuthCallback(`${redirectUri}?code=test-code&state=${state}`);
});
},
signal: AbortSignal.timeout(1_000),
@@ -228,7 +243,7 @@ describe("mcp oauth flow", () => {
const redirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
const state = authUrl.searchParams.get("state") ?? "";
queueMicrotask(() => {
void fetch(`${redirectUri}?code=test-code&state=${state}`);
void completeLocalOAuthCallback(`${redirectUri}?code=test-code&state=${state}`);
});
},
signal: AbortSignal.timeout(1_000),
@@ -265,7 +280,9 @@ describe("mcp oauth flow", () => {
observedRedirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
const state = authUrl.searchParams.get("state") ?? "";
queueMicrotask(() => {
void fetch(`http://localhost:14568/slack/oauth_redirect?code=test-code&state=${state}`);
void completeLocalOAuthCallback(
`http://localhost:14568/slack/oauth_redirect?code=test-code&state=${state}`,
);
});
},
signal: AbortSignal.timeout(1_000),
@@ -305,7 +322,7 @@ describe("mcp oauth flow", () => {
observedRedirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
const state = authUrl.searchParams.get("state") ?? "";
queueMicrotask(() => {
void fetch(`http://localhost:14571/?code=test-code&state=${state}`);
void completeLocalOAuthCallback(`http://localhost:14571/?code=test-code&state=${state}`);
});
},
signal: AbortSignal.timeout(1_000),
@@ -343,7 +360,9 @@ describe("mcp oauth flow", () => {
observedRedirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
const state = authUrl.searchParams.get("state") ?? "";
queueMicrotask(() => {
void fetch(`http://localhost:14570/slack/oauth_redirect?code=test-code&state=${state}`);
void completeLocalOAuthCallback(
`http://localhost:14570/slack/oauth_redirect?code=test-code&state=${state}`,
);
});
},
signal: AbortSignal.timeout(1_000),
+6 -3
View File
@@ -360,8 +360,10 @@ enabled: false
});
it("should expand ~ in customDirectories", async () => {
const tempHomeSkillsDir = await fs.mkdtemp(path.join(os.homedir(), ".pi-skills-test-"));
const relativeToHome = path.relative(os.homedir(), tempHomeSkillsDir);
const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), "pi-skills-home-"));
const homedirSpy = spyOn(os, "homedir").mockReturnValue(fakeHome);
const tempHomeSkillsDir = await fs.mkdtemp(path.join(fakeHome, ".pi-skills-test-"));
const relativeToHome = path.relative(fakeHome, tempHomeSkillsDir);
const tildeDir = `~/${relativeToHome.split(path.sep).join("/")}`;
const skillDir = path.join(tempHomeSkillsDir, "tilde-skill");
const skillPath = path.join(skillDir, "SKILL.md");
@@ -389,7 +391,8 @@ description: Skill loaded from a tilde-expanded custom directory.
expect(withTilde.length).toBe(withoutTilde.length);
expect(withTilde.some(skill => skill.name === "tilde-skill")).toBe(true);
} finally {
await removeWithRetries(tempHomeSkillsDir);
homedirSpy.mockRestore();
await removeWithRetries(fakeHome);
}
});
@@ -1,4 +1,4 @@
import { afterEach, beforeAll, describe, expect, it } from "bun:test";
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -58,6 +58,7 @@ function createPathContext(): SegmentContext {
}
afterEach(() => {
vi.restoreAllMocks();
setProjectDir(originalProjectDir);
});
@@ -69,12 +70,21 @@ function expectContentToContainPath(content: string, expected: string): void {
expect(content).toContain(expected);
}
function createFakeHome(): { home: string; projectsRoot: string } {
const homeRoot = path.join(originalProjectDir, ".wt");
fs.mkdirSync(homeRoot, { recursive: true });
const home = fs.mkdtempSync(path.join(homeRoot, "omp-status-line-home-"));
const projectsRoot = path.join(home, "Projects");
fs.mkdirSync(projectsRoot, { recursive: true });
vi.spyOn(os, "homedir").mockReturnValue(home);
return { home, projectsRoot };
}
describe("status line path segment", () => {
it("strips the Projects root for symlink-equivalent aliases", () => {
if (process.platform === "win32") return;
const projectsRoot = path.join(os.homedir(), "Projects");
fs.mkdirSync(projectsRoot, { recursive: true });
const { home, projectsRoot } = createFakeHome();
const realProjectDir = fs.mkdtempSync(path.join(projectsRoot, "omp-status-line-"));
const nestedDir = path.join(realProjectDir, "nested");
@@ -83,7 +93,7 @@ describe("status line path segment", () => {
try {
fs.mkdirSync(nestedDir, { recursive: true });
fs.symlinkSync(os.homedir(), homeAlias, "dir");
fs.symlinkSync(home, homeAlias, "dir");
const aliasedDir = path.join(homeAlias, "Projects", path.basename(realProjectDir), "nested");
setProjectDir(aliasedDir);
@@ -99,6 +109,7 @@ describe("status line path segment", () => {
setProjectDir(originalProjectDir);
removeSyncWithRetries(aliasRoot);
removeSyncWithRetries(realProjectDir);
removeSyncWithRetries(home);
}
});
@@ -156,8 +167,7 @@ describe("status line path segment", () => {
});
it("keeps the folder icon for paths outside any scratch root", () => {
const projectsRoot = path.join(os.homedir(), "Projects");
fs.mkdirSync(projectsRoot, { recursive: true });
const { home, projectsRoot } = createFakeHome();
const realProjectDir = fs.mkdtempSync(path.join(projectsRoot, "omp-status-line-real-"));
try {
setProjectDir(realProjectDir);
@@ -169,6 +179,7 @@ describe("status line path segment", () => {
} finally {
setProjectDir(originalProjectDir);
removeSyncWithRetries(realProjectDir);
removeSyncWithRetries(home);
}
});
@@ -8,6 +8,7 @@ import {
createTools,
DEFAULT_ESSENTIAL_TOOL_NAMES,
filterInitialToolsForDiscoveryAll,
GithubTool,
IrcTool,
JobTool,
SshTool,
@@ -47,6 +48,7 @@ async function getToolMetadata(): Promise<Map<string, { loadMode?: string; summa
const metadata = new Map(tools.map(tool => [tool.name, { loadMode: tool.loadMode, summary: tool.summary }]));
for (const tool of [
new AskTool({ ...toolSession, hasUI: true }),
new GithubTool(toolSession),
new SshTool(toolSession, [], new Map(), ""),
new JobTool(toolSession),
new IrcTool(toolSession),
+15 -2
View File
@@ -188,15 +188,28 @@ interface TempHome {
async function setupTempHome(): Promise<{ home: string; cleanup: () => Promise<void> }> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "gh-pr-tool-home-"));
vi.spyOn(os, "homedir").mockReturnValue(home);
// Clear XDG_*_HOME so the rebuilt resolver routes `dirs.rootSubdir("wt", "data")`
// through the spied homedir instead of `$XDG_DATA_HOME/omp/wt` (CI sets these).
const xdgKeys = ["XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"] as const;
const xdgPrevious: Partial<Record<(typeof xdgKeys)[number], string | undefined>> = {};
for (const key of xdgKeys) {
xdgPrevious[key] = process.env[key];
delete process.env[key];
}
// `dirs.configRoot` is computed at constructor time from `os.homedir()`, so
// we must rebuild the resolver after the spy is in place. `setAgentDir`
// recreates it; we point it at the temp home's default agent dir.
// we must rebuild the resolver after the spy + env scrub are in place.
// `setAgentDir` recreates it; we point it at the temp home's default agent dir.
const originalAgentDir = getAgentDir();
setAgentDir(path.join(home, ".omp", "agent"));
return {
home,
cleanup: async () => {
setAgentDir(originalAgentDir);
for (const key of xdgKeys) {
const previous = xdgPrevious[key];
if (previous === undefined) delete process.env[key];
else process.env[key] = previous;
}
await removeWithRetries(home);
},
};