Skip to content

Commit 9cb88ed

Browse files
Merge randomized test-order isolation fixes
2 parents 284b998 + 9e3b9db commit 9cb88ed

8 files changed

Lines changed: 129 additions & 12 deletions

File tree

.github/workflows/ci.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ on:
55
branches: [main]
66
pull_request:
77
workflow_dispatch:
8+
schedule:
9+
# Nightly, a fresh seed each run -- the fixed-seed step above only ever
10+
# exercises one shuffle of the suite, so a leak that this particular
11+
# order does not disturb would otherwise stay invisible forever.
12+
- cron: "17 7 * * *"
813

914
jobs:
1015
check:
@@ -41,3 +46,40 @@ jobs:
4146
# inference package (vendor/) is out of scope for this repo's CI.
4247
- name: Test
4348
run: bun run test
49+
50+
# Catches tests that only pass because of the default file order (shared
51+
# module-level state, an unrestored global mock, a leaked env var). The
52+
# seed is fixed so a failure here reproduces locally with the same flag.
53+
- name: Test (randomized order)
54+
run: bun test ./src ./tests ./evals --randomize --seed 424242
55+
56+
randomize-nightly:
57+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
58+
runs-on: ubuntu-latest
59+
steps:
60+
- name: Checkout
61+
uses: actions/checkout@v4
62+
63+
- name: Setup Node
64+
uses: actions/setup-node@v4
65+
with:
66+
node-version: "24"
67+
68+
- name: Setup Bun
69+
uses: oven-sh/setup-bun@v2
70+
with:
71+
bun-version: "1.3.14"
72+
73+
- name: Install ripgrep
74+
run: sudo apt-get install -y ripgrep
75+
76+
- name: Install dependencies
77+
run: bun install --frozen-lockfile
78+
79+
# A fresh seed every run, printed up front so a failure here reproduces
80+
# locally with the exact same `--seed` regardless of which shuffle hit it.
81+
- name: Test (fresh random seed)
82+
run: |
83+
seed=$RANDOM$RANDOM
84+
echo "seed=$seed"
85+
bun test ./src ./tests ./evals --randomize --seed "$seed"

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ When refactoring replaces an old path, delete the old one. No back-compat shims,
3333
- Add or update tests with every behavior change.
3434
- Bug fixes start with a failing test that reproduces the bug. Do not start by patching.
3535
- `tests/unit/` shared unit tests and helpers · co-located `src/**/*.test.ts` for module logic · `tests/fixtures/` fixture repos · `tests/integration/` reactor/permission harness. Planned: `tests/e2e/` (fixture-repo runs).
36+
- A test must not depend on another file having run, or on the default file order. It must pass under `bun test ./src ./tests ./evals --randomize`. If a test mutates module-level state or calls `mock.module`, it must restore that state itself (`afterEach`/`afterAll`), not rely on the process happening to reset it. When capturing a module's real exports to restore later, shallow-copy them (`{ ...moduleNamespace }`) at capture time, whether the namespace came from `await import(path)` or a static `import * as ns from "path"` — Bun mutates the live namespace object in place when the module is mocked, so holding a bare reference to it (either form) silently turns into the mocked exports.
3637

3738
## Build & Validation
3839

