diff --git a/docs/ci/pipeline.md b/docs/ci/pipeline.md index 05f2a43ddabd..0194315df92a 100644 --- a/docs/ci/pipeline.md +++ b/docs/ci/pipeline.md @@ -179,11 +179,14 @@ The Code Mode executor runs with Vitest on Bun using the fork's copy-on-write diagnostics-channel subscriber handling. Markdown render-aware chunking stays on Node because the pinned WebKit lacks the `Intl.Segmenter` surrogate-boundary fix needed by that suite. -The complete fake-timer lane, plugin and proxy retention tests, and Control UI -support Bun. UI retains its six GC assertions in `chat-pane-retention.test.ts`, -`chat-thread-retention.test.ts`, and `usage-page-retention.test.ts`; they use -runtime-neutral collection and WeakRef checks. V8-specific heap and worker-limit -assertions and the remaining qualified Node-only selections still run on Node. +The complete fake-timer lane, plugin and proxy retention tests, and other +Control UI tests support Bun. Control UI WeakRef-collection proofs in +`desktop-mobile-keyboard.test.ts`, `chat-pane-retention.test.ts`, +`chat-thread-retention.test.ts`, `session-snapshot-store.test.ts`, and +`usage-page-retention.test.ts` stay on Node because JavaScriptCore's +conservative stack scanning can keep an unreachable target alive after a forced +collection. V8-specific heap and worker-limit assertions and the remaining +qualified Node-only selections still run on Node. The missing-Docker test also runs on Bun, using an empty executable directory instead of an empty `PATH`, which Bun resolves through its default search path. Other families retain Node until they pass on the pinned fork within their diff --git a/scripts/lib/ci-test-runtime.mts b/scripts/lib/ci-test-runtime.mts index b072e4928e9f..c1cd78e04db2 100644 --- a/scripts/lib/ci-test-runtime.mts +++ b/scripts/lib/ci-test-runtime.mts @@ -210,7 +210,15 @@ const runtimePartitions = new Map< globSync(controlUiTestGlobs, { cwd, exclude: controlUiE2eTestGlobs }) .map((file) => file.replaceAll("\\", "/")) .toSorted(), - nodeRequired: new Set(), + // collectGarbageForTest needs V8's precise collection: JavaScriptCore's + // conservative stack scanning can retain unreachable WeakRef targets. + nodeRequired: new Set([ + "ui/src/components/desktop/desktop-mobile-keyboard.test.ts", + "ui/src/pages/chat/chat-pane-retention.test.ts", + "ui/src/pages/chat/chat-thread-retention.test.ts", + "ui/src/pages/chat/session-snapshot-store.test.ts", + "ui/src/pages/usage/usage-page-retention.test.ts", + ]), includeAfterShard: true, }, ], diff --git a/test/scripts/ci-run-node-test-shard.test.ts b/test/scripts/ci-run-node-test-shard.test.ts index 6c32d7fdc017..dd0503d5c711 100644 --- a/test/scripts/ci-run-node-test-shard.test.ts +++ b/test/scripts/ci-run-node-test-shard.test.ts @@ -89,6 +89,13 @@ const gatewayClientTarget = "src/gateway/talk/handlers/client-native-control.tes const memoryConfig = "test/vitest/vitest.extension-memory.config.ts"; const memoryTarget = "extensions/memory-lancedb/config.test.ts"; const memoryIncludes = ["extensions/memory-lancedb", "extensions/memory-wiki"]; +const uiNodeFiles = [ + "ui/src/components/desktop/desktop-mobile-keyboard.test.ts", + "ui/src/pages/chat/chat-pane-retention.test.ts", + "ui/src/pages/chat/chat-thread-retention.test.ts", + "ui/src/pages/chat/session-snapshot-store.test.ts", + "ui/src/pages/usage/usage-page-retention.test.ts", +]; function makeScratchDir(): string { const dir = mkdtempSync(path.join(tmpdir(), "openclaw-shard-test-")); @@ -270,12 +277,8 @@ describe("scripts/ci-run-node-test-shard.mts", () => { "preserves selected UI discovery before runtime partitioning under %s", async (policy) => { vi.spyOn(groupOwner, "shouldUseDetachedVitestProcessGroup").mockReturnValue(true); - const includePatterns = [ - "ui/src/pages/chat/chat-pane-history.test.ts", - "ui/src/pages/chat/chat-pane-retention.test.ts", - "ui/src/pages/chat/chat-thread-retention.test.ts", - "ui/src/pages/usage/usage-page-retention.test.ts", - ]; + const bunFile = "ui/src/pages/chat/chat-pane-history.test.ts"; + const includePatterns = [bunFile, ...uiNodeFiles]; const seen: Array<{ runtime: string | undefined; membership?: string[] }> = []; await expect( runShardPlans( @@ -307,10 +310,11 @@ describe("scripts/ci-run-node-test-shard.mts", () => { }, ), ).resolves.toBe(0); - expect(seen).toEqual([ - ...(policy === "dual" ? [{ runtime: "node", membership: undefined }] : []), - { runtime: "bun", membership: includePatterns }, - ]); + const expected = [ + { runtime: "node", membership: policy === "dual" ? undefined : uiNodeFiles }, + { runtime: "bun", membership: [bunFile] }, + ]; + expect(seen).toEqual(policy === "dual" ? expected : expected.toReversed()); }, ); @@ -1298,11 +1302,11 @@ describe("scripts/ci-run-node-test-shard.mts", () => { allocatorInterval: "1000", }, ]; - expect(seen).toEqual(policy === "dual" ? expected : expected.slice(1)); + expect(seen).toEqual(policy === "dual" ? expected : expected.toReversed()); }, ); - it("runs qualified UI only on Bun without joined process ownership", async () => { + it("retains Node-first UI execution without joined process ownership", async () => { vi.spyOn(groupOwner, "shouldUseDetachedVitestProcessGroup").mockReturnValue(false); const seen: string[] = []; await expect( @@ -1316,7 +1320,7 @@ describe("scripts/ci-run-node-test-shard.mts", () => { }, }), ).resolves.toBe(0); - expect(seen).toEqual(["bun"]); + expect(seen).toEqual(["node", "bun"]); }); it.each([ @@ -1336,17 +1340,8 @@ describe("scripts/ci-run-node-test-shard.mts", () => { vi.spyOn(groupOwner, "shouldUseDetachedVitestProcessGroup").mockReturnValue(true); const seen: string[] = []; let receiptFile: string | undefined; - const nodeFile = "ui/src/synthetic-node-only.test.ts"; + const nodeFile = "ui/src/pages/usage/usage-page-retention.test.ts"; const bunFile = "ui/src/pages/chat/chat-pane-history.test.ts"; - // All current UI files qualify for Bun; preserve the receipt transport's - // fallback coverage with an explicit synthetic mixed-runtime partition. - vi.spyOn( - await import("../../scripts/lib/ci-test-runtime.mts"), - "resolveCiTestRuntimeSelections", - ).mockReturnValue([ - { runtime: "node", includePatterns: [nodeFile], includeAfterShard: true }, - { runtime: "bun", includePatterns: [bunFile], includeAfterShard: true }, - ]); await expect( runShardPlans([{ kind: "group", name: "ui", plan: { configs: ["ui/vitest.config.ts"] } }], { env: { OPENCLAW_CI_TEST_RUNTIME_POLICY: "bun-compatible" }, @@ -1357,7 +1352,7 @@ describe("scripts/ci-run-node-test-shard.mts", () => { const included = JSON.parse( readFileSync(env.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE!, "utf8"), ); - expect(included).toEqual([nodeFile]); + expect(included).toEqual(uiNodeFiles); return 0; } receiptFile = env.OPENCLAW_VITEST_NATIVE_SHARD_RECEIPT; diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index cb3356364df8..f3131501d80e 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -5233,7 +5233,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" frozenTarget: false, compatibilityTarget: false, policy: "bun-compatible", - runtimes: ["bun"], + runtimes: ["bun", "node"], shards: [1, 2, 3], }, { @@ -5416,18 +5416,31 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(childEnv.OPENCLAW_VITEST_INCLUDE_FILE).toBeUndefined(); } const includeFile = childEnv.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE; - if (childEnv.OPENCLAW_VITEST_RUNTIME === "bun") { + if ( + childEnv.OPENCLAW_VITEST_RUNTIME === "bun" || + scenario.policy === "bun-compatible" + ) { expect(includeFile).toBeTruthy(); const included = JSON.parse(readFileSync(includeFile!, "utf8")); - const retentionFiles = [ + const nodeFiles = [ + "ui/src/components/desktop/desktop-mobile-keyboard.test.ts", "ui/src/pages/chat/chat-pane-retention.test.ts", "ui/src/pages/chat/chat-thread-retention.test.ts", + "ui/src/pages/chat/session-snapshot-store.test.ts", "ui/src/pages/usage/usage-page-retention.test.ts", ]; - expect(included.length).toBeGreaterThan(1000); - expect(included).toEqual(expect.arrayContaining(retentionFiles)); - if (uiGroups[0]?.includePatterns) { - expect(included.toSorted()).toEqual(uiGroups[0].includePatterns.toSorted()); + if (childEnv.OPENCLAW_VITEST_RUNTIME === "node") { + expect(included.toSorted()).toEqual(nodeFiles); + } else { + expect(included.length).toBeGreaterThan(1000); + expect(included.filter((file: string) => nodeFiles.includes(file))).toEqual([]); + if (uiGroups[0]?.includePatterns) { + expect(included.toSorted()).toEqual( + uiGroups[0].includePatterns + .filter((file) => !nodeFiles.includes(file)) + .toSorted(), + ); + } } } else { expect(includeFile).toBeUndefined(); diff --git a/test/vitest-ui-package-config.test.ts b/test/vitest-ui-package-config.test.ts index f08f8ef7301a..33c5a34fe769 100644 --- a/test/vitest-ui-package-config.test.ts +++ b/test/vitest-ui-package-config.test.ts @@ -165,6 +165,13 @@ describe("ui package vitest config", () => { projectOrder: { native: string[]; actual: string[] }; }; }; + const nodeFiles = new Set([ + "ui/src/components/desktop/desktop-mobile-keyboard.test.ts", + "ui/src/pages/chat/chat-pane-retention.test.ts", + "ui/src/pages/chat/chat-thread-retention.test.ts", + "ui/src/pages/chat/session-snapshot-store.test.ts", + "ui/src/pages/usage/usage-page-retention.test.ts", + ]); expect(report.discovered.length).toBeGreaterThan(1000); // Package isolated/timing/Chromium projects retain their separate owners. expect(report.rootNodeFiles).toEqual(report.packageNodeFiles); @@ -234,7 +241,7 @@ describe("ui package vitest config", () => { .toSorted(), ).toEqual(report.discovered); for (const row of report.rows) { - expect(row.receipts).toHaveLength(3); + expect(row.receipts).toHaveLength(4); for (const { requestId, value } of row.receipts) { expect(value).toEqual({ version: 1, @@ -247,10 +254,13 @@ describe("ui package vitest config", () => { expect(value.files.toSorted()).toEqual(row.original); } const compatible = row.selected["bun-compatible"]!; - expect(compatible).toEqual([{ runtime: "bun", files: row.original }]); + expect(compatible.map((selection) => selection.runtime)).toEqual(["node", "bun"]); + expect(compatible[0]!.files).toEqual(row.original.filter((file) => nodeFiles.has(file))); + expect(compatible[1]!.files).toEqual(row.original.filter((file) => !nodeFiles.has(file))); + expect(compatible.flatMap((selection) => selection.files).toSorted()).toEqual(row.original); expect(row.selected.dual).toEqual([ { runtime: "node", files: row.original }, - compatible[0], + compatible[1], ]); } })); diff --git a/ui/src/pages/usage/usage-page-retention.test.ts b/ui/src/pages/usage/usage-page-retention.test.ts index 5a7cdba8bfe8..1f9986e99aca 100644 --- a/ui/src/pages/usage/usage-page-retention.test.ts +++ b/ui/src/pages/usage/usage-page-retention.test.ts @@ -99,22 +99,32 @@ describe("UsagePage detail requests", () => { }); it("releases a loaded overview when its Gateway identity is replaced", async () => { - const result = { - ...cacheSnapshot("fresh").result, - sessions: [{ key: "agent:main:overview-lifetime", usage: null }], + class OverviewPayload { + key = "agent:main:overview-lifetime"; + usage = null; + } + let payload: WeakRef | undefined; + const snapshot = cacheSnapshot("fresh"); + const request = async (method: string) => { + if (method === "sessions.usage") { + const report = new OverviewPayload(); + payload = new WeakRef(report); + return { ...snapshot.result, sessions: [report] }; + } + return { providers: [] }; }; - const request = async (method: string) => - method === "sessions.usage" ? result : { providers: [] }; const page = await createPage({ request } as unknown as GatewayBrowserClient); await page.loadUsage(); - expect(page).toHaveProperty("usageSnapshot.result", result); + expect(payload).toBeDefined(); page.context = contextWithClient({ request: async () => ({}), } as unknown as GatewayBrowserClient); page.requestUpdate(); await page.updateComplete; - // Check the display owner's strong reference, independent of GC scheduling. - expect(page).toHaveProperty("usageSnapshot", null); + const collectionControl = new WeakRef({ unowned: true }); + await collectGarbageForTest(); + expect(collectionControl.deref()).toBeUndefined(); + expect(payload!.deref()).toBeUndefined(); expect(page.isConnected).toBe(true); }); }); diff --git a/ui/src/test-helpers/garbage-collection.ts b/ui/src/test-helpers/garbage-collection.ts index 55971f59f63e..0166a74d1b31 100644 --- a/ui/src/test-helpers/garbage-collection.ts +++ b/ui/src/test-helpers/garbage-collection.ts @@ -1,21 +1,14 @@ -import { setImmediate as scheduleImmediate } from "node:timers"; import { setImmediate } from "node:timers/promises"; -declare const Bun: { gc(force: boolean): void }; - export async function collectGarbageForTest(): Promise { - // WeakRef targets stay alive for the current job, even without a strong owner. if (process.versions.bun) { - // Collect outside JavaScriptCore's promise-microtask drain, whose stack - // can otherwise keep settled async values alive. - await new Promise((resolve) => { - scheduleImmediate(() => { - Bun.gc(true); - resolve(); - }); - }); - return; + throw new Error( + "collectGarbageForTest needs V8's precise collection: JavaScriptCore scans stacks " + + "conservatively, so a forced Bun GC cannot prove a WeakRef target is unreachable. " + + "Add this file to the ui/vitest.config.ts nodeRequired set in scripts/lib/ci-test-runtime.mts.", + ); } + // WeakRef targets stay alive for the current job, even without a strong owner. await setImmediate(); const { Session } = await import("node:inspector"); const session = new Session();