src/pricing-metadata.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, describe, expect, test } from "bun:test";
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
import { mkdtemp, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
@@ -15,6 +15,14 @@ import { contextWindowFor } from "./provider/context-window.js";
1515
import { writePricingCache } from "./cost/pricing-fetcher.js";
1616

1717
describe("pricing-metadata", () => {
18+
// refreshScheduled is a module-level one-shot latch shared with every other
19+
// file in this process; another file's real loadConfig() call can leave it
20+
// set before this file's first test ever runs. Reset on both sides so this
21+
// suite's outcome does not depend on what ran before it.
22+
beforeEach(() => {
23+
resetPricingMetadataRefreshForTests();
24+
});
25+
1826
afterEach(() => {
1927
resetPricingMetadataRefreshForTests();
2028
applyPricingCacheMetadata(null);

src/session/state.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { afterEach, beforeEach, expect, mock, test } from "bun:test";
2-
import * as realFs from "node:fs/promises";
1+
import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test";
32
import { mkdir, rm } from "node:fs/promises";
43
import { join } from "node:path";
54
import { tmpdir } from "node:os";
65

6+
// Bun mutates the imported namespace object in place when a module is
7+
// mocked, so the capture is shallow-copied immediately -- holding onto the
8+
// live namespace would turn into the mocked exports as soon as mock.module
9+
// below runs, making a later restore a no-op.
10+
const realFs = { ...(await import("node:fs/promises")) };
11+
712
// Simulates the straggler write's real await point (e.g. cycleRecorder.dispose
813
// during the terminal path) landing its writeFile after a later-issued
914
// terminal write's writeFile, so rename-order alone would let it win.
@@ -20,6 +25,10 @@ mock.module("node:fs/promises", () => ({
2025
},
2126
}));
2227

28+
afterAll(() => {
29+
mock.module("node:fs/promises", () => realFs);
30+
});
31+
2332
const { loadState, saveState } = await import("./state.js");
2433
type RunState = Awaited<ReturnType<typeof loadState>>;
2534

src/tools/web-search.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1+
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
22

33
const calls: { toolName: string; args: Record<string, unknown> }[] = [];
44
let connectConfigs: { name: string; url?: string }[] = [];
55

6+
// Bun mutates the imported namespace object in place when a module is
7+
// mocked, so the capture is shallow-copied immediately -- holding onto the
8+
// live namespace would turn into the mocked exports as soon as mock.module
9+
// below runs, making the afterAll restore a no-op. The mock also needs to
10+
// spread the real module rather than replace it outright, or any other
11+
// export (unwrapToolContent, connectMCPServers) disappears for the rest of
12+
// the process for every file that runs after this one.
13+
const realClient = { ...(await import("../mcp/client.js")) };
14+
615
mock.module("../mcp/client.js", () => ({
16+
...realClient,
717
connectMCPServer: async (config: { name: string; url?: string }) => {
818
connectConfigs.push(config);
919
return {
@@ -21,6 +31,10 @@ mock.module("../mcp/client.js", () => ({
2131
},
2232
}));
2333

34+
afterAll(() => {
35+
mock.module("../mcp/client.js", () => realClient);
36+
});
37+
2438
const {
2539
createWebSearchTool,
2640
disposeWebSearchClients,

src/tui-opentui/mouse-reporting-disabled.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ const mountedHarnesses: Harness[] = []
2222
// helpers) does a real `@opentui/core` import, or that import wins the module
2323
// cache and the mock never takes effect. Every dependency below is loaded
2424
// with a dynamic `import()` after `mock.module` for that reason.
25-
const realCore = await import("@opentui/core")
25+
//
26+
// Bun mutates the imported namespace object in place when a module is
27+
// mocked, so the capture is shallow-copied immediately -- holding onto the
28+
// live namespace would turn into the mocked exports the moment mock.module
29+
// below runs, making the afterAll restore below a no-op.
30+
const realCore = { ...(await import("@opentui/core")) }
2631

2732
mock.module("@opentui/core", () => ({
2833
...realCore,

tests/unit/config.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { test, expect, mock } from "bun:test";
22
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
33
import * as nodeOs from "node:os";
4+
5+
// Bun mutates the imported namespace object in place when a module is
6+
// mocked, so `nodeOs` itself is not safe to hold onto across a mock.module
7+
// call -- capture a shallow copy now, before anything mocks node:os, so the
8+
// snapshot below still reads "real" after the mock/restore round-trip.
9+
const realNodeOs = { ...nodeOs };
410
import { tmpdir } from "node:os";
511
import { join } from "node:path";
612
import { loadConfig } from "../../src/config/index.js";
@@ -226,7 +232,7 @@ test("loadConfig resolves an OAuth-profile provider absent from any settings fil
226232
// parameter — so the only way to point it at a synthetic auth store
227233
// without touching the real one is to stub node:os for the duration of
228234
// this call.
229-
mock.module("node:os", () => ({ ...nodeOs, homedir: () => fakeHome }));
235+
mock.module("node:os", () => ({ ...realNodeOs, homedir: () => fakeHome }));
230236
try {
231237
const { impl } = offlineFetch();
232238
const config = await loadConfig(
@@ -240,7 +246,7 @@ test("loadConfig resolves an OAuth-profile provider absent from any settings fil
240246
expect(config.providers.some((p) => p.name === "xai/synthetic")).toBe(true);
241247
}
242248
} finally {
243-
mock.module("node:os", () => nodeOs);
249+
mock.module("node:os", () => realNodeOs);
244250
}
245251
} finally {
246252
await rm(fakeHome, { recursive: true, force: true });

tests/unit/tui/agent-tools.test.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect, mock } from "bun:test";
1+
import { afterAll, test, expect, mock } from "bun:test";
22
import type { ToolDefinition, ToolCall } from "@intx/types/runtime";
33
import { TOOL_NAMES } from "@intx/tools-posix";
44

@@ -17,6 +17,27 @@ const mockPosixTools = {
1717
dispose: mockDispose,
1818
};
1919

20+
// mock.module replaces the shared module cache for the whole test process, so
21+
// every other file that imports these modules runs against the mock until it
22+
// is put back. Capture the real modules up front and restore them in
23+
// afterAll so this file's mocking is invisible outside its own tests. Bun
24+
// mutates the imported namespace object in place when a module is mocked, so
25+
// each capture is shallow-copied immediately -- holding onto the live
26+
// namespace instead would silently turn into the mocked exports as soon as
27+
// mock.module below runs, making the "restore" a no-op.
28+
const realToolsPosix = { ...(await import("@intx/tools-posix")) };
29+
const realPosixToolPlugins = { ...(await import("../../../src/agent/posix-tool-plugins.js")) };
30+
const realMcpPlugin = { ...(await import("../../../src/mcp/plugin.js")) };
31+
const realPathEscapePlugin = { ...(await import("../../../src/plugins/path-escape-plugin.js")) };
32+
const realAuthzPlugin = { ...(await import("../../../src/plugins/authz-plugin.js")) };
33+
const realVerifyPlugin = { ...(await import("../../../src/plugins/verify-plugin.js")) };
34+
const realPermissionPlugin = { ...(await import("../../../src/plugins/permission-plugin.js")) };
35+
const realSecretGuardPlugin = { ...(await import("../../../src/plugins/secret-guard-plugin.js")) };
36+
const realShellGuardPlugin = { ...(await import("../../../src/plugins/shell-guard-plugin.js")) };
37+
const realReadFileGuardPlugin = { ...(await import("../../../src/plugins/read-file-guard-plugin.js")) };
38+
const realEditFileLineRange = { ...(await import("../../../src/plugins/edit-file-line-range.js")) };
39+
const realDirector = { ...(await import("../../../src/agent/director.js")) };
40+
2041
mock.module("@intx/tools-posix", () => ({
2142
createPosixTools: () => mockPosixTools,
2243
TOOL_NAMES,
@@ -70,10 +91,6 @@ mock.module("../../../src/plugins/edit-file-line-range.js", () => ({
7091
advertiseEditFileLineRange: (defs: ToolDefinition[]) => defs,
7192
}));
7293

73-
mock.module("../../../src/web/plugin.js", () => ({
74-
webToolsPlugin: () => ({}),
75-
}));
76-
7794
mock.module("../../../src/agent/director.js", () => ({
7895
askOperatorDefinition: {
7996
name: "ask_operator",
@@ -93,6 +110,21 @@ mock.module("../../../src/agent/director.js", () => ({
93110
createChatDirector: mock(() => ({})),
94111
}));
95112

113+
afterAll(() => {
114+
mock.module("@intx/tools-posix", () => realToolsPosix);
115+
mock.module("../../../src/agent/posix-tool-plugins.js", () => realPosixToolPlugins);
116+
mock.module("../../../src/mcp/plugin.js", () => realMcpPlugin);
117+
mock.module("../../../src/plugins/path-escape-plugin.js", () => realPathEscapePlugin);
118+
mock.module("../../../src/plugins/authz-plugin.js", () => realAuthzPlugin);
119+
mock.module("../../../src/plugins/verify-plugin.js", () => realVerifyPlugin);
120+
mock.module("../../../src/plugins/permission-plugin.js", () => realPermissionPlugin);
121+
mock.module("../../../src/plugins/secret-guard-plugin.js", () => realSecretGuardPlugin);
122+
mock.module("../../../src/plugins/shell-guard-plugin.js", () => realShellGuardPlugin);
123+
mock.module("../../../src/plugins/read-file-guard-plugin.js", () => realReadFileGuardPlugin);
124+
mock.module("../../../src/plugins/edit-file-line-range.js", () => realEditFileLineRange);
125+
mock.module("../../../src/agent/director.js", () => realDirector);
126+
});
127+
96128
const { createAgentToolset } = await import("../../../src/agent/tools.js");
97129

98130
const fakePermissionGate = {

0 commit comments

Comments
 (0)