diff --git a/docs/reference/test.md b/docs/reference/test.md index 20d4ba461c41..6e671433871a 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -174,7 +174,7 @@ If `pnpm test` flakes on a loaded host, rerun once before treating it as a regre - `pnpm test:perf:imports`: enables Vitest import-duration + import-breakdown reporting, while still using scoped lane routing for explicit file/directory targets. `pnpm test:perf:imports:changed` scopes the same profiling to files changed since `origin/main`. - `pnpm test:perf:changed:bench -- --ref ` benchmarks the routed changed-mode path against the native root-project run for the same committed git diff; `pnpm test:perf:changed:bench -- --worktree` benchmarks the current worktree change set without committing first. - `pnpm test:perf:profile:main` writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`); `pnpm test:perf:profile:runner` writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`). -- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. The Test Performance Agent uses this as its baseline before attempting slow-test fixes. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change. +- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. Full-suite reports isolate files by default so retained module graphs and GC pauses from earlier files are not charged to later assertions; pass `-- --no-isolate` only when intentionally profiling shared-worker accumulation. The Test Performance Agent uses this as its baseline before attempting slow-test fixes. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change. - Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact. ## Benchmarks diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index c7cb4802d760..b0e64d6230e7 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -2839,10 +2839,10 @@ describe("active-memory plugin", () => { it("returns partial transcript text on timeout when the subagent has already written assistant output", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); - testing.setTimeoutPartialDataGraceMsForTests(200); + testing.setTimeoutPartialDataGraceMsForTests(50); api.pluginConfig = { agents: ["main"], - timeoutMs: 1_000, + timeoutMs: 100, maxSummaryChars: 40, persistTranscripts: true, logging: true, diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index 2d8ae8368b14..d3b767948a43 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -242,7 +242,7 @@ describe("startCodexAttemptThread", () => { }); it("clears the shared app-server when startup abandons an in-flight thread request", async () => { - const { harness, run } = startThreadWithHarness(2_000); + const { harness, run } = startThreadWithHarness(500); const runError = run.then( () => undefined, (error: unknown) => error, @@ -253,7 +253,7 @@ describe("startCodexAttemptThread", () => { const error = await runError; await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), { interval: 1, - timeout: 2_000, + timeout: 1_000, }); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("codex app-server startup timed out"); @@ -290,7 +290,7 @@ describe("startCodexAttemptThread", () => { }); it("closes the shared app-server when startup times out during initialize", async () => { - const { harness, run } = startThreadWithHarness(2_000); + const { harness, run } = startThreadWithHarness(500); const runError = run.then( () => undefined, (error: unknown) => error, @@ -304,7 +304,7 @@ describe("startCodexAttemptThread", () => { expect((error as Error).message).toBe("codex app-server startup timed out"); await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), { interval: 1, - timeout: 2_000, + timeout: 1_000, }); expect( readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"), diff --git a/extensions/codex/src/app-server/provider-capabilities.test.ts b/extensions/codex/src/app-server/provider-capabilities.test.ts index ed1550e2839c..8a54b181cbb4 100644 --- a/extensions/codex/src/app-server/provider-capabilities.test.ts +++ b/extensions/codex/src/app-server/provider-capabilities.test.ts @@ -85,6 +85,7 @@ describe("resolveCodexProviderWebSearchSupport", () => { await expect(resolveSupport(clientFactory, "amazon-bedrock")).resolves.toBe("unsupported"); await expect(resolveSupport(clientFactory, "custom-provider")).resolves.toBe("unsupported"); + await expect(resolveSupport(clientFactory, "lmstudio")).resolves.toBe("unsupported"); expect(request).not.toHaveBeenCalled(); }); }); diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 3ac64d7be4c4..65c596c29498 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -5657,41 +5657,6 @@ describe("runCodexAppServerAttempt", () => { expect(turnRequestParams?.approvalsReviewer).toBe("user"); }); - it("keeps managed web_search for provider-qualified Codex model overrides", async () => { - testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("web_search")]); - const sessionFile = path.join(tempDir, "session.jsonl"); - const workspaceDir = path.join(tempDir, "workspace"); - const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => { - if (method === "modelProvider/capabilities/read") { - return { webSearch: true }; - } - return undefined; - }); - const params = createParams(sessionFile, workspaceDir); - params.disableTools = false; - params.runtimePlan = createCodexRuntimePlanFixture(); - params.modelId = "lmstudio/local-model"; - - const run = runCodexAppServerAttempt(params); - await waitForMethod("turn/start"); - await completeTurn({ threadId: "thread-1", turnId: "turn-1" }); - await run; - - expect(requests.map((request) => request.method)).not.toContain( - "modelProvider/capabilities/read", - ); - const startRequest = requests.find((request) => request.method === "thread/start"); - const startRequestParams = startRequest?.params as Record | undefined; - const startConfig = startRequestParams?.config as Record | undefined; - const dynamicToolNames = specNames( - (startRequestParams?.dynamicTools as CodexDynamicToolSpec[] | undefined) ?? [], - ); - expect(startRequestParams?.model).toBe("local-model"); - expect(startRequestParams?.modelProvider).toBe("lmstudio"); - expect(startConfig?.web_search).toBe("disabled"); - expect(dynamicToolNames).toContain("web_search"); - }); - it("uses bound local model providers when disabling Guardian on resumed threads", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/copilot/src/byok-proxy.ts b/extensions/copilot/src/byok-proxy.ts index 0847d940cb76..62f45c8b89ff 100644 --- a/extensions/copilot/src/byok-proxy.ts +++ b/extensions/copilot/src/byok-proxy.ts @@ -77,6 +77,9 @@ export async function createCopilotByokProxy( } await new Promise((resolve) => { server.close(() => resolve()); + // The proxy is private and ephemeral. Do not let a client's keep-alive + // socket delay shutdown after every upstream request has been aborted. + server.closeAllConnections(); }); }, }; diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts index 6f29adf4b5d5..f031a7e7edeb 100644 --- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -220,6 +220,10 @@ describe("discord native /think autocomplete", () => { }); ({ findCommandByNativeName, resolveCommandArgChoices, resolveDiscordNativeChoiceContext } = await loadDiscordThinkAutocompleteModulesForTest()); + + // Compile the provider-backed default choice context outside per-case timing. + const { command, levelArg } = requireThinkLevelCommand(); + resolveCommandArgChoices({ command, arg: levelArg, cfg: createConfig(), catalog: [] }); }); beforeEach(async () => { diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts index d71bdd3682fd..23e63998d6b3 100644 --- a/extensions/imessage/src/send.test.ts +++ b/extensions/imessage/src/send.test.ts @@ -963,19 +963,13 @@ describe("sendMessageIMessage receipts", () => { }); it("does not use the local default chat.db path for custom cliPath wrappers", async () => { + vi.useFakeTimers({ now: 1_000 }); vi.stubEnv("HOME", "/Users/me"); const client = createRejectingClient(new Error("imsg rpc timeout (send)")); const runCliJson = vi.fn(); const resolveSentMessageGuidImpl = vi.fn(async () => null); const approvalText = createApprovalText("approval-remote"); - const nowSpy = vi.spyOn(Date, "now"); - nowSpy - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(6_001); - - await expect( + const rejection = expect( sendMessageIMessage("chat_id:42", approvalText, { config: { channels: { @@ -994,6 +988,8 @@ describe("sendMessageIMessage receipts", () => { resolveSentMessageGuidImpl, }), ).rejects.toThrow("imsg rpc timeout (send)"); + await vi.runAllTimersAsync(); + await rejection; expect(runCliJson).not.toHaveBeenCalled(); expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({ @@ -1005,6 +1001,7 @@ describe("sendMessageIMessage receipts", () => { }); it("does not use the local default chat.db path for auto-detected ssh wrappers", async () => { + vi.useFakeTimers({ now: 1_000 }); vi.stubEnv("HOME", "/Users/me"); const wrapperDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-wrapper-")); const wrapperPath = path.join(wrapperDir, "imsg"); @@ -1013,15 +1010,8 @@ describe("sendMessageIMessage receipts", () => { const runCliJson = vi.fn(); const resolveSentMessageGuidImpl = vi.fn(async () => null); const approvalText = createApprovalText("approval-ssh-wrapper"); - const nowSpy = vi.spyOn(Date, "now"); - nowSpy - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(6_001); - try { - await expect( + const rejection = expect( sendMessageIMessage("chat_id:42", approvalText, { config: IMESSAGE_TEST_CFG, client, @@ -1030,6 +1020,8 @@ describe("sendMessageIMessage receipts", () => { resolveSentMessageGuidImpl, }), ).rejects.toThrow("imsg rpc timeout (send)"); + await vi.runAllTimersAsync(); + await rejection; } finally { fs.rmSync(wrapperDir, { recursive: true, force: true }); } @@ -1063,17 +1055,11 @@ describe("sendMessageIMessage receipts", () => { }); it("throws the rpc timeout without resending when sent-row recovery misses", async () => { + vi.useFakeTimers({ now: 1_000 }); const client = createRejectingClient(new Error("imsg rpc timeout (send)")); const runCliJson = vi.fn(); const resolveSentMessageGuidImpl = vi.fn(async () => null); - const nowSpy = vi.spyOn(Date, "now"); - nowSpy - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(6_001); - - await expect( + const rejection = expect( sendMessageIMessage("chat_id:42", "hello", { config: IMESSAGE_TEST_CFG, createClient: async () => client, @@ -1082,23 +1068,19 @@ describe("sendMessageIMessage receipts", () => { resolveSentMessageGuidImpl, }), ).rejects.toThrow("imsg rpc timeout (send)"); + await vi.runAllTimersAsync(); + await rejection; expect(getClientMocks(client).stop).toHaveBeenCalledTimes(1); expect(runCliJson).not.toHaveBeenCalled(); }); it("does not stop caller-owned rpc clients after sent-row recovery misses", async () => { + vi.useFakeTimers({ now: 1_000 }); const client = createRejectingClient(new Error("imsg rpc timeout (send)")); const runCliJson = vi.fn(); const resolveSentMessageGuidImpl = vi.fn(async () => null); - const nowSpy = vi.spyOn(Date, "now"); - nowSpy - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(6_001); - - await expect( + const rejection = expect( sendMessageIMessage("chat_id:42", "hello", { config: IMESSAGE_TEST_CFG, client, @@ -1107,6 +1089,8 @@ describe("sendMessageIMessage receipts", () => { resolveSentMessageGuidImpl, }), ).rejects.toThrow("imsg rpc timeout (send)"); + await vi.runAllTimersAsync(); + await rejection; expect(runCliJson).not.toHaveBeenCalled(); expect(getClientMocks(client).stop).not.toHaveBeenCalled(); @@ -1130,18 +1114,12 @@ describe("sendMessageIMessage receipts", () => { }); it("throws the rpc timeout without resending when approval GUID recovery misses", async () => { + vi.useFakeTimers({ now: 1_000 }); const client = createRejectingClient(new Error("imsg rpc timeout (send)")); const runCliJson = vi.fn(); const resolveSentMessageGuidImpl = vi.fn(async () => null); const approvalText = createApprovalText(); - const nowSpy = vi.spyOn(Date, "now"); - nowSpy - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(6_001); - - await expect( + const rejection = expect( sendMessageIMessage("chat_id:42", approvalText, { config: IMESSAGE_TEST_CFG, client, @@ -1150,6 +1128,8 @@ describe("sendMessageIMessage receipts", () => { resolveSentMessageGuidImpl, }), ).rejects.toThrow("imsg rpc timeout (send)"); + await vi.runAllTimersAsync(); + await rejection; expect(runCliJson).not.toHaveBeenCalled(); expect(resolveSentMessageGuidImpl).toHaveBeenCalled(); diff --git a/extensions/imessage/src/status.test.ts b/extensions/imessage/src/status.test.ts index 23ae08f1f4be..6187a52deecc 100644 --- a/extensions/imessage/src/status.test.ts +++ b/extensions/imessage/src/status.test.ts @@ -21,6 +21,14 @@ const getIMessageSetupStatus = createPluginSetupWizardStatus({ } as never); const spawnMock = vi.hoisted(() => vi.fn()); +const setupToolsMocks = vi.hoisted(() => ({ + detectBinary: vi.fn(async () => false), +})); + +vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => ({ + ...(await importOriginal()), + ...setupToolsMocks, +})); function createMockChildProcess() { const child = new EventEmitter() as EventEmitter & { @@ -182,6 +190,10 @@ describe("createIMessageRpcClient", () => { }); describe("imessage setup status", () => { + beforeEach(() => { + setupToolsMocks.detectBinary.mockClear(); + }); + it("does not inherit configured state from a sibling account", async () => { const result = await getIMessageSetupStatus({ cfg: { diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index f0c6eccbac84..c8dde3d59a32 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -45,7 +45,9 @@ class FakeWebSocket { close(): void {} - terminate(): void {} + terminate(): void { + this.emitClose(1000); + } get openListenerCount(): number { return this.openListeners.length; @@ -107,7 +109,8 @@ vi.mock("./draft-stream.js", () => ({ createMattermostDraftStream: mockState.createMattermostDraftStream, })); -vi.mock("./monitor-resources.js", () => ({ +vi.mock("./monitor-resources.js", async (importOriginal) => ({ + ...(await importOriginal()), createMattermostMonitorResources: () => ({ resolveMattermostMedia: mockState.resolveMattermostMedia, sendTypingIndicator: vi.fn(async () => {}), @@ -419,7 +422,7 @@ describe("mattermost inbound user posts", () => { channel_display_name: "Town Square", sender_name: "alice", post: JSON.stringify({ - id: "post-1", + id: "post-inbound-system-event-regular", channel_id: "chan-1", user_id: "user-1", message: "hello from mattermost", @@ -439,7 +442,7 @@ describe("mattermost inbound user posts", () => { const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; expect(ctx?.BodyForAgent).toBe("hello from mattermost"); expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1"); - expect(ctx?.MessageSid).toBe("post-1"); + expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular"); expect(ctx?.OriginatingChannel).toBe("mattermost"); expect(ctx?.Provider).toBe("mattermost"); }); diff --git a/extensions/qa-lab/src/test-file-scenario-runner.test.ts b/extensions/qa-lab/src/test-file-scenario-runner.test.ts index 072373eca4a7..4429e4490386 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.test.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { validateQaEvidenceSummaryJson } from "./evidence-summary.js"; import { readQaScenarioById, type QaSeedScenarioWithSource } from "./scenario-catalog.js"; import { createTempDirHarness } from "./temp-dir.test-helper.js"; @@ -149,6 +149,7 @@ async function writeScriptProducerEvidence(params: { describe("qa test file scenario runner", () => { afterEach(async () => { + qaTestFileScenarioRunnerTesting.resetTimeoutCleanupTimings(); await Promise.all([ cleanupTempDirs(), ...tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), @@ -494,17 +495,15 @@ describe("qa test file scenario runner", () => { expect(commands.map((command) => command.timeoutMs)).toEqual([3 * 60 * 60_000]); }); - it("times out script scenarios and kills descendant process groups", async () => { - if (process.platform === "win32") { - return; - } - - const repoRoot = process.cwd(); - const tempRoot = await makeTempDir("qa-script-timeout-"); - const scriptPath = path.join(tempRoot, "hanging-producer.ts"); - const descendantPidPath = path.join(tempRoot, "descendant.pid"); + describe.skipIf(process.platform === "win32")("script timeout process groups", () => { + const commandTimeoutMs = 1_500; let descendantPid: number | undefined; - try { + let result: Awaited>; + + beforeAll(async () => { + const tempRoot = await makeTempDir("qa-script-timeout-"); + const scriptPath = path.join(tempRoot, "hanging-producer.ts"); + const descendantPidPath = path.join(tempRoot, "descendant.pid"); const descendantScript = [ "process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);", @@ -523,29 +522,39 @@ describe("qa test file scenario runner", () => { "utf8", ); - const commandTimeoutMs = 3_000; + qaTestFileScenarioRunnerTesting.setTimeoutCleanupTimings({ + forceSettleMs: 25, + killGraceMs: 50, + }); const run = runQaTestFileScenarios({ - repoRoot, + repoRoot: process.cwd(), outputDir: path.join(tempRoot, "out"), providerMode: "mock-openai", primaryModel: "mock-openai/gpt-5.5", scenarios: [makeTestFileScenario("script", scriptPath)], commandTimeoutMs, }); - descendantPid = await readPid(descendantPidPath, commandTimeoutMs - 250); + descendantPid = await readPid(descendantPidPath, commandTimeoutMs); + result = await run; + await waitForDead(descendantPid, 2_000); + }); - const result = await run; + afterAll(() => { + if (descendantPid !== undefined && isProcessRunning(descendantPid)) { + process.kill(descendantPid, "SIGKILL"); + } + }); + it("times out script scenarios and kills descendant process groups", () => { expect(result.results[0]?.status).toBe("fail"); expect(result.results[0]?.failureMessage).toMatch( new RegExp(`timed out after ${commandTimeoutMs}ms`, "u"), ); - await waitForDead(descendantPid, 2_000); - } finally { - if (descendantPid !== undefined && isProcessRunning(descendantPid)) { - process.kill(descendantPid, "SIGKILL"); + if (descendantPid === undefined) { + throw new Error("descendant pid was not captured"); } - } + expect(isProcessRunning(descendantPid)).toBe(false); + }); }); it("force-kills Windows scenario command trees when graceful taskkill fails", () => { @@ -1062,64 +1071,71 @@ describe("qa test file scenario runner", () => { expect(artifactPath?.includes("..")).toBe(false); }); - it("runs the UX Matrix script producer and imports its evidence bundle", async () => { - const repoRoot = process.cwd(); - const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-ux-matrix-script-")); - tempRoots.push(outputDir); - const scenario = readQaScenarioById("ux-matrix-evidence-dashboard"); + describe("UX Matrix scenario composition", () => { + let outputDir: string; + let result: Awaited>; + let evidence: ReturnType; - expect(scenario.execution.kind).toBe("script"); - const result = await runQaTestFileScenarios({ - repoRoot, - outputDir, - providerMode: "mock-openai", - primaryModel: "mock-openai/gpt-5.5", - scenarios: [scenario], - env: { - OPENCLAW_QA_REF: "scenario-ref", - } as NodeJS.ProcessEnv, + beforeAll(async () => { + outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-ux-matrix-script-")); + tempRoots.push(outputDir); + const scenario = readQaScenarioById("ux-matrix-evidence-dashboard"); + + expect(scenario.execution.kind).toBe("script"); + result = await runQaTestFileScenarios({ + repoRoot: process.cwd(), + outputDir, + providerMode: "mock-openai", + primaryModel: "mock-openai/gpt-5.5", + scenarios: [scenario], + env: { + OPENCLAW_QA_REF: "scenario-ref", + } as NodeJS.ProcessEnv, + }); + evidence = validateQaEvidenceSummaryJson( + JSON.parse(await fs.readFile(result.evidencePath, "utf8")), + ); }); - expect(result.executionKind).toBe("script"); - expect(result.results[0]?.producerEvidence?.entries).toHaveLength(3); - const evidence = validateQaEvidenceSummaryJson( - JSON.parse(await fs.readFile(result.evidencePath, "utf8")), - ); - expect(evidence.entries.map((entry) => entry.test.id)).toEqual([ - "ux-matrix.qa-lab.producer-artifact-fixture", - "ux-matrix.control-ui.screenshot-artifact", - "ux-matrix.cli.entrypoint-help", - ]); - expect( - evidence.entries.flatMap((entry) => entry.coverage.map((coverage) => coverage.id)), - ).toEqual( - expect.arrayContaining([ - "qa.artifact-safety", - "tools.evidence", - "workspace.artifacts", - "ui.control", - "gateway.control-ui-hosting", - "cli.entrypoint", - "cli.status-snapshots", - ]), - ); - const artifactKinds = evidence.entries.flatMap( - (entry) => entry.execution?.artifacts.map((artifact) => artifact.kind) ?? [], - ); - expect(artifactKinds).toEqual(expect.arrayContaining(["html", "log"])); - const fixtureEntry = evidence.entries.find( - (entry) => entry.test.id === "ux-matrix.qa-lab.producer-artifact-fixture", - ); - expect(fixtureEntry?.execution?.artifacts.map((artifact) => artifact.path)).toContain( - path.join( - outputDir, - "ux-matrix-evidence-dashboard", - "surfaces", - "qa-lab", - "stages", - "producer-artifact-fixture", - "producer-artifact-fixture.html", - ), - ); + it("runs the checked-in producer and imports its evidence bundle", () => { + expect(result.executionKind).toBe("script"); + expect(result.results[0]?.producerEvidence?.entries).toHaveLength(3); + expect(evidence.entries.map((entry) => entry.test.id)).toEqual([ + "ux-matrix.qa-lab.producer-artifact-fixture", + "ux-matrix.control-ui.screenshot-artifact", + "ux-matrix.cli.entrypoint-help", + ]); + expect( + evidence.entries.flatMap((entry) => entry.coverage.map((coverage) => coverage.id)), + ).toEqual( + expect.arrayContaining([ + "qa.artifact-safety", + "tools.evidence", + "workspace.artifacts", + "ui.control", + "gateway.control-ui-hosting", + "cli.entrypoint", + "cli.status-snapshots", + ]), + ); + const artifactKinds = evidence.entries.flatMap( + (entry) => entry.execution?.artifacts.map((artifact) => artifact.kind) ?? [], + ); + expect(artifactKinds).toEqual(expect.arrayContaining(["html", "log"])); + const fixtureEntry = evidence.entries.find( + (entry) => entry.test.id === "ux-matrix.qa-lab.producer-artifact-fixture", + ); + expect(fixtureEntry?.execution?.artifacts.map((artifact) => artifact.path)).toContain( + path.join( + outputDir, + "ux-matrix-evidence-dashboard", + "surfaces", + "qa-lab", + "stages", + "producer-artifact-fixture", + "producer-artifact-fixture.html", + ), + ); + }); }); }); diff --git a/extensions/qa-lab/src/test-file-scenario-runner.ts b/extensions/qa-lab/src/test-file-scenario-runner.ts index 1fd01e41d18e..08031ed6078b 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.ts @@ -96,6 +96,8 @@ type QaTestFileRunnerDefinition = { const DEFAULT_QA_TEST_FILE_COMMAND_TIMEOUT_MS = 30 * 60_000; const QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS = 2_000; const QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS = 500; +let qaTestFileCommandTimeoutKillGraceMs = QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS; +let qaTestFileCommandTimeoutForceSettleMs = QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS; const QA_TEST_FILE_COMMAND_PARENT_SIGNALS = ["SIGINT", "SIGTERM"] as const; export function isQaTestFileScenario( @@ -345,8 +347,8 @@ function runQaScenarioCommand( signal: result.signal, ...(failureMessage ? { failureMessage } : {}), }); - }, QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS); - }, QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS); + }, qaTestFileCommandTimeoutForceSettleMs); + }, qaTestFileCommandTimeoutKillGraceMs); }; timeoutTimer = timeoutMs === undefined @@ -826,4 +828,12 @@ export async function runQaTestFileScenarios( export const qaTestFileScenarioRunnerTesting = { killQaScenarioWindowsProcessTree, + resetTimeoutCleanupTimings() { + qaTestFileCommandTimeoutKillGraceMs = QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS; + qaTestFileCommandTimeoutForceSettleMs = QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS; + }, + setTimeoutCleanupTimings(params: { forceSettleMs: number; killGraceMs: number }) { + qaTestFileCommandTimeoutKillGraceMs = params.killGraceMs; + qaTestFileCommandTimeoutForceSettleMs = params.forceSettleMs; + }, }; diff --git a/extensions/qqbot/src/engine/api/media-chunked.test.ts b/extensions/qqbot/src/engine/api/media-chunked.test.ts index 175608105a5e..f86efcde0731 100644 --- a/extensions/qqbot/src/engine/api/media-chunked.test.ts +++ b/extensions/qqbot/src/engine/api/media-chunked.test.ts @@ -12,10 +12,7 @@ import { type UploadPrepareResponse, } from "../types.js"; import type { ApiClient } from "./api-client.js"; -import { - ChunkedMediaApi, - UploadDailyLimitExceededError, -} from "./media-chunked.js"; +import { ChunkedMediaApi, UploadDailyLimitExceededError } from "./media-chunked.js"; import type { UploadCacheAdapter } from "./media.js"; import { UPLOAD_PREPARE_FALLBACK_CODE } from "./retry.js"; import type { TokenManager } from "./token.js"; @@ -142,6 +139,7 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => { }); afterEach(() => { + vi.useRealTimers(); globalThis.fetch = originalFetch; fetchWithSsrFGuardMock.mockReset(); vi.restoreAllMocks(); @@ -282,6 +280,7 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => { }); it("bounds COS PUT error bodies without using response.text()", async () => { + vi.useFakeTimers(); const client = mockApiClient(); const tm = mockTokenManager(); const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn() }; @@ -324,18 +323,17 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => { }); const api = new ChunkedMediaApi(client, tm, { logger }); - let error: unknown; - try { - await api.uploadChunked({ + const upload = api + .uploadChunked({ scope: "group", targetId: "g1", fileType: MediaFileType.FILE, source: { kind: "buffer", buffer: Buffer.from("01234567"), fileName: "blob.bin" }, creds: { appId: "a", clientSecret: "s" }, - }); - } catch (caught) { - error = caught; - } + }) + .catch((error: unknown) => error); + await vi.runAllTimersAsync(); + const error = await upload; expect(String(error)).toContain("COS PUT failed: 503 Service Unavailable"); expect(String(error)).toContain("cos gateway unavailable"); diff --git a/extensions/telegram/src/webhook.test.ts b/extensions/telegram/src/webhook.test.ts index 87e07ac27787..627b9c58cb06 100644 --- a/extensions/telegram/src/webhook.test.ts +++ b/extensions/telegram/src/webhook.test.ts @@ -780,6 +780,7 @@ describe("startTelegramWebhook", () => { }); it("durably retries a webhook update after acknowledging Telegram", async () => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); const runtimeLog = vi.fn(); const seenUpdates: unknown[] = []; handleUpdateSpy.mockImplementation(async (update: unknown) => { @@ -790,39 +791,47 @@ describe("startTelegramWebhook", () => { }); const payload = JSON.stringify({ update_id: 3, message: { text: "boom" } }); - await withStartedWebhook( - { - secret: TELEGRAM_SECRET, - path: TELEGRAM_WEBHOOK_PATH, - runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() }, - }, - async ({ port }) => { - const response = await postWebhookJson({ - url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH), - payload, + try { + await withStartedWebhook( + { secret: TELEGRAM_SECRET, - }); + path: TELEGRAM_WEBHOOK_PATH, + runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() }, + }, + async ({ port }) => { + const response = await postWebhookJson({ + url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH), + payload, + secret: TELEGRAM_SECRET, + }); - expect(response.status).toBe(200); - expect(await response.text()).toBe(""); - await vi.waitFor(() => expect(seenUpdates).toEqual([JSON.parse(payload)])); - await vi.waitFor(async () => - expect( - (await listTelegramSpooledUpdates({ spoolDir: requireWebhookSpoolDir() })).length, - ).toBe(1), - ); - expectMockMessageContains(runtimeLog, "webhook spooled update 3 failed; keeping for retry"); - await sleep(1_100); - await vi.waitFor(() => - expect(seenUpdates).toEqual([JSON.parse(payload), JSON.parse(payload)]), - ); - await vi.waitFor(async () => - expect(await listTelegramSpooledUpdates({ spoolDir: requireWebhookSpoolDir() })).toEqual( - [], - ), - ); - }, - ); + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + await vi.waitFor(() => expect(seenUpdates).toEqual([JSON.parse(payload)])); + await vi.waitFor(async () => + expect( + (await listTelegramSpooledUpdates({ spoolDir: requireWebhookSpoolDir() })).length, + ).toBe(1), + ); + expectMockMessageContains( + runtimeLog, + "webhook spooled update 3 failed; keeping for retry", + ); + vi.setSystemTime(Date.now() + 1_100); + await vi.advanceTimersByTimeAsync(500); + await vi.waitFor(() => + expect(seenUpdates).toEqual([JSON.parse(payload), JSON.parse(payload)]), + ); + await vi.waitFor(async () => + expect( + await listTelegramSpooledUpdates({ spoolDir: requireWebhookSpoolDir() }), + ).toEqual([]), + ); + }, + ); + } finally { + vi.useRealTimers(); + } }); it("keeps a timed-out webhook lane guarded until replay settles", async () => { diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index 1360bab9af49..d31b2c3766fc 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -186,8 +186,12 @@ const inboundRuntimeMocks = vi.hoisted(() => { return current; } + async function* fakeMediaStream() { + yield Buffer.from("fake-media-data"); + } + return { - downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("fake-media-data")), + downloadMediaMessage: vi.fn(() => fakeMediaStream()), isJidGroup: vi.fn((jid: string | undefined | null) => typeof jid === "string" ? jid.endsWith("@g.us") : false, ), diff --git a/scripts/anthropic-prompt-probe.ts b/scripts/anthropic-prompt-probe.ts index e51bde33471b..f98164dd4e42 100644 --- a/scripts/anthropic-prompt-probe.ts +++ b/scripts/anthropic-prompt-probe.ts @@ -455,7 +455,14 @@ async function getFreePort(): Promise { }); } -async function runDirectPrompt(prompt: string): Promise { +async function runDirectPrompt( + prompt: string, + options: { + claudeBin?: string; + shutdownWaitMs?: number; + timeoutMs?: number; + } = {}, +): Promise { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-prompt-probe-")); const proxyPort = ENABLE_CAPTURE ? await getFreePort() : undefined; const proxy = @@ -466,17 +473,21 @@ async function runDirectPrompt(prompt: string): Promise { try { const stdout: string[] = []; const stderr: string[] = []; - const child = spawn(CLAUDE_BIN, [...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT], { - cwd: process.cwd(), - env: { - ...process.env, - ...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}), - ANTHROPIC_API_KEY: "", - ANTHROPIC_API_KEY_OLD: "", + const child = spawn( + options.claudeBin ?? CLAUDE_BIN, + [...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT], + { + cwd: process.cwd(), + env: { + ...process.env, + ...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}), + ANTHROPIC_API_KEY: "", + ANTHROPIC_API_KEY_OLD: "", + }, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], }, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - }); + ); child.stdout.on("data", (chunk) => stdout.push(String(chunk))); child.stderr.on("data", (chunk) => stderr.push(String(chunk))); const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( @@ -490,7 +501,7 @@ async function runDirectPrompt(prompt: string): Promise { await waitForGatewayPromptChildTreeExit( child, exitPromise.then(() => undefined), - 1_500, + options.shutdownWaitMs ?? 1_500, ); }; const removeParentSignalHandlers = installGatewayPromptParentSignalHandlers( @@ -505,7 +516,7 @@ async function runDirectPrompt(prompt: string): Promise { void stopDirectChild("SIGKILL").finally(() => { resolve({ code: null, signal: "SIGKILL" }); }); - }, TIMEOUT_MS); + }, options.timeoutMs ?? TIMEOUT_MS); }), ]).finally(() => { if (timeoutTimer) { @@ -963,6 +974,7 @@ export const testing = { readLogTail, readRequestBody, resolveAnthropicUpstreamUrl, + runDirectPrompt, stopGatewayPromptChild, summarizeCapture, summarizeText, diff --git a/scripts/bench-cli-startup.ts b/scripts/bench-cli-startup.ts index 651899330a59..691a122495a9 100644 --- a/scripts/bench-cli-startup.ts +++ b/scripts/bench-cli-startup.ts @@ -105,10 +105,20 @@ type CliOptions = { const DEFAULT_RUNS = 5; const DEFAULT_WARMUP = 1; const DEFAULT_TIMEOUT_MS = 30_000; -const TIMEOUT_KILL_GRACE_MS = 1_000; +const DEFAULT_TIMEOUT_KILL_GRACE_MS = 1_000; +const TIMEOUT_KILL_GRACE_MS = resolveTimeoutKillGraceMs(process.env); const PROCESS_GROUP_EXIT_POLL_MS = 25; const DEFAULT_ENTRY = "openclaw.mjs"; const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; + +function resolveTimeoutKillGraceMs(env: NodeJS.ProcessEnv): number { + const raw = env.VITEST ? env.OPENCLAW_TEST_CLI_STARTUP_TIMEOUT_KILL_GRACE_MS : undefined; + if (!raw || !/^\d+$/u.test(raw)) { + return DEFAULT_TIMEOUT_KILL_GRACE_MS; + } + const parsed = Number(raw); + return Number.isSafeInteger(parsed) ? parsed : DEFAULT_TIMEOUT_KILL_GRACE_MS; +} const VALUE_FLAGS = new Set([ "--case", "--compare-baseline", diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 7f861c411c45..1084507fcd90 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url"; import { resolvePathEnvKey, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const CRABBOX_METADATA_PROBE_TIMEOUT_MS = 5_000; const ignoreRepoBinary = process.env.OPENCLAW_CRABBOX_WRAPPER_IGNORE_REPO_BINARY === "1"; const repoLocal = ignoreRepoBinary ? null : resolveCrabboxBinary(process.env, process.platform); const pathLocal = resolvePathBinary("crabbox", process.env, process.platform); @@ -361,7 +362,7 @@ function checkedOutput(command, commandArgs) { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, - timeout: 5_000, + timeout: resolveMetadataProbeTimeoutMs(process.env), killSignal: "SIGKILL", }); const timedOut = result.error?.name === "Error" && result.signal === "SIGKILL"; @@ -3467,7 +3468,7 @@ const child = spawn(childInvocation.command, childInvocation.args, { env: childEnv, windowsVerbatimArguments: childInvocation.windowsVerbatimArguments, }); -const childKillGraceMs = 5_000; +const childKillGraceMs = resolveChildKillGraceMs(process.env); let childForceKillTimer; let childTreeShutdownStarted = false; if (fullCheckout) { @@ -3604,3 +3605,19 @@ async function waitForChildTreeExit(childProcess, timeoutMs) { } return !childProcessTreeIsAlive(childProcess); } + +function resolveChildKillGraceMs(env) { + if (!env.VITEST || !env.OPENCLAW_TEST_CRABBOX_CHILD_KILL_GRACE_MS) { + return 5_000; + } + const value = Number.parseInt(env.OPENCLAW_TEST_CRABBOX_CHILD_KILL_GRACE_MS, 10); + return Number.isFinite(value) && value >= 0 ? value : 5_000; +} + +function resolveMetadataProbeTimeoutMs(env) { + if (!env.VITEST || !env.OPENCLAW_TEST_CRABBOX_METADATA_PROBE_TIMEOUT_MS) { + return CRABBOX_METADATA_PROBE_TIMEOUT_MS; + } + const value = Number.parseInt(env.OPENCLAW_TEST_CRABBOX_METADATA_PROBE_TIMEOUT_MS, 10); + return Number.isFinite(value) && value > 0 ? value : CRABBOX_METADATA_PROBE_TIMEOUT_MS; +} diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index 7256141fe935..7f2525ac9e66 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -551,14 +551,15 @@ async function shutdownActiveCommands(signal) { return commandShutdownPromise; } const children = [...activeCommandChildren]; + const killGraceMs = resolveCommandParentSignalKillGraceMs(process.env); for (const child of children) { signalProcessGroup(child, signal); } commandShutdownPromise = Promise.all( children.map((child) => finishTimedOutCommandProcessTree(child, { - forceKillAt: Date.now() + COMMAND_PARENT_SIGNAL_KILL_GRACE_MS, - timeoutKillGraceMs: COMMAND_PARENT_SIGNAL_KILL_GRACE_MS, + forceKillAt: Date.now() + killGraceMs, + timeoutKillGraceMs: killGraceMs, }), ), ).finally(() => { @@ -568,6 +569,15 @@ async function shutdownActiveCommands(signal) { return commandShutdownPromise; } +function resolveCommandParentSignalKillGraceMs(env) { + const raw = env.VITEST && env.OPENCLAW_TEST_KITCHEN_SINK_PARENT_SIGNAL_KILL_GRACE_MS; + if (!raw) { + return COMMAND_PARENT_SIGNAL_KILL_GRACE_MS; + } + const value = Number.parseInt(raw, 10); + return Number.isFinite(value) && value >= 0 ? value : COMMAND_PARENT_SIGNAL_KILL_GRACE_MS; +} + async function waitForCommandProcessTreeExit(child, timeoutMs) { const deadlineAt = Date.now() + timeoutMs; while (Date.now() < deadlineAt) { diff --git a/scripts/e2e/lib/release-user-journey/assertions.mjs b/scripts/e2e/lib/release-user-journey/assertions.mjs index adf6c9ddc926..6d772ece9506 100644 --- a/scripts/e2e/lib/release-user-journey/assertions.mjs +++ b/scripts/e2e/lib/release-user-journey/assertions.mjs @@ -309,7 +309,11 @@ async function waitClickClackSocket() { 30, "ClickClack websocket timeout seconds", ); - const deadline = Date.now() + timeoutSeconds * 1000; + await waitForClickClackSocket({ baseUrl, timeoutMs: timeoutSeconds * 1000 }); +} + +export async function waitForClickClackSocket({ baseUrl, timeoutMs, pollIntervalMs = 250 }) { + const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const remainingMs = Math.max(1, deadline - Date.now()); const state = await withClickClackFixtureResponse( @@ -329,7 +333,7 @@ async function waitClickClackSocket() { } } await new Promise((resolve) => { - setTimeout(resolve, 250); + setTimeout(resolve, Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()))); }); } throw new Error(`Timed out waiting for ClickClack websocket connection at ${baseUrl}`); diff --git a/scripts/e2e/secret-provider-integrations.mjs b/scripts/e2e/secret-provider-integrations.mjs index 400ba1da69c2..44579e98ded1 100644 --- a/scripts/e2e/secret-provider-integrations.mjs +++ b/scripts/e2e/secret-provider-integrations.mjs @@ -104,10 +104,7 @@ function readPositiveInt(raw, fallback, label) { function clampSecretProofTimerTimeoutMs(valueMs) { const value = Number.isFinite(valueMs) ? valueMs : 1; - return Math.min( - Math.max(1, Math.floor(value)), - MAX_SECRET_PROOF_TIMER_TIMEOUT_MS, - ); + return Math.min(Math.max(1, Math.floor(value)), MAX_SECRET_PROOF_TIMER_TIMEOUT_MS); } function readPositiveTimerMs(raw, fallback, label) { @@ -361,6 +358,9 @@ async function cleanupEnv(root, options = {}) { function runCommand(command, args, options = {}) { const timeoutMs = clampSecretProofTimerTimeoutMs(options.timeoutMs ?? COMMAND_TIMEOUT_MS); + const timeoutKillGraceMs = clampSecretProofTimerTimeoutMs( + options.timeoutKillGraceMs ?? COMMAND_TIMEOUT_KILL_GRACE_MS, + ); return new Promise((resolve, reject) => { const usesProcessGroup = options.detached ?? process.platform !== "win32"; const child = childProcess.spawn(command, args, { @@ -379,11 +379,8 @@ function runCommand(command, args, options = {}) { let killTimer; let forceKillAt; const armForceKill = () => { - forceKillAt ??= Date.now() + COMMAND_TIMEOUT_KILL_GRACE_MS; - killTimer ??= setTimeout( - () => terminateProcessTree(child, "SIGKILL"), - COMMAND_TIMEOUT_KILL_GRACE_MS, - ); + forceKillAt ??= Date.now() + timeoutKillGraceMs; + killTimer ??= setTimeout(() => terminateProcessTree(child, "SIGKILL"), timeoutKillGraceMs); killTimer.unref(); }; const abort = () => { @@ -421,7 +418,7 @@ function runCommand(command, args, options = {}) { const finishTerminatedTree = async () => { await finishTimedOutCommandProcessTree(child, { forceKillAt, - timeoutKillGraceMs: COMMAND_TIMEOUT_KILL_GRACE_MS, + timeoutKillGraceMs, }); if (killTimer) { clearTimeout(killTimer); diff --git a/scripts/lib/docker-build.sh b/scripts/lib/docker-build.sh index 9374e2d2d170..0b321524d046 100644 --- a/scripts/lib/docker-build.sh +++ b/scripts/lib/docker-build.sh @@ -118,6 +118,22 @@ docker_build_run_command() { "$@" } +docker_build_maybe_print_heartbeat() { + local label="$1" + local elapsed_seconds="$2" + local next_heartbeat="$3" + local log_file="$4" + if [ "$elapsed_seconds" -lt "$next_heartbeat" ]; then + return 1 + fi + local log_bytes="0" + if [ -f "$log_file" ]; then + log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)" + log_bytes="${log_bytes//[[:space:]]/}" + fi + echo "Docker build $label still running (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)..." +} + docker_build_run_logged() { local label="$1" local timeout_value="$2" @@ -195,18 +211,14 @@ docker_build_run_logged() { docker_build_run_command "$timeout_value" "$@" >"$log_file" 2>&1 & build_pid="$!" while kill -0 "$build_pid" 2>/dev/null; do - /bin/sleep 1 & + # Poll promptly so short builds do not pay a one-second wrapper tax. + /bin/sleep 0.1 & heartbeat_sleep_pid="$!" wait "$heartbeat_sleep_pid" 2>/dev/null || true heartbeat_sleep_pid="" local elapsed_seconds=$((SECONDS - started_at)) - if [ "$elapsed_seconds" -ge "$next_heartbeat" ] && kill -0 "$build_pid" 2>/dev/null; then - local log_bytes="0" - if [ -f "$log_file" ]; then - log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)" - log_bytes="${log_bytes//[[:space:]]/}" - fi - echo "Docker build $label still running (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)..." + if kill -0 "$build_pid" 2>/dev/null && \ + docker_build_maybe_print_heartbeat "$label" "$elapsed_seconds" "$next_heartbeat" "$log_file"; then next_heartbeat=$((elapsed_seconds + heartbeat_seconds)) fi done diff --git a/scripts/lib/docker-e2e-logs.sh b/scripts/lib/docker-e2e-logs.sh index ac715b1d2423..b6e83f0434be 100644 --- a/scripts/lib/docker-e2e-logs.sh +++ b/scripts/lib/docker-e2e-logs.sh @@ -65,6 +65,22 @@ run_logged_print() { rm -f "$log_file" } +docker_e2e_maybe_print_log_heartbeat() { + local label="$1" + local elapsed_seconds="$2" + local next_heartbeat="$3" + local log_file="$4" + if [ "$elapsed_seconds" -lt "$next_heartbeat" ]; then + return 1 + fi + local log_bytes="0" + if [ -f "$log_file" ]; then + log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)" + log_bytes="${log_bytes//[[:space:]]/}" + fi + echo "still running $label (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)" +} + run_logged_print_heartbeat() { local label="$1" local interval_seconds="$2" @@ -143,15 +159,11 @@ run_logged_print_heartbeat() { local next_heartbeat=$interval_seconds local status=0 while kill -0 "$command_pid" 2>/dev/null; do - /bin/sleep 1 + # Poll promptly so short commands do not pay a one-second wrapper tax. + /bin/sleep 0.1 local elapsed_seconds=$((SECONDS - started_at)) - if [ "$elapsed_seconds" -ge "$next_heartbeat" ] && kill -0 "$command_pid" 2>/dev/null; then - local log_bytes="0" - if [ -f "$log_file" ]; then - log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)" - log_bytes="${log_bytes//[[:space:]]/}" - fi - echo "still running $label (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)" + if kill -0 "$command_pid" 2>/dev/null && \ + docker_e2e_maybe_print_log_heartbeat "$label" "$elapsed_seconds" "$next_heartbeat" "$log_file"; then next_heartbeat=$((elapsed_seconds + interval_seconds)) fi done diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index 8ce33e3139d7..0abde4c6cedf 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -133,6 +133,10 @@ type ClawHubRequestOptions = { requestTimeoutMs?: number; }; +type ClawHubRetryOptions = ClawHubRequestOptions & { + sleep?: (ms: number) => Promise; +}; + async function fetchClawHubRequest( url: URL, options: ClawHubRequestOptions = {}, @@ -485,10 +489,8 @@ async function doesClawHubPackageExist( async function hasClawHubTrustedPublisher( packageName: string, - options: { - fetchImpl?: typeof fetch; + options: ClawHubRetryOptions & { registryBaseUrl?: string; - requestTimeoutMs?: number; } = {}, ): Promise { const url = new URL( @@ -537,7 +539,7 @@ async function hasClawHubTrustedPublisher( } await response.body?.cancel().catch(() => undefined); - await delay(clawHubRetryDelayMs(response, attempt)); + await (options.sleep ?? delay)(clawHubRetryDelayMs(response, attempt)); } } @@ -600,6 +602,7 @@ export async function collectPluginClawHubReleasePlan(params?: { fetchImpl?: typeof fetch; requestTimeoutMs?: number; resolveLatestVersion?: NpmLatestVersionResolver; + sleep?: (ms: number) => Promise; }): Promise { const rootDir = params?.rootDir; const selection = params?.selection ?? []; @@ -648,6 +651,7 @@ export async function collectPluginClawHubReleasePlan(params?: { registryBaseUrl: params?.registryBaseUrl, fetchImpl: params?.fetchImpl, requestTimeoutMs: params?.requestTimeoutMs, + sleep: params?.sleep, }) : false; const alreadyPublished = packageExists diff --git a/scripts/native-app-i18n.ts b/scripts/native-app-i18n.ts index 10af7d1ef368..5a8f2a2b57ff 100644 --- a/scripts/native-app-i18n.ts +++ b/scripts/native-app-i18n.ts @@ -74,6 +74,7 @@ const SOURCE_ROOTS: Record = { const ANDROID_EXTENSIONS = new Set([".kt", ".kts"]); const APPLE_EXTENSIONS = new Set([".swift", ".plist"]); const NATIVE_FORMAT_RE = /%(?:\d+\$)?[@a-z]/giu; +const NATIVE_SOURCE_READ_CONCURRENCY = 32; const APPLE_UI_MULTILINE_CALLS = /(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"""([\s\S]*?)"""/gu; const APPLE_LOCALIZED_STRING_CALLS = @@ -564,6 +565,27 @@ function normalizeSource(source: string): string { return source; } +function identifierBefore(source: string, offset: number): string | null { + let cursor = offset - 1; + while (cursor >= 0 && source.charCodeAt(cursor) <= 32) { + cursor -= 1; + } + const end = cursor + 1; + while (cursor >= 0 && (isAsciiAlphaNumeric(source[cursor]) || source[cursor] === "_")) { + cursor -= 1; + } + const start = cursor + 1; + if ( + start === end || + (!isAsciiLowercaseLetter(source[start]) && + !isAsciiUppercaseLetter(source[start]) && + source[start] !== "_") + ) { + return null; + } + return source.slice(start, end); +} + function enclosingCallName(source: string, offset: number): string | null { let depth = 0; for (let index = offset - 1; index >= 0; index -= 1) { @@ -578,7 +600,7 @@ function enclosingCallName(source: string, offset: number): string | null { depth -= 1; continue; } - return source.slice(0, index).match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/u)?.[1] ?? null; + return identifierBefore(source, index); } return null; } @@ -837,36 +859,31 @@ function extractCandidates( return entries; } -async function walkFiles( - root: string, - surface: NativeI18nSurface, - out: string[] = [], -): Promise { +async function walkFiles(root: string, surface: NativeI18nSurface): Promise { const entries = await readdir(root, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(root, entry.name); - if (entry.isDirectory()) { - if (GENERATED_PATH_RE.test(fullPath) || EXCLUDED_PATH_RE.test(fullPath)) { - continue; + const nested = await Promise.all( + entries.map(async (entry): Promise => { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + if (GENERATED_PATH_RE.test(fullPath) || EXCLUDED_PATH_RE.test(fullPath)) { + return []; + } + return await walkFiles(fullPath, surface); } - await walkFiles(fullPath, surface, out); - continue; - } - const extension = path.extname(entry.name); - const isAndroidValuesXml = - surface === "android" && - extension === ".xml" && - path.dirname(fullPath).endsWith(`${path.sep}res${path.sep}values`); - const allowed = surface === "apple" ? APPLE_EXTENSIONS : ANDROID_EXTENSIONS; - if ( - entry.isFile() && - (allowed.has(extension) || isAndroidValuesXml) && - !EXCLUDED_FILE_RE.test(entry.name) - ) { - out.push(fullPath); - } - } - return out; + const extension = path.extname(entry.name); + const isAndroidValuesXml = + surface === "android" && + extension === ".xml" && + path.dirname(fullPath).endsWith(`${path.sep}res${path.sep}values`); + const allowed = surface === "apple" ? APPLE_EXTENSIONS : ANDROID_EXTENSIONS; + return entry.isFile() && + (allowed.has(extension) || isAndroidValuesXml) && + !EXCLUDED_FILE_RE.test(entry.name) + ? [fullPath] + : []; + }), + ); + return nested.flat(); } function withIds(entries: Candidate[]): NativeI18nEntry[] { @@ -899,24 +916,55 @@ function withIds(entries: Candidate[]): NativeI18nEntry[] { }); } +async function mapWithConcurrency( + values: readonly T[], + limit: number, + run: (value: T) => Promise, +): Promise { + const results = Array(values.length); + let nextIndex = 0; + const workerCount = Math.min(limit, values.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + for (;;) { + const index = nextIndex; + nextIndex += 1; + if (index >= values.length) { + return; + } + results[index] = await run(values[index]); + } + }), + ); + return results; +} + export async function collectNativeI18nEntries(): Promise { - const sources: Array<{ + const roots = (["android", "apple"] as const).flatMap((surface) => + SOURCE_ROOTS[surface].map((sourceRoot) => ({ sourceRoot, surface })), + ); + const filesByRoot = await Promise.all( + roots.map(async ({ sourceRoot, surface }) => ({ + files: (await walkFiles(sourceRoot, surface)).toSorted(), + surface, + })), + ); + const sources = await mapWithConcurrency( + filesByRoot.flatMap(({ files, surface }) => files.map((filePath) => ({ filePath, surface }))), + NATIVE_SOURCE_READ_CONCURRENCY, + async ({ filePath, surface }) => ({ + repoPath: path.relative(ROOT, filePath).split(path.sep).join("/"), + source: await readFile(filePath, "utf8"), + surface, + }), + ); + const typedSources: Array<{ repoPath: string; source: string; surface: NativeI18nSurface; - }> = []; - for (const surface of ["android", "apple"] as const) { - for (const sourceRoot of SOURCE_ROOTS[surface]) { - const files = await walkFiles(sourceRoot, surface); - for (const filePath of files.toSorted()) { - const source = await readFile(filePath, "utf8"); - const repoPath = path.relative(ROOT, filePath).split(path.sep).join("/"); - sources.push({ repoPath, source, surface }); - } - } - } + }> = sources; const uiCallNames = new Set([...APPLE_BUILTIN_UI_TYPES, ...ANDROID_BUILTIN_UI_CALLS]); - for (const { source, surface } of sources) { + for (const { source, surface } of typedSources) { if (surface === "android") { for (const match of source.matchAll(ANDROID_COMPOSABLE_FUNCTION)) { if (match[1]) { @@ -933,7 +981,7 @@ export async function collectNativeI18nEntries(): Promise { } } } - const entries = sources.flatMap(({ repoPath, source, surface }) => + const entries = typedSources.flatMap(({ repoPath, source, surface }) => extractCandidates(surface, repoPath, source, uiCallNames), ); return withIds(entries); diff --git a/scripts/perf/issue-78851-model-resolution-cli.ts b/scripts/perf/issue-78851-model-resolution-cli.ts new file mode 100644 index 000000000000..eb5ef1c85c42 --- /dev/null +++ b/scripts/perf/issue-78851-model-resolution-cli.ts @@ -0,0 +1,132 @@ +// Lightweight CLI contract for the issue #78851 model-resolution profiler. +export type Issue78851ModelResolutionOptions = { + agentCount: number; + cpuProfDir?: string; + cpuProfOutput?: string; + json: boolean; + keepTemp: boolean; + lookupsPerRun: number; + modelsPerProvider: number; + output?: string; + providers: number; + runs: number; + runtimeHooks: boolean; + warmup: number; +}; + +const BOOLEAN_FLAGS = new Set(["--help", "-h", "--json", "--keep-temp", "--runtime-hooks"]); +const VALUE_FLAGS = new Set([ + "--agents", + "--cpu-prof-dir", + "--cpu-prof-output", + "--lookups", + "--models-per-provider", + "--output", + "--providers", + "--runs", + "--warmup", +]); + +export class Issue78851CliArgumentError extends Error { + override name = "Issue78851CliArgumentError"; +} + +function parseFlagValue(flag: string, args: readonly string[]): string | undefined { + const index = args.indexOf(flag); + if (index === -1) { + return undefined; + } + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + throw new Issue78851CliArgumentError(`${flag} requires a value`); + } + return value; +} + +function parseInteger( + flag: string, + fallback: number, + args: readonly string[], + minimum: number, + label: string, +): number { + const raw = parseFlagValue(flag, args); + if (!raw) { + return fallback; + } + const value = Number(raw); + if (!Number.isInteger(value) || value < minimum) { + throw new Issue78851CliArgumentError(`${flag} must be a ${label} integer`); + } + return value; +} + +function validateArgs(args: readonly string[]): void { + const seenValueFlags = new Set(); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (BOOLEAN_FLAGS.has(arg)) { + continue; + } + if (!VALUE_FLAGS.has(arg)) { + throw new Issue78851CliArgumentError(`Unknown argument: ${arg}`); + } + if (seenValueFlags.has(arg)) { + throw new Issue78851CliArgumentError(`${arg} was provided more than once`); + } + seenValueFlags.add(arg); + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + throw new Issue78851CliArgumentError(`${arg} requires a value`); + } + index += 1; + } +} + +export function issue78851ModelResolutionHelpRequested(args: readonly string[]): boolean { + return args.includes("--help") || args.includes("-h"); +} + +export function parseIssue78851ModelResolutionOptions( + args: readonly string[], +): Issue78851ModelResolutionOptions { + validateArgs(args); + return { + agentCount: parseInteger("--agents", 8, args, 1, "positive"), + cpuProfDir: parseFlagValue("--cpu-prof-dir", args), + cpuProfOutput: parseFlagValue("--cpu-prof-output", args), + json: args.includes("--json"), + keepTemp: args.includes("--keep-temp"), + lookupsPerRun: parseInteger("--lookups", 32, args, 1, "positive"), + modelsPerProvider: parseInteger("--models-per-provider", 16, args, 1, "positive"), + output: parseFlagValue("--output", args), + providers: parseInteger("--providers", 48, args, 1, "positive"), + runs: parseInteger("--runs", 8, args, 1, "positive"), + runtimeHooks: args.includes("--runtime-hooks"), + warmup: parseInteger("--warmup", 1, args, 0, "non-negative"), + }; +} + +export function issue78851ModelResolutionUsage(): string { + return `OpenClaw issue #78851 model-resolution profiler + +Usage: + pnpm perf:issue-78851 -- [options] + node --import tsx scripts/perf/issue-78851-model-resolution.ts [options] + +Options: + --providers Synthetic configured providers (default: 48) + --models-per-provider Models per provider (default: 16) + --agents Agent configs/fallback chains (default: 8) + --lookups resolveModelAsync calls per phase (default: 32) + --runs Measured runs (default: 8) + --warmup Warmup runs before measurement (default: 1) + --cpu-prof-dir Write a V8 .cpuprofile for the measured loop + --cpu-prof-output Write the V8 .cpuprofile to this exact path + --runtime-hooks Include provider runtime hook resolution + --output Write JSON report + --json Print JSON report + --keep-temp Keep generated temp state + --help, -h Show this text +`; +} diff --git a/scripts/perf/issue-78851-model-resolution.ts b/scripts/perf/issue-78851-model-resolution.ts index 838998ba7ae6..241765132815 100644 --- a/scripts/perf/issue-78851-model-resolution.ts +++ b/scripts/perf/issue-78851-model-resolution.ts @@ -10,38 +10,13 @@ import { resetModelsJsonReadyCacheForTest, } from "../../src/agents/models-config.js"; import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; - -type Options = { - agentCount: number; - cpuProfDir?: string; - cpuProfOutput?: string; - json: boolean; - keepTemp: boolean; - lookupsPerRun: number; - modelsPerProvider: number; - output?: string; - providers: number; - runs: number; - runtimeHooks: boolean; - warmup: number; -}; - -const BOOLEAN_FLAGS = new Set(["--help", "-h", "--json", "--keep-temp", "--runtime-hooks"]); -const VALUE_FLAGS = new Set([ - "--agents", - "--cpu-prof-dir", - "--cpu-prof-output", - "--lookups", - "--models-per-provider", - "--output", - "--providers", - "--runs", - "--warmup", -]); - -class CliArgumentError extends Error { - override name = "CliArgumentError"; -} +import { + Issue78851CliArgumentError, + issue78851ModelResolutionHelpRequested, + issue78851ModelResolutionUsage, + parseIssue78851ModelResolutionOptions, + type Issue78851ModelResolutionOptions as Options, +} from "./issue-78851-model-resolution-cli.js"; type PhaseSample = { ensureMs: number; @@ -85,117 +60,6 @@ type Report = { cpuProfilePath?: string; }; -function parseFlagValue(flag: string, args = process.argv.slice(2)): string | undefined { - const index = args.indexOf(flag); - if (index === -1) { - return undefined; - } - const value = args[index + 1]; - if (!value || value.startsWith("-")) { - throw new CliArgumentError(`${flag} requires a value`); - } - return value; -} - -function hasFlag(flag: string, args = process.argv.slice(2)): boolean { - return args.includes(flag); -} - -function parsePositiveInt(flag: string, fallback: number, args = process.argv.slice(2)): number { - const raw = parseFlagValue(flag, args); - if (!raw) { - return fallback; - } - const value = Number(raw); - if (!Number.isFinite(value) || value <= 0) { - throw new CliArgumentError(`${flag} must be a positive integer`); - } - if (!Number.isInteger(value)) { - throw new CliArgumentError(`${flag} must be a positive integer`); - } - return value; -} - -function parseNonNegativeInt(flag: string, fallback: number, args = process.argv.slice(2)): number { - const raw = parseFlagValue(flag, args); - if (!raw) { - return fallback; - } - const value = Number(raw); - if (!Number.isFinite(value) || value < 0) { - throw new CliArgumentError(`${flag} must be a non-negative integer`); - } - if (!Number.isInteger(value)) { - throw new CliArgumentError(`${flag} must be a non-negative integer`); - } - return value; -} - -function validateCliArgs(args = process.argv.slice(2)): void { - const seenValueFlags = new Set(); - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] ?? ""; - if (BOOLEAN_FLAGS.has(arg)) { - continue; - } - if (VALUE_FLAGS.has(arg)) { - if (seenValueFlags.has(arg)) { - throw new CliArgumentError(`${arg} was provided more than once`); - } - seenValueFlags.add(arg); - const value = args[index + 1]; - if (!value || value.startsWith("-")) { - throw new CliArgumentError(`${arg} requires a value`); - } - index += 1; - continue; - } - throw new CliArgumentError(`Unknown argument: ${arg}`); - } -} - -function parseOptions(args = process.argv.slice(2)): Options { - validateCliArgs(args); - return { - agentCount: parsePositiveInt("--agents", 8, args), - cpuProfDir: parseFlagValue("--cpu-prof-dir", args), - cpuProfOutput: parseFlagValue("--cpu-prof-output", args), - json: hasFlag("--json", args), - keepTemp: hasFlag("--keep-temp", args), - lookupsPerRun: parsePositiveInt("--lookups", 32, args), - modelsPerProvider: parsePositiveInt("--models-per-provider", 16, args), - output: parseFlagValue("--output", args), - providers: parsePositiveInt("--providers", 48, args), - runs: parsePositiveInt("--runs", 8, args), - runtimeHooks: hasFlag("--runtime-hooks", args), - warmup: parseNonNegativeInt("--warmup", 1, args), - }; -} - -function printUsage(): void { - process.stdout.write(`OpenClaw issue #78851 model-resolution profiler - -Usage: - pnpm perf:issue-78851 -- [options] - node --import tsx scripts/perf/issue-78851-model-resolution.ts [options] - -Options: - --providers Synthetic configured providers (default: 48) - --models-per-provider Models per provider (default: 16) - --agents Agent configs/fallback chains (default: 8) - --lookups resolveModelAsync calls per phase (default: 32) - --runs Measured runs (default: 8) - --warmup Warmup runs before measurement (default: 1) - --cpu-prof-dir Write a V8 .cpuprofile for the measured loop - --cpu-prof-output Write the V8 .cpuprofile to this exact path - --runtime-hooks Include provider runtime hook resolution - --output Write JSON report - --json Print JSON report - --keep-temp Keep generated temp state - --help, -h Show this text -`); -} - function round(value: number): number { return Math.round(value * 100) / 100; } @@ -478,12 +342,11 @@ function printHuman(report: Report, cpuProfilePath?: string): void { async function main(): Promise { const args = process.argv.slice(2); - validateCliArgs(args); - if (hasFlag("--help", args) || hasFlag("-h", args)) { - printUsage(); + const options = parseIssue78851ModelResolutionOptions(args); + if (issue78851ModelResolutionHelpRequested(args)) { + process.stdout.write(issue78851ModelResolutionUsage()); return; } - const options = parseOptions(args); const tempRoot = await mkdtemp(path.join(tmpdir(), "openclaw-issue-78851-")); const workspaceDir = path.join(tempRoot, "workspace"); await mkdir(workspaceDir, { recursive: true }); @@ -547,7 +410,7 @@ async function main(): Promise { } main().catch((error: unknown) => { - if (error instanceof CliArgumentError) { + if (error instanceof Issue78851CliArgumentError) { process.stderr.write(`${error.message}\n`); process.exit(1); } diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index df01301778bc..a311f0806ae2 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -3,7 +3,7 @@ // Reports plugin SDK export surface metadata. import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import ts from "typescript"; import { deprecatedBarrelPluginSdkEntrypoints, @@ -25,7 +25,7 @@ Options: `; } -function parseArgs(argv) { +export function parsePluginSdkSurfaceReportArgs(argv) { const args = { check: false, help: false }; for (const arg of argv) { if (arg === "--check") { @@ -40,28 +40,14 @@ function parseArgs(argv) { } return args; } - -let cliArgs; -try { - cliArgs = parseArgs(process.argv.slice(2)); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -} -if (cliArgs.help) { - process.stdout.write(usage()); - process.exit(0); -} - -const checkOnly = cliArgs.check; const publicEntrypointSet = new Set(publicPluginSdkEntrypoints); const localOnlyEntrypointSet = new Set(privateLocalOnlyPluginSdkEntrypoints); const deprecatedPublicEntrypointSet = new Set(deprecatedPublicPluginSdkEntrypoints); const deprecatedBarrelEntrypointSet = new Set(deprecatedBarrelPluginSdkEntrypoints); const forbiddenPublicSubpaths = new Set(["test-utils"]); -function readBudgetEnv(name, fallback) { - const raw = process.env[name]; +export function readPluginSdkSurfaceBudgetEnv(name, fallback, env = process.env) { + const raw = env[name]; if (raw === undefined) { return fallback; } @@ -76,8 +62,8 @@ function readBudgetEnv(name, fallback) { return parsed; } -function readEntrypointBudgetEnv(name, fallback) { - const raw = process.env[name]; +export function readPluginSdkEntrypointBudgetEnv(name, fallback, env = process.env) { + const raw = env[name]; if (raw === undefined) { return fallback; } @@ -101,7 +87,7 @@ function readEntrypointBudgetEnv(name, fallback) { return Object.freeze({ ...fallback, ...overrides }); } -const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ +export const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ core: 2, health: 1, lmstudio: 1, @@ -197,29 +183,40 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ zod: 282, }); -let budgets; -let publicDeprecatedExportsByEntrypointBudget; -try { - budgets = { - publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 324), - publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10429), - publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5206), - publicDeprecatedExports: readBudgetEnv( +export function readPluginSdkSurfaceBudgets(env = process.env) { + const budgets = { + publicEntrypoints: readPluginSdkSurfaceBudgetEnv( + "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", + 324, + env, + ), + publicExports: readPluginSdkSurfaceBudgetEnv( + "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", + 10429, + env, + ), + publicFunctionExports: readPluginSdkSurfaceBudgetEnv( + "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", + 5206, + env, + ), + publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS", 3261, + env, ), - publicWildcardReexports: readBudgetEnv( + publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS", 212, + env, ), }; - publicDeprecatedExportsByEntrypointBudget = readEntrypointBudgetEnv( + const publicDeprecatedExportsByEntrypointBudget = readPluginSdkEntrypointBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS_BY_ENTRYPOINT", defaultPublicDeprecatedExportsByEntrypointBudget, + env, ); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); + return { budgets, publicDeprecatedExportsByEntrypointBudget }; } function entrypointPath(entrypoint) { @@ -360,6 +357,36 @@ function collectExportStats(entrypoints) { return { byEntrypoint, totals }; } +function selectExportStats(scannedStats, entrypoints) { + const byEntrypoint = new Map(); + const totals = { + entrypoints: entrypoints.length, + exports: 0, + callableExports: 0, + deprecatedExports: 0, + deprecatedCallableExports: 0, + uniqueExports: 0, + uniqueCallableExports: 0, + }; + for (const entrypoint of entrypoints) { + const stats = scannedStats.byEntrypoint.get(entrypoint) ?? { + exports: 0, + callableExports: 0, + deprecatedExports: 0, + deprecatedCallableExports: 0, + }; + byEntrypoint.set(entrypoint, stats); + totals.exports += stats.exports; + totals.callableExports += stats.callableExports; + totals.deprecatedExports += stats.deprecatedExports; + totals.deprecatedCallableExports += stats.deprecatedCallableExports; + } + // Export identities are entrypoint-qualified, so the selected totals are unique. + totals.uniqueExports = totals.exports; + totals.uniqueCallableExports = totals.callableExports; + return { byEntrypoint, totals }; +} + function formatStats(label, stats) { return [ `${label}:`, @@ -372,10 +399,10 @@ function formatStats(label, stats) { ].join("\n"); } -function collectDeprecatedEntrypointBudgetFailures(byEntrypoint) { +function collectDeprecatedEntrypointBudgetFailures(byEntrypoint, entrypointBudgets) { const failures = []; for (const [entrypoint, stats] of byEntrypoint) { - const budget = publicDeprecatedExportsByEntrypointBudget[entrypoint] ?? 0; + const budget = entrypointBudgets[entrypoint] ?? 0; if (stats.deprecatedExports > budget) { failures.push( `public deprecated exports in ${entrypoint} ${stats.deprecatedExports} > ${budget}`, @@ -385,95 +412,159 @@ function collectDeprecatedEntrypointBudgetFailures(byEntrypoint) { return failures; } -const allStats = collectExportStats(pluginSdkEntrypoints); -const publicStats = collectExportStats(publicPluginSdkEntrypoints); -const localOnlyStats = collectExportStats(privateLocalOnlyPluginSdkEntrypoints); -const publicWildcards = countWildcardReexports(publicPluginSdkEntrypoints); -const packageExportedSubpaths = readPackageExportedSubpaths(); -const leakedForbiddenExports = packageExportedSubpaths.filter((subpath) => - forbiddenPublicSubpaths.has(subpath), -); -const localOnlyStillPublic = privateLocalOnlyPluginSdkEntrypoints.filter((entrypoint) => - publicEntrypointSet.has(entrypoint), -); -const localOnlyMissingFromInventory = [...localOnlyEntrypointSet].filter( - (entrypoint) => !pluginSdkEntrypoints.includes(entrypoint), -); -const deprecatedMissingFromPublic = [...deprecatedPublicEntrypointSet].filter( - (entrypoint) => !publicEntrypointSet.has(entrypoint), -); -const deprecatedBarrelMissingFromInventory = [...deprecatedBarrelEntrypointSet].filter( - (entrypoint) => !pluginSdkEntrypoints.includes(entrypoint), -); -const deprecatedBarrelWithoutWildcard = [...deprecatedBarrelEntrypointSet].filter((entrypoint) => { - const source = fs.readFileSync(entrypointPath(entrypoint), "utf8"); - return !/^\s*export\s+(?:type\s+)?\*\s+from\s+["'][^"']+["']/mu.test(source); -}); - -console.log(formatStats("all SDK entrypoints", allStats.totals)); -console.log(formatStats("public package SDK entrypoints", publicStats.totals)); -console.log(formatStats("local-only SDK entrypoints", localOnlyStats.totals)); -console.log(`deprecated public subpaths: ${deprecatedPublicPluginSdkEntrypoints.length}`); -console.log(`deprecated barrel subpaths: ${deprecatedBarrelPluginSdkEntrypoints.length}`); -console.log(`public wildcard reexports: ${publicWildcards.count}`); -console.log(`package-exported forbidden subpaths: ${leakedForbiddenExports.length}`); - -const failures = []; -if (publicPluginSdkEntrypoints.length > budgets.publicEntrypoints) { - failures.push( - `public entrypoints ${publicPluginSdkEntrypoints.length} > ${budgets.publicEntrypoints}`, +export function collectPluginSdkSurfaceReport() { + const scannedEntrypoints = [ + ...new Set([ + ...pluginSdkEntrypoints, + ...publicPluginSdkEntrypoints, + ...privateLocalOnlyPluginSdkEntrypoints, + ]), + ]; + const scannedStats = collectExportStats(scannedEntrypoints); + const allStats = selectExportStats(scannedStats, pluginSdkEntrypoints); + const publicStats = selectExportStats(scannedStats, publicPluginSdkEntrypoints); + const localOnlyStats = selectExportStats(scannedStats, privateLocalOnlyPluginSdkEntrypoints); + const publicWildcards = countWildcardReexports(publicPluginSdkEntrypoints); + const leakedForbiddenExports = readPackageExportedSubpaths().filter((subpath) => + forbiddenPublicSubpaths.has(subpath), ); -} -if (publicStats.totals.exports > budgets.publicExports) { - failures.push(`public exports ${publicStats.totals.exports} > ${budgets.publicExports}`); -} -if (publicStats.totals.callableExports > budgets.publicFunctionExports) { - failures.push( - `public callable exports ${publicStats.totals.callableExports} > ${budgets.publicFunctionExports}`, + const localOnlyStillPublic = privateLocalOnlyPluginSdkEntrypoints.filter((entrypoint) => + publicEntrypointSet.has(entrypoint), ); -} -if (publicStats.totals.deprecatedExports > budgets.publicDeprecatedExports) { - failures.push( - `public deprecated exports ${publicStats.totals.deprecatedExports} > ${budgets.publicDeprecatedExports}`, + const localOnlyMissingFromInventory = [...localOnlyEntrypointSet].filter( + (entrypoint) => !pluginSdkEntrypoints.includes(entrypoint), ); -} -failures.push(...collectDeprecatedEntrypointBudgetFailures(publicStats.byEntrypoint)); -if (publicWildcards.count > budgets.publicWildcardReexports) { - failures.push( - `public wildcard reexports ${publicWildcards.count} > ${budgets.publicWildcardReexports}`, + const deprecatedMissingFromPublic = [...deprecatedPublicEntrypointSet].filter( + (entrypoint) => !publicEntrypointSet.has(entrypoint), ); -} -if (leakedForbiddenExports.length > 0) { - failures.push(`forbidden public subpaths: ${leakedForbiddenExports.join(", ")}`); -} -if (localOnlyStillPublic.length > 0) { - failures.push(`local-only entrypoints still public: ${localOnlyStillPublic.join(", ")}`); -} -if (localOnlyMissingFromInventory.length > 0) { - failures.push( - `local-only entrypoints missing from inventory: ${localOnlyMissingFromInventory.join(", ")}`, + const deprecatedBarrelMissingFromInventory = [...deprecatedBarrelEntrypointSet].filter( + (entrypoint) => !pluginSdkEntrypoints.includes(entrypoint), ); -} -if (deprecatedMissingFromPublic.length > 0) { - failures.push( - `deprecated public entrypoints missing from package surface: ${deprecatedMissingFromPublic.join(", ")}`, - ); -} -if (deprecatedBarrelMissingFromInventory.length > 0) { - failures.push( - `deprecated barrel entrypoints missing from inventory: ${deprecatedBarrelMissingFromInventory.join(", ")}`, - ); -} -if (deprecatedBarrelWithoutWildcard.length > 0) { - failures.push( - `deprecated barrel entrypoints without wildcard exports: ${deprecatedBarrelWithoutWildcard.join(", ")}`, + const deprecatedBarrelWithoutWildcard = [...deprecatedBarrelEntrypointSet].filter( + (entrypoint) => { + const source = fs.readFileSync(entrypointPath(entrypoint), "utf8"); + return !/^\s*export\s+(?:type\s+)?\*\s+from\s+["'][^"']+["']/mu.test(source); + }, ); + return { + allStats, + deprecatedBarrelMissingFromInventory, + deprecatedBarrelWithoutWildcard, + deprecatedMissingFromPublic, + leakedForbiddenExports, + localOnlyMissingFromInventory, + localOnlyStats, + localOnlyStillPublic, + publicStats, + publicWildcards, + }; } -if (checkOnly && failures.length > 0) { - console.error("plugin SDK surface budget failed:"); - for (const failure of failures) { - console.error(`- ${failure}`); +export function evaluatePluginSdkSurfaceReport( + report, + { budgets, publicDeprecatedExportsByEntrypointBudget }, +) { + const failures = []; + if (publicPluginSdkEntrypoints.length > budgets.publicEntrypoints) { + failures.push( + `public entrypoints ${publicPluginSdkEntrypoints.length} > ${budgets.publicEntrypoints}`, + ); + } + if (report.publicStats.totals.exports > budgets.publicExports) { + failures.push(`public exports ${report.publicStats.totals.exports} > ${budgets.publicExports}`); + } + if (report.publicStats.totals.callableExports > budgets.publicFunctionExports) { + failures.push( + `public callable exports ${report.publicStats.totals.callableExports} > ${budgets.publicFunctionExports}`, + ); + } + if (report.publicStats.totals.deprecatedExports > budgets.publicDeprecatedExports) { + failures.push( + `public deprecated exports ${report.publicStats.totals.deprecatedExports} > ${budgets.publicDeprecatedExports}`, + ); + } + failures.push( + ...collectDeprecatedEntrypointBudgetFailures( + report.publicStats.byEntrypoint, + publicDeprecatedExportsByEntrypointBudget, + ), + ); + if (report.publicWildcards.count > budgets.publicWildcardReexports) { + failures.push( + `public wildcard reexports ${report.publicWildcards.count} > ${budgets.publicWildcardReexports}`, + ); + } + if (report.leakedForbiddenExports.length > 0) { + failures.push(`forbidden public subpaths: ${report.leakedForbiddenExports.join(", ")}`); + } + if (report.localOnlyStillPublic.length > 0) { + failures.push(`local-only entrypoints still public: ${report.localOnlyStillPublic.join(", ")}`); + } + if (report.localOnlyMissingFromInventory.length > 0) { + failures.push( + `local-only entrypoints missing from inventory: ${report.localOnlyMissingFromInventory.join(", ")}`, + ); + } + if (report.deprecatedMissingFromPublic.length > 0) { + failures.push( + `deprecated public entrypoints missing from package surface: ${report.deprecatedMissingFromPublic.join(", ")}`, + ); + } + if (report.deprecatedBarrelMissingFromInventory.length > 0) { + failures.push( + `deprecated barrel entrypoints missing from inventory: ${report.deprecatedBarrelMissingFromInventory.join(", ")}`, + ); + } + if (report.deprecatedBarrelWithoutWildcard.length > 0) { + failures.push( + `deprecated barrel entrypoints without wildcard exports: ${report.deprecatedBarrelWithoutWildcard.join(", ")}`, + ); + } + return failures; +} + +function renderPluginSdkSurfaceReport(report) { + return [ + formatStats("all SDK entrypoints", report.allStats.totals), + formatStats("public package SDK entrypoints", report.publicStats.totals), + formatStats("local-only SDK entrypoints", report.localOnlyStats.totals), + `deprecated public subpaths: ${deprecatedPublicPluginSdkEntrypoints.length}`, + `deprecated barrel subpaths: ${deprecatedBarrelPluginSdkEntrypoints.length}`, + `public wildcard reexports: ${report.publicWildcards.count}`, + `package-exported forbidden subpaths: ${report.leakedForbiddenExports.length}`, + ].join("\n"); +} + +function main(argv = process.argv.slice(2), env = process.env) { + const cliArgs = parsePluginSdkSurfaceReportArgs(argv); + if (cliArgs.help) { + process.stdout.write(usage()); + return 0; + } + const budgetConfig = readPluginSdkSurfaceBudgets(env); + const report = collectPluginSdkSurfaceReport(); + process.stdout.write(`${renderPluginSdkSurfaceReport(report)}\n`); + const failures = evaluatePluginSdkSurfaceReport(report, budgetConfig); + if (cliArgs.check && failures.length > 0) { + process.stderr.write(`plugin SDK surface budget failed:\n`); + for (const failure of failures) { + process.stderr.write(`- ${failure}\n`); + } + return 1; + } + return 0; +} + +const isMain = + typeof process.argv[1] === "string" && + process.argv[1].length > 0 && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; + +if (isMain) { + try { + process.exitCode = main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; } - process.exit(1); } diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index 36cb7db20de8..ef655eb3ee46 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -17,7 +17,7 @@ const ROOT_SHIMS_MAX_OLD_SPACE_SIZE = process.env.OPENCLAW_ROOT_SHIMS_MAX_OLD_SPACE_SIZE?.trim() || "8192"; const ROOT_SHIMS_NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ""} --max-old-space-size=${ROOT_SHIMS_MAX_OLD_SPACE_SIZE}`.trim(); -const NODE_STEP_ABORT_KILL_GRACE_MS = 1_000; +const DEFAULT_NODE_STEP_ABORT_KILL_GRACE_MS = 1_000; const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; const NODE_STEP_PARENT_SIGNALS = ["SIGHUP", "SIGINT", "SIGTERM"]; const NODE_STEP_PARENT_SIGNAL_EXIT_CODES = new Map([ @@ -25,7 +25,7 @@ const NODE_STEP_PARENT_SIGNAL_EXIT_CODES = new Map([ ["SIGINT", 130], ["SIGTERM", 143], ]); -const ACTIVE_NODE_STEP_KILLERS = new Set(); +const ACTIVE_NODE_STEP_KILLERS = new Map(); let nodeStepParentSignalForwardersInstalled = false; let exitingAfterParentSignal = false; let parentSignalExitCode = 1; @@ -475,11 +475,17 @@ export function signalNodeStep( } function signalActiveNodeSteps(signal) { - for (const killNodeStep of ACTIVE_NODE_STEP_KILLERS) { + for (const killNodeStep of ACTIVE_NODE_STEP_KILLERS.keys()) { killNodeStep(signal); } } +function activeNodeStepKillGraceMs() { + return ACTIVE_NODE_STEP_KILLERS.size > 0 + ? Math.max(...ACTIVE_NODE_STEP_KILLERS.values()) + : DEFAULT_NODE_STEP_ABORT_KILL_GRACE_MS; +} + function installNodeStepParentSignalForwarders() { if (nodeStepParentSignalForwardersInstalled) { return; @@ -497,7 +503,7 @@ function installNodeStepParentSignalForwarders() { signalActiveNodeSteps(signal); parentSignalExitTimer ??= setTimeout( () => process.exit(parentSignalExitCode), - NODE_STEP_ABORT_KILL_GRACE_MS, + activeNodeStepKillGraceMs(), ); }); } @@ -519,6 +525,10 @@ function resolveNodeStepTimerTimeoutMs(valueMs) { */ export function runNodeStep(label, args, timeoutMs, params = {}) { const resolvedTimeoutMs = resolveNodeStepTimerTimeoutMs(timeoutMs); + const abortKillGraceMs = Math.max( + 0, + Math.floor(params.abortKillGraceMs ?? DEFAULT_NODE_STEP_ABORT_KILL_GRACE_MS), + ); const abortController = params.abortController; const spawnImpl = params.spawnImpl ?? spawn; installNodeStepParentSignalForwarders(); @@ -571,18 +581,18 @@ export function runNodeStep(label, args, timeoutMs, params = {}) { await waitForProcessGroupExit(100); } }; - ACTIVE_NODE_STEP_KILLERS.add(killNodeStep); + ACTIVE_NODE_STEP_KILLERS.set(killNodeStep, abortKillGraceMs); const abortStep = () => { if (settled || canceled) { return; } canceled = true; killNodeStep("SIGTERM"); - killDeadlineAt = Date.now() + NODE_STEP_ABORT_KILL_GRACE_MS; + killDeadlineAt = Date.now() + abortKillGraceMs; killTimer = setTimeout(() => { killTimer = undefined; killNodeStep("SIGKILL"); - }, NODE_STEP_ABORT_KILL_GRACE_MS); + }, abortKillGraceMs); killTimer.unref?.(); }; function cleanup() { @@ -667,7 +677,11 @@ export async function runNodeStepsInParallel(steps) { const abortController = new AbortController(); const results = await Promise.allSettled( steps.map((step) => - runNodeStep(step.label, step.args, step.timeoutMs, { abortController, env: step.env }), + runNodeStep(step.label, step.args, step.timeoutMs, { + abortController, + abortKillGraceMs: step.abortKillGraceMs, + env: step.env, + }), ), ); const firstFailure = results.find((result) => result.status === "rejected"); diff --git a/scripts/profile-extension-memory.mjs b/scripts/profile-extension-memory.mjs index 7881bd5f05e2..d937be5f53fd 100644 --- a/scripts/profile-extension-memory.mjs +++ b/scripts/profile-extension-memory.mjs @@ -15,6 +15,7 @@ import { formatErrorMessage } from "./lib/error-format.mjs"; const DEFAULT_CONCURRENCY = 6; const DEFAULT_TIMEOUT_MS = 90_000; const DEFAULT_COMBINED_TIMEOUT_MS = 180_000; +const DEFAULT_CHILD_SHUTDOWN_GRACE_MS = 1_000; const DEFAULT_TOP = 10; const OUTPUT_CAPTURE_MAX_CHARS = 128 * 1024; const STDERR_PREVIEW_MAX_CHARS = 8 * 1024; @@ -24,7 +25,7 @@ const PARENT_SIGNAL_EXIT_CODES = new Map([ ["SIGINT", 130], ["SIGTERM", 143], ]); -const activeCaseChildren = new Set(); +const activeCaseChildren = new Map(); const parentSignalHandlers = new Map(); let parentSignalHandlersInstalled = false; let parentSignalShutdownStarted = false; @@ -203,6 +204,7 @@ export async function runCase({ name, body, timeoutMs, + shutdownGraceMs = DEFAULT_CHILD_SHUTDOWN_GRACE_MS, spawnImpl = spawn, }) { return await new Promise((resolve) => { @@ -216,7 +218,7 @@ export async function runCase({ stdio: ["ignore", "pipe", "pipe"], }, ); - trackActiveCaseChild(child); + trackActiveCaseChild(child, shutdownGraceMs); let stdout = createOutputCapture(); let stderr = createOutputCapture(); @@ -265,7 +267,7 @@ export async function runCase({ child.on("close", (code, signal) => { void (async () => { if (timedOut) { - await waitForChildProcessTreeExit(child, 1_000); + await waitForChildProcessTreeExit(child, shutdownGraceMs); } const stderrText = formatCapturedOutput(stderr); settle({ @@ -321,8 +323,8 @@ function childProcessTreeIsAlive(child) { } } -function trackActiveCaseChild(child) { - activeCaseChildren.add(child); +function trackActiveCaseChild(child, shutdownGraceMs) { + activeCaseChildren.set(child, shutdownGraceMs); installParentSignalHandlers(); } @@ -365,7 +367,7 @@ function removeInstalledParentSignalHandlers() { function handleParentSignal(signal) { if (parentSignalShutdownStarted) { - for (const child of activeCaseChildren) { + for (const child of activeCaseChildren.keys()) { signalChildProcessTree(child, "SIGKILL"); } return; @@ -375,17 +377,21 @@ function handleParentSignal(signal) { } async function cleanupActiveCaseChildrenForParentSignal(signal) { - const children = [...activeCaseChildren]; - for (const child of children) { + const children = [...activeCaseChildren.entries()]; + for (const [child] of children) { signalChildProcessTree(child, signal); } - await Promise.all(children.map((child) => waitForChildProcessTreeExit(child, 1_000))); - for (const child of children) { + await Promise.all( + children.map(([child, shutdownGraceMs]) => waitForChildProcessTreeExit(child, shutdownGraceMs)), + ); + for (const [child] of children) { if (childProcessTreeIsAlive(child)) { signalChildProcessTree(child, "SIGKILL"); } } - await Promise.all(children.map((child) => waitForChildProcessTreeExit(child, 1_000))); + await Promise.all( + children.map(([child, shutdownGraceMs]) => waitForChildProcessTreeExit(child, shutdownGraceMs)), + ); removeInstalledParentSignalHandlers(); process.exit(PARENT_SIGNAL_EXIT_CODES.get(signal) ?? 1); } diff --git a/scripts/qa/ux-matrix-evidence-producer.ts b/scripts/qa/ux-matrix-evidence-producer.ts index 4730f77e6889..7a1d744daf8c 100644 --- a/scripts/qa/ux-matrix-evidence-producer.ts +++ b/scripts/qa/ux-matrix-evidence-producer.ts @@ -128,6 +128,30 @@ function parseOptions(argv: readonly string[]): ProducerOptions { }; } +type ProducerCliOutput = { + error: (message: string) => void; + log: (message: string) => void; +}; + +export async function runUxMatrixEvidenceProducerCli( + argv: readonly string[], + output: ProducerCliOutput = console, +): Promise { + try { + if (isHelpRequest(argv)) { + output.log(usage()); + return 0; + } + const result = await runUxMatrixEvidenceProducer(parseOptions(argv)); + output.log(`UX Matrix evidence: ${path.join(result.artifactBase, QA_EVIDENCE_FILENAME)}`); + output.log(`UX Matrix entries: ${result.evidence.entries.length}`); + return 0; + } catch (error) { + output.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} + async function writeJson(filePath: string, value: unknown) { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); @@ -784,21 +808,5 @@ export async function runUxMatrixEvidenceProducer(options: ProducerOptions) { } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - (async () => { - const cliArgs = process.argv.slice(2); - if (isHelpRequest(cliArgs)) { - console.log(usage()); - return; - } - const result = await runUxMatrixEvidenceProducer(parseOptions(cliArgs)); - console.log(`UX Matrix evidence: ${path.join(result.artifactBase, QA_EVIDENCE_FILENAME)}`); - console.log(`UX Matrix entries: ${result.evidence.entries.length}`); - })() - .then(() => { - process.exitCode = 0; - }) - .catch((error: unknown) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); + process.exitCode = await runUxMatrixEvidenceProducerCli(process.argv.slice(2)); } diff --git a/scripts/resolve-openclaw-package-candidate.mjs b/scripts/resolve-openclaw-package-candidate.mjs index aa49a946090a..c30005417234 100644 --- a/scripts/resolve-openclaw-package-candidate.mjs +++ b/scripts/resolve-openclaw-package-candidate.mjs @@ -25,6 +25,7 @@ export const ARTIFACT_TARBALL_SCAN_MAX_ENTRIES = 10_000; const COMMAND_STDOUT_CAPTURE_MAX_CHARS = 8 * 1024 * 1024; const COMMAND_STDERR_CAPTURE_MAX_CHARS = 128 * 1024; const COMMAND_TIMEOUT_KILL_AFTER_MS = 5_000; +const FORWARDED_SIGNAL_KILL_AFTER_MS = 250; const COMMAND_PROCESS_TREE_EXIT_POLL_MS = 50; const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; const ACTIVE_CHILD_KILLERS = new Set(); @@ -58,7 +59,7 @@ for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { killChild("SIGKILL"); } process.exit(forwardedSignalExitCode); - }, COMMAND_TIMEOUT_KILL_AFTER_MS); + }, FORWARDED_SIGNAL_KILL_AFTER_MS); }); } export const OPENCLAW_PACKAGE_SPEC_RE = @@ -457,7 +458,7 @@ async function assertExpectedSha256(file, expected) { export const assertExpectedSha256ForTest = assertExpectedSha256; -async function findSingleTarball(dir) { +async function findSingleTarball(dir, maxEntries = ARTIFACT_TARBALL_SCAN_MAX_ENTRIES) { const root = path.resolve(ROOT_DIR, dir); const pending = [root]; const tarballs = []; @@ -471,9 +472,9 @@ async function findSingleTarball(dir) { const handle = await fs.opendir(currentDir); for await (const entry of handle) { scannedEntries += 1; - if (scannedEntries > ARTIFACT_TARBALL_SCAN_MAX_ENTRIES) { + if (scannedEntries > maxEntries) { throw new Error( - `source=artifact scan exceeded ${ARTIFACT_TARBALL_SCAN_MAX_ENTRIES} filesystem entries under ${dir}; provide a smaller artifact directory containing exactly one .tgz.`, + `source=artifact scan exceeded ${maxEntries} filesystem entries under ${dir}; provide a smaller artifact directory containing exactly one .tgz.`, ); } diff --git a/scripts/run-additional-boundary-checks.mjs b/scripts/run-additional-boundary-checks.mjs index ad01e1808c86..dc772054bf11 100644 --- a/scripts/run-additional-boundary-checks.mjs +++ b/scripts/run-additional-boundary-checks.mjs @@ -6,9 +6,10 @@ import { performance } from "node:perf_hooks"; const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000; const DEFAULT_OUTPUT_MAX_BYTES = 512 * 1024; -const TIMEOUT_KILL_GRACE_MS = 5_000; +// Boundary checks are disposable subprocesses; bound descendant cleanup after timeout. +const TIMEOUT_KILL_GRACE_MS = 250; const PROCESS_GROUP_EXIT_POLL_MS = 25; -const POST_FORCE_KILL_WAIT_MS = 1_000; +const POST_FORCE_KILL_WAIT_MS = 250; const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; /** Ordered list of supplemental boundary checks used by CI sharding. */ diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index e40c4a9a49fe..aae33031af21 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -998,9 +998,9 @@ export function resolveTestProjectsRunnerSpawnParams(env, platform = process.pla }; } -function spawnTestProjectsRunner(argv, env) { +function spawnTestProjectsRunner(argv, env, options = {}) { let forwardedSignal = null; - const child = spawn(process.execPath, [testProjectsRunnerPath, ...argv], { + const child = spawn(process.execPath, [options.runnerPath ?? testProjectsRunnerPath, ...argv], { ...resolveTestProjectsRunnerSpawnParams(env), }); const teardown = installVitestProcessGroupCleanup({ @@ -1014,6 +1014,30 @@ function spawnTestProjectsRunner(argv, env) { return { child, getForwardedSignal: () => forwardedSignal, teardown }; } +export function runTestProjectsDelegation(argv, env, options = {}) { + const { child, getForwardedSignal, teardown } = spawnTestProjectsRunner(argv, env, options); + child.on("exit", (code, signal) => { + teardown(); + const forwardedSignal = getForwardedSignal(); + if (forwardedSignal) { + forceKillVitestProcessGroup(child); + process.kill(process.pid, forwardedSignal); + return; + } + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); + }); + child.on("error", (error) => { + teardown(); + console.error(error); + process.exit(1); + }); + return child; +} + function main(argv = process.argv.slice(2), env = process.env) { if (argv.length === 0) { console.error("usage: node scripts/run-vitest.mjs "); @@ -1033,26 +1057,7 @@ function main(argv = process.argv.slice(2), env = process.env) { const delegatedArgs = resolveTestProjectsDelegationArgs(argv); if (delegatedArgs) { - const { child, getForwardedSignal, teardown } = spawnTestProjectsRunner(delegatedArgs, env); - child.on("exit", (code, signal) => { - teardown(); - const forwardedSignal = getForwardedSignal(); - if (forwardedSignal) { - forceKillVitestProcessGroup(child); - process.kill(process.pid, forwardedSignal); - return; - } - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 1); - }); - child.on("error", (error) => { - teardown(); - console.error(error); - process.exit(1); - }); + runTestProjectsDelegation(delegatedArgs, env); return; } diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index bb595d8518a7..f4191ee63a05 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -438,7 +438,7 @@ async function writeTimingStore(timingStore, results) { console.log(`==> Docker lane timings: ${timingStore.file}`); } -async function writeRunSummary(logDir, summary) { +export async function writeRunSummary(logDir, summary) { const file = path.join(logDir, "summary.json"); const payload = { ...summary, @@ -594,7 +594,7 @@ export function runShellCommand({ env, stdio: pipeOutput ? ["ignore", "pipe", "pipe"] : "inherit", }); - activeChildren.add(child); + activeChildren.set(child, resolvedTimeoutKillGraceMs); let timedOut = false; let noOutputTimedOut = false; let killTimer; @@ -614,10 +614,7 @@ export function runShellCommand({ } terminateChild(child, "SIGTERM"); killAt = Date.now() + resolvedTimeoutKillGraceMs; - killTimer = setTimeout( - () => terminateChild(child, "SIGKILL"), - resolvedTimeoutKillGraceMs, - ); + killTimer = setTimeout(() => terminateChild(child, "SIGKILL"), resolvedTimeoutKillGraceMs); killTimer.unref?.(); }; const resetNoOutputTimer = () => { @@ -725,7 +722,7 @@ export function runShellCaptureCommand({ env, stdio: ["ignore", "pipe", "pipe"], }); - activeChildren.add(child); + activeChildren.set(child, resolvedTimeoutKillGraceMs); let stdout = ""; let stderr = ""; let stdoutTruncated = false; @@ -878,6 +875,25 @@ async function runCleanupSmoke(baseEnv, logDir, command, startedAtMs) { }; } +export async function runCleanupSmokePhase(baseEnv, logDir, phases) { + const command = "pnpm test:docker:cleanup"; + const startedAtMs = Date.now(); + let failure; + try { + await runPhase(phases, CLEANUP_SMOKE_NAME, {}, async () => { + failure = await runCleanupSmoke(baseEnv, logDir, command, startedAtMs); + if (failure) { + throw new Error( + `Run cleanup smoke after parallel lanes failed with status ${failure.status}`, + ); + } + }); + } catch (error) { + failure ??= await recordCleanupSmokeFailure(error, baseEnv, logDir, command, startedAtMs); + } + return failure; +} + async function runForegroundGroup(entries, env) { const failures = []; for (const entry of entries) { @@ -1298,7 +1314,7 @@ async function printFailureSummary(failures, tailLines) { } } -const activeChildren = new Set(); +const activeChildren = new Map(); let activeChildrenShutdownPromise; function shellCommandSkippedForShutdown() { @@ -1376,7 +1392,7 @@ function terminateChild(child, signal) { } function terminateActiveChildren(signal) { - for (const child of activeChildren) { + for (const child of activeChildren.keys()) { terminateChild(child, signal); } } @@ -1386,13 +1402,13 @@ async function shutdownActiveChildren(signal, exitCode) { terminateActiveChildren("SIGKILL"); return activeChildrenShutdownPromise; } - const children = [...activeChildren]; + const children = [...activeChildren.entries()]; terminateActiveChildren(signal); activeChildrenShutdownPromise = Promise.all( - children.map((child) => + children.map(([child, timeoutKillGraceMs]) => finishTimedOutShellProcessTree(child, { - killAt: Date.now() + SHELL_TIMEOUT_KILL_GRACE_MS, - timeoutKillGraceMs: SHELL_TIMEOUT_KILL_GRACE_MS, + killAt: Date.now() + timeoutKillGraceMs, + timeoutKillGraceMs, }), ), ).finally(() => { @@ -1717,32 +1733,7 @@ async function main() { } if (profile === DEFAULT_PROFILE && selectedLaneNames.length === 0) { - const cleanupSmokeCommand = "pnpm test:docker:cleanup"; - const cleanupStartedAtMs = Date.now(); - let cleanupFailure; - try { - await runPhase(phases, CLEANUP_SMOKE_NAME, {}, async () => { - cleanupFailure = await runCleanupSmoke( - baseEnv, - logDir, - cleanupSmokeCommand, - cleanupStartedAtMs, - ); - if (cleanupFailure) { - throw new Error( - `Run cleanup smoke after parallel lanes failed with status ${cleanupFailure.status}`, - ); - } - }); - } catch (error) { - cleanupFailure ??= await recordCleanupSmokeFailure( - error, - baseEnv, - logDir, - cleanupSmokeCommand, - cleanupStartedAtMs, - ); - } + const cleanupFailure = await runCleanupSmokePhase(baseEnv, logDir, phases); if (cleanupFailure) { failures.push(cleanupFailure); } diff --git a/scripts/test-group-report.mjs b/scripts/test-group-report.mjs index 094a619625c3..5fcb76cffd05 100644 --- a/scripts/test-group-report.mjs +++ b/scripts/test-group-report.mjs @@ -596,6 +596,9 @@ async function runVitestJsonReport(params) { env: { ...process.env, ...params.env, + // The JSON reporter can stay silent for the entire config. The profiler + // owns the wall-clock timeout and process-group cleanup for this child. + OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "0", NODE_OPTIONS: [ (params.env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS)?.trim(), ...resolveVitestNodeArgs({ ...process.env, ...params.env }).filter( @@ -908,15 +911,35 @@ export function resolveRunPlanConcurrency(args, runPlanCount) { return Math.min(2, runPlanCount); } +function hasExplicitIsolationArg(args) { + return args.some( + (arg) => arg === "--isolate" || arg === "--no-isolate" || arg.startsWith("--isolate="), + ); +} + +/** + * Gives full-suite duration reports one process lifetime per test file. + * This prevents unrelated retained module graphs and GC pauses from being + * attributed to whichever assertion happens to run next in a shared worker. + */ +export function resolveReportVitestArgs(args) { + if (!args.fullSuite || hasExplicitIsolationArg(args.vitestArgs)) { + return args.vitestArgs; + } + return [...args.vitestArgs, "--isolate=true"]; +} + /** * Builds concrete report run specs from parsed args and config plans. */ export function resolveReportRunSpecs(args, runPlans, params = {}) { const concurrency = params.concurrency ?? resolveRunPlanConcurrency(args, runPlans.length); const env = params.env ?? process.env; + const vitestArgs = resolveReportVitestArgs(args); const specs = runPlans.map((plan) => ({ ...plan, env: resolveFullSuiteVitestEnv(args, env, plan.label), + vitestArgs, })); if (concurrency <= 1) { return specs; @@ -933,11 +956,34 @@ function printRunLine(run) { ); } -async function runReportPlans(params) { +function printSlowTestsForRun(entry, maxTestMs) { + if (maxTestMs === null || !fs.existsSync(entry.reportPath)) { + return; + } + const input = readReportInputs([entry]).reports[0]; + if (!input) { + return; + } + const report = buildGroupedTestReport({ + groupBy: "area", + maxTestMs, + reports: [input], + }); + for (const test of report.slowTests) { + console.log( + `[test-group-report] slow-test config=${test.config} duration=${formatMs(test.durationMs)} file=${test.file} name=${test.fullName}`, + ); + } +} + +export async function runReportPlans(params) { const concurrency = resolveRunPlanConcurrency(params.args, params.runPlans.length); const runSpecs = resolveReportRunSpecs(params.args, params.runPlans, { concurrency }); + const runVitest = params.runVitestJsonReport ?? runVitestJsonReport; const results = []; results.length = runSpecs.length; + const runs = []; + runs.length = runSpecs.length; let nextIndex = 0; let failed = false; let exitCode = 0; @@ -948,7 +994,7 @@ async function runReportPlans(params) { nextIndex += 1; const plan = runSpecs[index]; const slug = sanitizePathSegment(plan.label); - const run = await runVitestJsonReport({ + const run = await runVitest({ config: plan.config, forwardedArgs: plan.forwardedArgs, env: plan.env, @@ -958,8 +1004,9 @@ async function runReportPlans(params) { rss: params.args.rss, timeoutMs: params.args.timeoutMs, killGraceMs: params.args.killGraceMs, - vitestArgs: params.args.vitestArgs, + vitestArgs: plan.vitestArgs, }); + runs[index] = run; printRunLine(run); let includeEntry = true; if (run.status !== 0) { @@ -968,7 +1015,6 @@ async function runReportPlans(params) { console.error( `[test-group-report] missing JSON report for failed config; see ${run.logPath}`, ); - exitCode = 1; includeEntry = false; } else { console.error( @@ -976,12 +1022,14 @@ async function runReportPlans(params) { ); } if (!params.args.allowFailures) { - exitCode = run.status; + exitCode = run.status || 1; } } - results[index] = includeEntry - ? { config: plan.label, reportPath: run.reportPath, run } - : null; + const entry = includeEntry ? { config: plan.label, reportPath: run.reportPath, run } : null; + results[index] = entry; + if (entry) { + printSlowTestsForRun(entry, params.args.maxTestMs); + } } } @@ -995,6 +1043,7 @@ async function runReportPlans(params) { failed, exitCode, runEntries: results.filter(Boolean), + runs: runs.filter(Boolean), }; } @@ -1030,6 +1079,7 @@ async function main() { const { reportDir, logDir } = resolveReportArtifactDirs(output); const runEntries = []; + const runs = []; const runPlans = resolveRunPlans(args); let failed = false; let exitCode = 0; @@ -1046,6 +1096,7 @@ async function main() { failed = result.failed; exitCode = result.exitCode; runEntries.push(...result.runEntries); + runs.push(...result.runs); } if (exitCode !== 0) { @@ -1083,7 +1134,7 @@ async function main() { ...report, command: "test-group-report", failed, - runs: reportInputs.map((entry) => entry.run).filter(Boolean), + runs: runs.length > 0 ? runs : reportInputs.map((entry) => entry.run).filter(Boolean), system: { node: process.version, platform: process.platform, diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index b6e61c8b60cc..647e070be0bd 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -3186,7 +3186,7 @@ function resolveDocsI18nBehaviorTargets(changedPath) { if (!/^scripts\/docs-i18n\/testdata\/behavior\/[^/]+\/[^/]+$/u.test(changedPath)) { return null; } - return ["test/scripts/docs-i18n-behavior.test.ts"]; + return ["test/scripts/docs-i18n.test.ts"]; } function resolveDocsI18nGoTargets(changedPath) { diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index f1b58aa68874..726eeb8874dd 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -34,9 +34,10 @@ const CGROUP_MEMORY_LIMIT_PATHS = [ "/sys/fs/cgroup/memory/memory.limit_in_bytes", ]; const PROC_MEMINFO_PATH = "/proc/meminfo"; -const TERMINATION_GRACE_MS = 5_000; +// Build descendants get a short cleanup window; a timed-out build must not hold CI for seconds. +const TERMINATION_GRACE_MS = 250; const PROCESS_GROUP_EXIT_POLL_MS = 25; -const POST_FORCE_KILL_WAIT_MS = 1_000; +const POST_FORCE_KILL_WAIT_MS = 250; const ROOT_TSDOWN_OUTPUT_ROOTS = ["dist", "dist-runtime"]; const PRESERVED_TSDOWN_OUTPUT_FILES = ["dist/cli-startup-metadata.json"]; const PRESERVE_CLI_STARTUP_METADATA_ENV = "OPENCLAW_PRESERVE_CLI_STARTUP_METADATA"; diff --git a/scripts/ui.js b/scripts/ui.js index 1f65d79c6272..3ee86b353b44 100644 --- a/scripts/ui.js +++ b/scripts/ui.js @@ -13,6 +13,7 @@ const repoRoot = path.resolve(here, ".."); const uiDir = path.join(repoRoot, "ui"); const WINDOWS_CMD_EXE_EXTENSIONS = new Set([".cmd", ".bat"]); +const FORWARDED_SIGNAL_KILL_GRACE_MS = 250; function usage() { // keep this tiny; it's invoked from npm scripts too @@ -140,7 +141,7 @@ function runSpawnCall(spawnCall, label) { forwardedSignalDrainTimer = setInterval(waitForForwardedSignalChildren, 25); forceKillTimer = setTimeout(() => { signalProcessTree(child, "SIGKILL", forwardedSignalPids); - }, 5_000); + }, FORWARDED_SIGNAL_KILL_GRACE_MS); forceKillTimer.unref?.(); } }, diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 4452854ac873..e0a33f481e78 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { AcpInitializeSessionInput } from "../acp/control-plane/manager.types.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -13,6 +13,7 @@ import { type SessionBindingPlacement, type SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; +import { resolveThinkingDefault } from "./model-selection.js"; function createDefaultSpawnConfig(): OpenClawConfig { return { @@ -686,6 +687,16 @@ function enableTelegramCurrentConversationBindings(): void { } describe("spawnAcpDirect", () => { + beforeAll(() => { + resolveThinkingDefault({ + cfg: { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + }, + provider: "anthropic", + model: "claude-sonnet-4-6", + }); + }); + beforeEach(() => { replaceSpawnConfig(createDefaultSpawnConfig()); hoisted.areHeartbeatsEnabledMock.mockReset().mockReturnValue(true); diff --git a/src/agents/agent-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts index de52ba217671..40c59b876db1 100644 --- a/src/agents/agent-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -722,15 +722,15 @@ describe("session MCP runtime", () => { expect(activeLeases).toBe(0); }); - it("keeps MCP tools/list responses that exceed the connection timeout but finish within the internal catalog timeout", async () => { + it("uses the internal catalog timeout for MCP tools/list after connecting", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-slow-listtools-")); const serverPath = path.join(tempDir, "slow-list-tools.mjs"); const logPath = path.join(tempDir, "server.log"); - testing.setBundleMcpCatalogListTimeoutMsForTest(3_000); + testing.setBundleMcpCatalogListTimeoutMsForTest(300); await writeListToolsMcpServer({ filePath: serverPath, logPath, - delayMs: 1_250, + delayMs: 100, }); const runtime = await getOrCreateSessionMcpRuntime({ @@ -758,7 +758,7 @@ describe("session MCP runtime", () => { serverName: "slowListTools", toolCount: 1, }); - await expect(fs.readFile(logPath, "utf8")).resolves.toContain("delay tools/list 1250"); + await expect(fs.readFile(logPath, "utf8")).resolves.toContain("delay tools/list 100"); } finally { await runtime.dispose(); await fs.rm(tempDir, { recursive: true, force: true }); @@ -1901,6 +1901,7 @@ describe("disposeSession timeout", () => { "force-closes transport and client when terminateSession hangs past the timeout", { timeout: 15_000 }, async () => { + testing.setBundleMcpDisposeTimeoutMsForTest(100); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-force-close-")); const serverPath = path.join(tempDir, "hanging-terminate.mjs"); const logPath = path.join(tempDir, "server.log"); @@ -1984,10 +1985,7 @@ process.stdin.on("end", () => { await runtime.dispose(); const elapsed = Date.now() - start; - // The timeout fires at 5s and force-closes transport + client, - // so disposal must complete well before 8s even when the process - // ignores shutdown signals. - expect(elapsed).toBeLessThan(8_000); + expect(elapsed).toBeLessThan(1_000); await retireSessionMcpRuntime({ sessionId: "session-force-close-timeout", @@ -2001,6 +1999,7 @@ process.stdin.on("end", () => { "completes disposal even when the MCP server process ignores shutdown", { timeout: 15_000 }, async () => { + testing.setBundleMcpDisposeTimeoutMsForTest(100); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-dispose-timeout-")); const serverPath = path.join(tempDir, "hanging-close.mjs"); const logPath = path.join(tempDir, "server.log"); @@ -2083,9 +2082,7 @@ process.stdin.on("end", () => { await runtime.dispose(); const elapsed = Date.now() - start; - // Dispose should complete within DISPOSE_TIMEOUT_MS (5s) + a small buffer, - // not hang indefinitely. - expect(elapsed).toBeLessThan(8_000); + expect(elapsed).toBeLessThan(1_000); await fs.rm(tempDir, { recursive: true, force: true }); }, @@ -2095,6 +2092,7 @@ process.stdin.on("end", () => { "force-closes streamable-http transport when DELETE hangs past the timeout", { timeout: 15_000 }, async () => { + testing.setBundleMcpDisposeTimeoutMsForTest(100); const sessionId = "test-session-" + Date.now(); const server = http.createServer((req, res) => { if (req.method === "GET") { @@ -2176,10 +2174,7 @@ process.stdin.on("end", () => { await runtime.dispose(); const elapsed = Date.now() - start; - // The timeout fires at 5s and force-closes transport + client, - // so disposal must complete well before 8s even when the DELETE - // request never receives a response. - expect(elapsed).toBeLessThan(8_000); + expect(elapsed).toBeLessThan(1_000); } finally { server.close(); } diff --git a/src/agents/agent-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts index 4da471f5547c..deb2be2ce8db 100644 --- a/src/agents/agent-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -35,6 +35,7 @@ import type { } from "./agent-bundle-mcp-types.js"; import { loadEmbeddedAgentMcpConfig } from "./embedded-agent-mcp.js"; import { isMcpConfigRecord } from "./mcp-config-shared.js"; +import { OpenClawStdioClientTransport } from "./mcp-stdio-transport.js"; import { resolveMcpTransport } from "./mcp-transport.js"; type BundleMcpSession = { @@ -66,9 +67,23 @@ const SESSION_MCP_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000; const BUNDLE_MCP_FAILURE_THRESHOLD = 3; const BUNDLE_MCP_FAILURE_COOLDOWN_MS = 60_000; const BUNDLE_MCP_CATALOG_LIST_TIMEOUT_MS = 1_500; +const BUNDLE_MCP_DISPOSE_TIMEOUT_MS = 5_000; const BUNDLE_MCP_CATALOG_CONNECT_CONCURRENCY = 6; const BUNDLE_MCP_METADATA_TEXT_LIMIT = 1_200; let bundleMcpCatalogListTimeoutMs: number | undefined; +const BUNDLE_MCP_TEST_STATE_KEY = Symbol.for("openclaw.bundleMcpTestState"); +type BundleMcpTestState = { disposeTimeoutMs?: number }; + +function getBundleMcpTestState(): BundleMcpTestState { + const globalStore = globalThis as Record; + const existing = globalStore[BUNDLE_MCP_TEST_STATE_KEY] as BundleMcpTestState | undefined; + if (existing) { + return existing; + } + const state: BundleMcpTestState = {}; + globalStore[BUNDLE_MCP_TEST_STATE_KEY] = state; + return state; +} type McpToolSelection = { include?: readonly string[]; @@ -287,6 +302,15 @@ function setBundleMcpCatalogListTimeoutMsForTest(timeoutMs?: number): void { ? Math.floor(timeoutMs) : undefined; } + +function setBundleMcpDisposeTimeoutMsForTest(timeoutMs?: number): void { + // Non-isolated test workers can reload this module while a facade still + // references an older copy. Share the override across those copies. + getBundleMcpTestState().disposeTimeoutMs = + typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Math.floor(timeoutMs) + : undefined; +} async function listAllResources(client: Client, timeoutMs: number) { const resources: unknown[] = []; let cursor: string | undefined; @@ -386,14 +410,30 @@ function summarizeServerCapabilities(capabilities: ServerCapabilities | undefine : undefined, }; } -// Safety net for hung MCP servers, not a tuning parameter. -const DISPOSE_TIMEOUT_MS = 5_000; +async function settleWithin(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + return await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + timer = setTimeout(() => { + resolve(); + }, timeoutMs); + timer.unref?.(); + }).then(() => false), + ]).finally(() => { + if (timer) { + clearTimeout(timer); + } + }); +} async function disposeSession(session: BundleMcpSession) { session.detachStderr?.(); - let timer: ReturnType | undefined; - let timedOut = false; - await Promise.race([ + const timeoutMs = getBundleMcpTestState().disposeTimeoutMs ?? BUNDLE_MCP_DISPOSE_TIMEOUT_MS; + const closed = await settleWithin( (async () => { if (session.transportType === "streamable-http") { await (session.transport as StreamableHTTPClientTransport) @@ -403,23 +443,17 @@ async function disposeSession(session: BundleMcpSession) { await session.transport.close().catch(() => {}); await session.client.close().catch(() => {}); })(), - new Promise((resolve) => { - timer = setTimeout(() => { - timedOut = true; - resolve(); - }, DISPOSE_TIMEOUT_MS); - timer.unref?.(); - }), - ]).finally(() => { - if (timer) { - clearTimeout(timer); - } - }); - if (timedOut) { + timeoutMs, + ); + if (!closed) { // Force-close transport and client so a hung terminateSession() DELETE - // gets its AbortSignal triggered by the transport teardown. - await session.transport.close().catch(() => {}); - await session.client.close().catch(() => {}); + // gets its AbortSignal triggered by teardown. Stdio owns a process group, + // so force it dead before disposal can report completion. + const transportClose = + session.transport instanceof OpenClawStdioClientTransport + ? session.transport.forceClose() + : session.transport.close(); + await settleWithin(Promise.allSettled([transportClose, session.client.close()]), timeoutMs); } } @@ -1275,11 +1309,13 @@ export const testing = { async resetSessionMcpRuntimeManager() { await disposeAllSessionMcpRuntimes(); setBundleMcpCatalogListTimeoutMsForTest(); + setBundleMcpDisposeTimeoutMsForTest(); }, getCachedSessionIds() { return getSessionMcpRuntimeManager().listSessionIds(); }, setBundleMcpCatalogListTimeoutMsForTest, + setBundleMcpDisposeTimeoutMsForTest, resolveSessionMcpRuntimeIdleTtlMs, }; export { testing as __testing }; diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index f5f803592e1a..d0cdca6ead0e 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -1,5 +1,5 @@ /** Tests live model switching behavior in active agent command sessions. */ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END } from "./internal-events.js"; import { LiveSessionModelSwitchError } from "./live-model-switch-error.js"; @@ -323,6 +323,12 @@ vi.mock("../logging/subsystem.js", () => ({ }, })); +afterAll(() => { + // This suite runs in a shared worker; do not leak its module-level logger + // mock into later files that verify real warning diagnostics. + vi.doUnmock("../logging/subsystem.js"); +}); + vi.mock("../channels/model-overrides.js", () => ({ resolveChannelModelOverride: (params: unknown) => state.resolveChannelModelOverrideMock(params), })); diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index b7e7227a70ff..2b00703b33ce 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js"; @@ -24,6 +24,7 @@ import { resolveMcpLoopbackYieldContext, updateMcpLoopbackToolCallCapture, } from "../gateway/mcp-http.loopback-runtime.js"; +import { resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { getProcessSupervisor } from "../process/supervisor/index.js"; import type { RunExit } from "../process/supervisor/types.js"; @@ -308,7 +309,17 @@ const CLI_RESEED_PROMPT = "Continue this conversation using the OpenClaw transcript below as prior session history.\n\n\nUser: earlier context\n\n\n\nhi\n"; describe("runCliAgent reliability", () => { + beforeEach(() => { + // Binding-flush retry timing has dedicated coverage. Reliability cases only + // need its stable not-yet-flushed outcome, without filesystem polling/sleeps. + setCliRunnerTestDeps({ + claudeCliSessionTranscriptHasContent: async () => false, + delay: async () => {}, + }); + }); + afterEach(() => { + restoreCliRunnerTestDeps(); replyRunTesting.resetReplyRunRegistry(); mockGetGlobalHookRunner.mockReset(); mockAutoCapture.mockReset(); @@ -318,6 +329,8 @@ describe("runCliAgent reliability", () => { sessionFileEnvSnapshot?.restore(); sessionFileEnvSnapshot = undefined; resetClaudeLiveSessionsForTest(); + resetDiagnosticEventsForTest(); + vi.useRealTimers(); }); it("fails with timeout when no-output watchdog trips", async () => { @@ -1659,6 +1672,7 @@ describe("runCliAgent reliability", () => { }); it("keeps non-capture live-session artifacts through fresh recovery retry", async () => { + vi.useFakeTimers(); supervisorSpawnMock.mockClear(); const artifactDir = autoCleanupTempDirs.make("openclaw-live-retry-artifacts-"); const mcpConfigPath = path.join(artifactDir, "mcp.json"); @@ -1807,6 +1821,7 @@ describe("runCliAgent reliability", () => { }, }); await firstSpawned; + await vi.advanceTimersByTimeAsync(1_000); const result = await resultPromise; expect(result.payloads).toEqual([{ text: "fresh ok" }]); diff --git a/src/agents/compaction-planning-worker.test.ts b/src/agents/compaction-planning-worker.test.ts index b48048191ff9..2003ddd70150 100644 --- a/src/agents/compaction-planning-worker.test.ts +++ b/src/agents/compaction-planning-worker.test.ts @@ -1,6 +1,6 @@ // Covers the compaction planning worker boundary and timeout behavior. import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { compactionPlanningWorkerTesting } from "./compaction-planning-worker.js"; import { runCompactionPlanningWorkerInput } from "./compaction-planning.worker.js"; import type { AgentMessage } from "./runtime/index.js"; @@ -20,6 +20,21 @@ function createSyntheticWorkerUrl(source: string): URL { } describe("compaction planning worker", () => { + let packagedSummaryChunks: Awaited< + ReturnType + >; + + beforeAll(async () => { + packagedSummaryChunks = await compactionPlanningWorkerTesting.runCompactionPlanningWorker({ + input: { + kind: "summaryChunks", + messages: [makeMessage(1), makeMessage(2), makeMessage(3)], + maxChunkTokens: 1200, + }, + timeoutMs: 30_000, + }); + }); + it("resolves the packaged worker URL from stable and hashed dist modules", () => { // Hashed bundle names still resolve to the stable worker sibling emitted by // the build, so runtime imports do not depend on the main chunk hash. @@ -42,18 +57,7 @@ describe("compaction planning worker", () => { }); }); - it("plans summary chunks in the packaged worker", async () => { - const packagedSummaryChunks = await compactionPlanningWorkerTesting.runCompactionPlanningWorker( - { - input: { - kind: "summaryChunks", - messages: [makeMessage(1), makeMessage(2), makeMessage(3)], - maxChunkTokens: 1200, - }, - timeoutMs: 30_000, - }, - ); - + it("plans summary chunks in the packaged worker", () => { expect(packagedSummaryChunks.kind).toBe("summaryChunks"); if (packagedSummaryChunks.kind !== "summaryChunks") { return; diff --git a/src/agents/context-runtime-state.test.ts b/src/agents/context-runtime-state.test.ts index 9b1085529145..3cfb22068468 100644 --- a/src/agents/context-runtime-state.test.ts +++ b/src/agents/context-runtime-state.test.ts @@ -1,12 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; import { execNodeEvalSync } from "../test-utils/node-process.js"; -import { lookupCachedContextWindow, providerContextTokenCacheKey } from "./context-cache.js"; -import { - CONTEXT_WINDOW_RUNTIME_STATE, - resetContextWindowCacheForTest, -} from "./context-runtime-state.js"; -import { ensureContextWindowCacheLoaded } from "./context.js"; +import { resetContextWindowCacheForTest } from "./context-runtime-state.js"; afterEach(() => { resetContextWindowCacheForTest(); @@ -38,30 +32,4 @@ describe("context runtime state", () => { expect(output).toBe("0:true:true"); }); - - it("warms fresh caches instead of reusing a pre-generation load promise", async () => { - const legacyLoadPromise = Promise.resolve(); - CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = legacyLoadPromise; - CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration = null; - CONTEXT_WINDOW_RUNTIME_STATE.configuredConfig = { - models: { - providers: { - "fresh-provider": { - baseUrl: "https://example.invalid", - models: [{ id: "fresh-model", contextWindow: 123_456 } as never], - }, - }, - }, - } satisfies OpenClawConfig; - - await ensureContextWindowCacheLoaded(); - - expect( - lookupCachedContextWindow(providerContextTokenCacheKey("fresh-provider", "fresh-model")), - ).toBe(123_456); - expect(CONTEXT_WINDOW_RUNTIME_STATE.loadPromise).not.toBe(legacyLoadPromise); - expect(CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration).toBe( - CONTEXT_WINDOW_RUNTIME_STATE.generation, - ); - }); }); diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 4ed02622e90e..f8a5916b7fae 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -2,6 +2,8 @@ // model resolution. import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { lookupCachedContextWindow, providerContextTokenCacheKey } from "./context-cache.js"; +import { CONTEXT_WINDOW_RUNTIME_STATE } from "./context-runtime-state.js"; type DiscoveredModel = { id: string; @@ -366,6 +368,27 @@ describe("lookupContextTokens", () => { ).toBe(1_048_576); }); + it("warms fresh caches instead of reusing a pre-generation load promise", async () => { + const legacyLoadPromise = Promise.resolve(); + CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = legacyLoadPromise; + CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration = null; + CONTEXT_WINDOW_RUNTIME_STATE.configuredConfig = createContextOverrideConfig( + "fresh-provider", + "fresh-model", + 123_456, + ); + + await contextModule.ensureContextWindowCacheLoaded(); + + expect( + lookupCachedContextWindow(providerContextTokenCacheKey("fresh-provider", "fresh-model")), + ).toBe(123_456); + expect(CONTEXT_WINDOW_RUNTIME_STATE.loadPromise).not.toBe(legacyLoadPromise); + expect(CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration).toBe( + CONTEXT_WINDOW_RUNTIME_STATE.generation, + ); + }); + it("status waits for pending context warmup but releases on timeout", async () => { vi.useFakeTimers(); try { diff --git a/src/agents/context.opencode-go.test.ts b/src/agents/context.opencode-go.test.ts index 99a96e712527..c85aee199260 100644 --- a/src/agents/context.opencode-go.test.ts +++ b/src/agents/context.opencode-go.test.ts @@ -1,25 +1,30 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { resolveMemoryFlushContextWindowTokens } from "../auto-reply/reply/memory-flush.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { refreshContextWindowCache, resetContextWindowCacheForTest } from "./context.js"; describe("OpenCode Go context metadata", () => { - afterEach(() => { - resetContextWindowCacheForTest(); - }); + let contextWindowTokens: number | undefined; + let configuredModels: OpenClawConfig["models"]; - it("warms the provider-owned context window without writing model config", async () => { + beforeAll(async () => { const cfg: OpenClawConfig = {}; await refreshContextWindowCache(cfg); + contextWindowTokens = resolveMemoryFlushContextWindowTokens({ + cfg, + provider: "opencode-go", + modelId: "deepseek-v4-pro", + }); + configuredModels = cfg.models; + }); - expect( - resolveMemoryFlushContextWindowTokens({ - cfg, - provider: "opencode-go", - modelId: "deepseek-v4-pro", - }), - ).toBe(1_000_000); - expect(cfg.models).toBeUndefined(); + afterAll(() => { + resetContextWindowCacheForTest(); + }); + + it("warms the provider-owned context window without writing model config", () => { + expect(contextWindowTokens).toBe(1_000_000); + expect(configuredModels).toBeUndefined(); }); }); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts index 3e20693de117..2ca19569c734 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts @@ -53,6 +53,15 @@ function resolveIncompleteTurnPayloadText( describe("runEmbeddedAgent incomplete-turn safety", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + resetRunOverflowCompactionHarnessMocks(); + mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ assistantTexts: ["warmup"] }), + ); + await runEmbeddedAgent({ + ...overflowBaseRunParams, + runId: "run-incomplete-turn-warmup", + }); }); beforeEach(() => { diff --git a/src/agents/mcp-stdio-transport.test.ts b/src/agents/mcp-stdio-transport.test.ts index 1764983b30ff..79db55b230f3 100644 --- a/src/agents/mcp-stdio-transport.test.ts +++ b/src/agents/mcp-stdio-transport.test.ts @@ -121,6 +121,29 @@ describe("OpenClawStdioClientTransport", () => { await closing; }); + it("force-closes an in-flight repeated graceful shutdown before returning", async () => { + vi.useFakeTimers(); + const child = new MockChildProcess(); + spawnMock.mockReturnValue(child); + + const transport = new OpenClawStdioClientTransport({ command: "npx" }); + const started = transport.start(); + child.emit("spawn"); + await started; + + const closing = transport.close(); + const repeatedClose = transport.close(); + const forced = transport.forceClose(); + expect(signalProcessTreeMock).toHaveBeenCalledWith(4321, "SIGKILL"); + + child.exitCode = 0; + child.emit("close", 0); + await expect(forced).resolves.toBeUndefined(); + await expect(closing).resolves.toBeUndefined(); + await expect(repeatedClose).resolves.toBeUndefined(); + expect(transport.pid).toBeNull(); + }); + it("does not kill the process tree when graceful stdio close exits", async () => { vi.useFakeTimers(); const child = new MockChildProcess(); diff --git a/src/agents/mcp-stdio-transport.ts b/src/agents/mcp-stdio-transport.ts index 2e268128b803..b9f9c53362ee 100644 --- a/src/agents/mcp-stdio-transport.ts +++ b/src/agents/mcp-stdio-transport.ts @@ -36,6 +36,7 @@ export class OpenClawStdioClientTransport implements Transport { private readonly readBuffer = new ReadBuffer(); private readonly stderrStream: PassThrough | null = null; private process?: ChildProcess; + private closingProcess?: ChildProcess; constructor(private readonly serverParams: OpenClawStdioServerParameters) { if (serverParams.stderr === "pipe" || serverParams.stderr === "overlapped") { @@ -97,7 +98,7 @@ export class OpenClawStdioClientTransport implements Transport { } get pid() { - return this.process?.pid ?? null; + return this.process?.pid ?? this.closingProcess?.pid ?? null; } private processReadBuffer() { @@ -115,8 +116,11 @@ export class OpenClawStdioClientTransport implements Transport { } async close(): Promise { - const processToClose = this.process; + const processToClose = this.process ?? this.closingProcess; this.process = undefined; + if (processToClose) { + this.closingProcess = processToClose; + } if (processToClose) { const closePromise = new Promise((resolve) => { processToClose.once("close", () => resolve()); @@ -137,6 +141,25 @@ export class OpenClawStdioClientTransport implements Transport { } } } + if (this.closingProcess === processToClose) { + this.closingProcess = undefined; + } + this.readBuffer.clear(); + } + + async forceClose(): Promise { + const processToClose = this.process ?? this.closingProcess; + this.process = undefined; + if (processToClose?.pid && processToClose.exitCode === null) { + const closePromise = new Promise((resolve) => { + processToClose.once("close", () => resolve()); + }); + signalProcessTree(processToClose.pid, "SIGKILL"); + await Promise.race([closePromise, delay(SIGKILL_REAP_TIMEOUT_MS)]); + } + if (this.closingProcess === processToClose) { + this.closingProcess = undefined; + } this.readBuffer.clear(); } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index cd2ac63b2154..de4a22bec722 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -101,6 +101,26 @@ const providerModelNormalizationMock = vi.hoisted(() => ({ normalizeProviderModelIdWithRuntime: vi.fn(() => undefined), })); +const providerPolicySurfaceMock = vi.hoisted(() => ({ + resolveBundledProviderPolicySurface: vi.fn((providerId: string) => { + if (providerId !== "anthropic" && providerId !== "amazon-bedrock") { + return null; + } + return { + resolveThinkingProfile: (context: { modelId: string }) => + context.modelId.includes("claude-") && context.modelId.includes("4-6") + ? { + levels: [ + { id: "off", label: "off", rank: 0 }, + { id: "adaptive", label: "adaptive", rank: 6 }, + ], + defaultLevel: "adaptive", + } + : undefined, + }; + }), +})); + vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ getCurrentPluginMetadataSnapshot: () => manifestNormalizationSnapshot, })); @@ -110,6 +130,11 @@ vi.mock("./provider-model-normalization.runtime.js", () => ({ providerModelNormalizationMock.normalizeProviderModelIdWithRuntime, })); +vi.mock("../plugins/provider-public-artifacts.js", () => ({ + resolveBundledProviderPolicySurface: + providerPolicySurfaceMock.resolveBundledProviderPolicySurface, +})); + vi.mock("./model-selection-cli.js", () => ({ isCliProvider: () => false, })); @@ -2677,7 +2702,7 @@ describe("model-selection", () => { expect(resolveClaudeCliOpus48Thinking(cfg)).toBe("off"); }); - it("uses bundled provider thinking defaults when no explicit config overrides them", () => { + it("uses provider policy thinking defaults when no explicit config overrides them", () => { const cfg = {} as OpenClawConfig; expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive"); diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index 5b9dd01ddc6a..473333c6f96e 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -1,14 +1,13 @@ // Verifies models.json planning applies config env vars and discovery scope. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { createConfigRuntimeEnv } from "../config/env-vars.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { withEnvAsync } from "../test-utils/env.js"; -import { saveAuthProfileStore } from "./auth-profiles/store.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + replaceRuntimeAuthProfileStoreSnapshots, +} from "./auth-profiles/store.js"; import { unsetEnv, withTempEnv } from "./models-config.e2e-harness.js"; import { planOpenClawModelsJsonWithDeps, @@ -18,7 +17,6 @@ import type { ProviderConfig } from "./models-config.providers.secrets.js"; import { encodePluginModelCatalogRelativePath } from "./plugin-model-catalog.js"; const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV"; -const BUNDLED_PLUGINS_DIR = fileURLToPath(new URL("../../extensions/", import.meta.url)); function createImplicitOpenRouterProvider(): ProviderConfig { return { @@ -468,22 +466,23 @@ describe("models-config", () => { }); it("keeps google-vertex static catalog rows when an auth profile supplies the API key", async () => { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-models-")); + const agentDir = "/tmp/openclaw-google-vertex-models-profile"; try { - saveAuthProfileStore( + replaceRuntimeAuthProfileStoreSnapshots([ { - version: 1, - profiles: { - "google-vertex:default": { - type: "api_key", - provider: "google-vertex", - keyRef: { source: "env", provider: "default", id: "GOOGLE_CLOUD_API_KEY" }, + agentDir, + store: { + version: 1, + profiles: { + "google-vertex:default": { + type: "api_key", + provider: "google-vertex", + keyRef: { source: "env", provider: "default", id: "GOOGLE_CLOUD_API_KEY" }, + }, }, }, }, - agentDir, - { filterExternalAuthProfiles: false, syncExternalCli: false }, - ); + ]); const plan = await planOpenClawModelsJsonWithDeps( { @@ -526,71 +525,56 @@ describe("models-config", () => { "gemini-2.5-pro", ]); } finally { - await fs.rm(agentDir, { recursive: true, force: true }); + clearRuntimeAuthProfileStoreSnapshots(); } }); - it("keeps google-vertex static catalog rows when ADC auth evidence supplies the marker", async () => { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-models-")); - const credentialsPath = path.join(agentDir, "application_default_credentials.json"); - await fs.writeFile(credentialsPath, JSON.stringify({ type: "authorized_user" }), "utf8"); - try { - const plan = await withEnvAsync( - { - OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR, - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - }, - async () => - await planOpenClawModelsJsonWithDeps( - { - cfg: { - agents: { - defaults: { - models: { - "google-vertex/gemini-2.5-pro": {}, - }, - model: { primary: "google-vertex/gemini-2.5-pro" }, - }, - }, - models: { providers: {} }, + it("keeps google-vertex static catalog rows when discovery supplies the ADC marker", async () => { + const plan = await planOpenClawModelsJsonWithDeps( + { + cfg: { + agents: { + defaults: { + models: { + "google-vertex/gemini-2.5-pro": {}, }, - agentDir, - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR, - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, - GOOGLE_CLOUD_PROJECT: "vertex-project", - GOOGLE_CLOUD_LOCATION: "global", - } as NodeJS.ProcessEnv, - existingRaw: "", - existingParsed: null, + model: { primary: "google-vertex/gemini-2.5-pro" }, }, - { - resolveImplicitProviders: async () => ({ - "google-vertex": createImplicitGoogleVertexProvider(), - }), - }, - ), - ); + }, + models: { providers: {} }, + }, + agentDir: "/tmp/openclaw-google-vertex-adc-models", + env: {}, + existingRaw: "", + existingParsed: null, + }, + { + // Provider discovery owns ADC detection; this planner test only proves + // the resulting marker and static rows survive models.json planning. + resolveImplicitProviders: async () => ({ + "google-vertex": { + ...createImplicitGoogleVertexProvider(), + apiKey: "gcp-vertex-credentials", + }, + }), + }, + ); - expect(plan.action).toBe("write"); - if (plan.action !== "write") { - throw new Error("Expected models.json write plan"); - } - const parsed = JSON.parse(plan.contents) as { - providers?: Record< - string, - { apiKey?: string; api?: string; models?: Array<{ id?: string }> } - >; - }; - expect(parsed.providers?.["google-vertex"]?.api).toBe("google-vertex"); - expect(parsed.providers?.["google-vertex"]?.apiKey).toBe("gcp-vertex-credentials"); - expect(parsed.providers?.["google-vertex"]?.models?.map((model) => model.id)).toEqual([ - "gemini-2.5-pro", - ]); - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); + expect(plan.action).toBe("write"); + if (plan.action !== "write") { + throw new Error("Expected models.json write plan"); } + const parsed = JSON.parse(plan.contents) as { + providers?: Record< + string, + { apiKey?: string; api?: string; models?: Array<{ id?: string }> } + >; + }; + expect(parsed.providers?.["google-vertex"]?.api).toBe("google-vertex"); + expect(parsed.providers?.["google-vertex"]?.apiKey).toBe("gcp-vertex-credentials"); + expect(parsed.providers?.["google-vertex"]?.models?.map((model) => model.id)).toEqual([ + "gemini-2.5-pro", + ]); }); it("uses config env.vars entries for implicit provider discovery without mutating process.env", async () => { diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index f2d5c2cfedf0..7a9a39771506 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -371,6 +371,16 @@ let createSessionStatusTool: typeof import("./tools/session-status-tool.js").cre beforeAll(async () => { ({ createSessionStatusTool } = await import("./tools/session-status-tool.js")); + resetSessionStore({ + "agent:main:spawned": { + sessionId: "spawned-status-warmup", + updatedAt: 1, + spawnedWorkspaceDir: "/tmp/openclaw-spawned-workspace", + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + }, + }); + await getSessionStatusTool("agent:main:spawned").execute("warm-spawned-workspace-status", {}); }); function resetSessionStore(store: Record) { diff --git a/src/agents/tool-search.test.ts b/src/agents/tool-search.test.ts index dd7d9fb20500..7b3d04d92355 100644 --- a/src/agents/tool-search.test.ts +++ b/src/agents/tool-search.test.ts @@ -1522,6 +1522,7 @@ describe("Tool Search", () => { }, 5_000); it("aborts already-started bridged calls when code mode times out", async () => { + testing.setToolSearchMinCodeTimeoutMsForTest(50); const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); const target = pluginTool("fake_abort_on_timeout", "Long-running target tool"); let observedSignal: AbortSignal | undefined; @@ -1554,7 +1555,7 @@ describe("Tool Search", () => { const config = { tools: { - toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 1_000 }, + toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 100 }, }, } as never; applyToolSearchCatalog({ diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 518405ea46f7..c54b12ae64d5 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { isInboundPathAllowed } from "@openclaw/media-core/inbound-path-policy"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { ModelDefinitionConfig } from "../../config/types.models.js"; import { encodePngRgba, fillPixel } from "../../media/png-encode.js"; @@ -350,32 +350,7 @@ async function writeAuthProfiles(agentDir: string, profiles: unknown) { } async function createOpenClawCodingToolsWithFreshModules(options?: CreateOpenClawCodingToolsArgs) { - const defaultImageModels = new Map([ - ["anthropic", "claude-opus-4-6"], - ["minimax", "MiniMax-VL-01"], - ["minimax-cn", "MiniMax-VL-01"], - ["minimax-portal", "MiniMax-VL-01"], - ["minimax-portal-cn", "MiniMax-VL-01"], - ["codex", "gpt-5.5"], - ["openai", "gpt-5.4-mini"], - ["opencode", "gpt-5-nano"], - ["opencode-go", "kimi-k2.6"], - ["zai", "glm-4.6v"], - ]); - testing.setProviderDepsForTest({ - buildProviderRegistry: (overrides?: Record) => - imageProviderHarness.buildProviderRegistry(overrides), - getMediaUnderstandingProvider: ( - id: string, - registry: Map, - ) => imageProviderHarness.getMediaUnderstandingProvider(id, registry), - describeImageWithModel: describeGenericImageWithModel, - describeImagesWithModel: describeGenericImagesWithModel, - resolveAutoMediaKeyProviders: ({ capability }) => - capability === "image" ? ["openai", "anthropic"] : [], - resolveDefaultMediaModel: ({ providerId, capability }) => - capability === "image" ? defaultImageModels.get(providerId.toLowerCase()) : undefined, - }); + installFastLocalImageProviderStubs(minimaxProvider, moonshotProvider); return createOpenClawCodingTools(options); } @@ -780,20 +755,29 @@ function installFastLocalImageProviderStubs(...providers: MediaUnderstandingProv }), loadImageWebMediaRuntime: async () => ({ loadWebMedia: async (mediaUrl, options) => { + const localRoots = + options && typeof options !== "number" && "localRoots" in options + ? options.localRoots + : []; const inboundRoots = options && typeof options !== "number" && "inboundRoots" in options ? options.inboundRoots : []; if ( + localRoots !== "any" && !isInboundPathAllowed({ filePath: mediaUrl, - roots: inboundRoots ?? [], + roots: [...(localRoots ?? []), ...(inboundRoots ?? [])], }) ) { throw new Error(`Local media path is not under an allowed directory: ${mediaUrl}`); } + const readFile = + options && typeof options !== "number" && "readFile" in options + ? options.readFile + : undefined; return { - buffer: await fs.readFile(mediaUrl), + buffer: readFile ? await readFile(mediaUrl) : await fs.readFile(mediaUrl), contentType: "image/png", kind: "image", fileName: path.basename(mediaUrl), @@ -982,6 +966,39 @@ describe("image tool implicit imageModel config", () => { "GITHUB_TOKEN", ]); + beforeAll(async () => { + await withTempAgentDir(async (agentDir) => { + installImageUnderstandingProviderStubs(); + await writeAuthProfiles(agentDir, { + version: 1, + profiles: { + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + access: "oauth-test", + refresh: "refresh-test", + expires: Date.now() + 60_000, + }, + }, + }); + stubMinimaxOkFetch(); + const tool = requireImageTool( + createImageTool({ + agentDir, + config: { + agents: { + defaults: { + model: { primary: "minimax-portal/MiniMax-M2.7" }, + imageModel: { primary: "minimax-portal/MiniMax-VL-01" }, + }, + }, + }, + }), + ); + await expectImageToolExecOk(tool, `data:image/png;base64,${ONE_PIXEL_PNG_B64}`); + }); + }); + beforeEach(() => { installImageUnderstandingProviderStubs(minimaxProvider, moonshotProvider); }); @@ -1226,7 +1243,7 @@ describe("image tool implicit imageModel config", () => { text: `ok ${params.provider}/${params.model}`, model: params.model, })); - installImageUnderstandingProviderStubs({ + installFastLocalImageProviderStubs({ id: "opencode-go", capabilities: ["image"], describeImage, @@ -1454,7 +1471,7 @@ describe("image tool implicit imageModel config", () => { text: "ok", model: params.model, })); - installImageUnderstandingProviderStubs({ + installFastLocalImageProviderStubs({ id: "ollama", capabilities: ["image"], describeImage, @@ -1487,7 +1504,7 @@ describe("image tool implicit imageModel config", () => { text: "ok", model: params.model, })); - installImageUnderstandingProviderStubs({ + installFastLocalImageProviderStubs({ id: "ollama", capabilities: ["image"], describeImage, @@ -1755,7 +1772,7 @@ describe("image tool implicit imageModel config", () => { text: `ok ${params.model}`, model: params.model, })); - installImageUnderstandingProviderStubs({ + installFastLocalImageProviderStubs({ id: "ollama", capabilities: ["image"], describeImage, @@ -1869,6 +1886,7 @@ describe("image tool implicit imageModel config", () => { it("sends moonshot image requests with user+image payloads only", async () => { await withTempAgentDir(async (agentDir) => { + installFastLocalImageProviderStubs(minimaxProvider, moonshotProvider); vi.stubEnv("MOONSHOT_API_KEY", "moonshot-test"); const fetch = stubOpenAiCompletionsOkFetch("ok moonshot"); const cfg: OpenClawConfig = { diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index efbd46db0a32..f480819d8191 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -117,6 +117,21 @@ describe("buildReplyPayloads media filter integration", () => { plugin: createChannelTestPluginBase({ id: "discord" }), source: "test", }, + { + pluginId: "feishu-plugin", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "feishu" }), + meta: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu", + docsPath: "/channels/feishu", + blurb: "test stub", + aliases: ["lark"], + }, + }, + }, ]), ); }); @@ -445,28 +460,6 @@ describe("buildReplyPayloads media filter integration", () => { }); it("delivers distinct same-target replies when target provider is channel alias", async () => { - resetPluginRuntimeStateForTest(); - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "feishu-plugin", - source: "test", - plugin: { - id: "feishu", - meta: { - id: "feishu", - label: "Feishu", - selectionLabel: "Feishu", - docsPath: "/channels/feishu", - blurb: "test stub", - aliases: ["lark"], - }, - capabilities: { chatTypes: ["direct"] }, - config: { listAccountIds: () => [], resolveAccount: () => ({}) }, - }, - }, - ]), - ); await expectSameTargetRepliesDelivered({ provider: "lark", to: "ou_abc123" }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts b/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts index 22331911219c..f2a4c8498e9b 100644 --- a/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ReplyPayload } from "../types.js"; import { @@ -22,11 +22,14 @@ function setNoAbort() { } describe("dispatchReplyFromConfig stale visible admission recovery", () => { - beforeEach(async () => { + beforeAll(async () => { ({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js")); ({ createReplyOperation, __testing: replyRunTesting } = await import("./reply-run-registry.js")); ({ resetInboundDedupe } = await import("./inbound-dedupe.js")); + }); + + beforeEach(() => { replyRunTesting.resetReplyRunRegistry(); resetInboundDedupe(); resetPluginTtsAndThreadMocks(); diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 79ce29cda56e..4ddfbfc6de81 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -2181,16 +2181,16 @@ describe("dispatchReplyFromConfig", () => { dispatcher, replyResolver, }); + let settled = false; + void resultPromise.finally(() => { + settled = true; + }); try { - const result = await Promise.race([ - resultPromise, - new Promise<"blocked">((resolve) => { - setTimeout(() => resolve("blocked"), 1_000); - }), - ]); - - expect(result).toBe("blocked"); + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(settled).toBe(false); expect(replyResolver).not.toHaveBeenCalled(); } finally { activeOperation.complete(); diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 362d42a66b56..da1b05dc424c 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -299,6 +299,9 @@ describe("runPreparedReply media-only handling", () => { await import("./inbound-meta.js")); ({ testing: replyRunTesting, getActiveReplyRunCount } = await import("./reply-run-registry.js")); + + // Load deferred reply dependencies before per-case timing starts. + await runPreparedReply(baseParams()); }); beforeEach(async () => { diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index e61100c02112..c6080e0886ee 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -11,6 +11,7 @@ import { writeSessionStoreForTestAsync, } from "../../config/sessions/test-helpers.js"; import { getReplyPayloadMetadata } from "../reply-payload.js"; +import { handleGoalCommand } from "./commands-goal.js"; import { buildFastReplyCommandContext, initFastReplySessionState, @@ -35,7 +36,9 @@ function emptyAliasIndex(): ModelAliasIndex { } const mocks = vi.hoisted(() => ({ + buildStatusReply: vi.fn(), ensureAgentWorkspace: vi.fn(), + handleCommands: vi.fn(), handleInlineActions: vi.fn(), initSessionState: vi.fn(), loadModelCatalog: vi.fn(async () => [ @@ -49,6 +52,14 @@ const mocks = vi.hoisted(() => ({ resolveReplyDirectives: vi.fn(), })); +vi.mock("./commands.runtime.js", () => ({ + handleCommands: (...args: unknown[]) => mocks.handleCommands(...args), +})); + +vi.mock("./commands-status.js", () => ({ + buildStatusReply: (...args: unknown[]) => mocks.buildStatusReply(...args), +})); + vi.mock("../../agents/model-catalog.js", async () => { const actual = await vi.importActual( "../../agents/model-catalog.js", @@ -132,7 +143,34 @@ describe("getReplyFromConfig fast test bootstrap", () => { }), resolveRuntimeCliBackends: () => [], }); + mocks.buildStatusReply.mockReset(); + mocks.buildStatusReply.mockImplementation(async (params: unknown) => { + const status = params as { + cfg: OpenClawConfig; + resolvedThinkLevel?: string; + resolveDefaultThinkingLevel: () => Promise; + sessionKey?: string; + }; + const agentId = status.sessionKey?.split(":")[1]; + const agentThinkingDefault = status.cfg.agents?.list?.find( + (agent) => agent.id === agentId, + )?.thinkingDefault; + const thinkLevel = + status.resolvedThinkLevel ?? + agentThinkingDefault ?? + status.cfg.agents?.defaults?.thinkingDefault ?? + (await status.resolveDefaultThinkingLevel()); + return { text: `OpenClaw\nThink: ${thinkLevel ?? "off"}` }; + }); mocks.ensureAgentWorkspace.mockReset(); + mocks.handleCommands.mockReset(); + mocks.handleCommands.mockImplementation(async (params: unknown) => { + const result = await handleGoalCommand( + params as Parameters[0], + true, + ); + return result ?? { shouldContinue: true, reply: undefined }; + }); mocks.handleInlineActions.mockReset(); mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: { text: "ok" } }); mocks.initSessionState.mockReset(); @@ -558,6 +596,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + expect(mocks.handleCommands).toHaveBeenCalledOnce(); expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce(); const directiveParams = requireDirectiveParams(); expect(directiveParams.sessionKey).toBe(targetSessionKey); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index f6c54c95d3e2..305182ac243c 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -433,6 +433,8 @@ async function runConfigCommand(args: string[]) { describe("config cli", () => { beforeAll(async () => { ({ registerConfigCli } = await import("./config-cli.js")); + const { resolveConfigSecretTargetByPath } = await import("../secrets/target-registry.js"); + resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); sharedProgram = new Command(); sharedProgram.exitOverride(); registerConfigCli(sharedProgram); diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index d74b5f42cfb6..49dd7ffaf43d 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { withEnvOverride } from "../config/test-helpers.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; import { registerGatewayCli } from "./gateway-cli.js"; @@ -147,6 +147,13 @@ function firstMockArg(mock: { mock: { calls: ReadonlyArray { + beforeAll(async () => { + // Gateway startup intentionally primes this large graph before installing + // signal handlers. Load it as suite setup so failure-path timings measure + // the lifecycle behavior rather than the one-time module parse. + await import("./gateway-cli/lifecycle.runtime.js"); + }); + beforeEach(() => { gatewayProgram = createGatewayProgram(); runtimeLogs.length = 0; diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 690f1acc8d96..431c3b5b3e24 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -68,6 +68,9 @@ const legacyConfigRepairMocks = vi.hoisted(() => ({ const launchdUpdateCleanupMocks = vi.hoisted(() => ({ disableCurrentOpenClawUpdateLaunchdJob: vi.fn(async () => false), })); +const restartHealthTestControl = vi.hoisted(() => ({ + snapshot: undefined as unknown, +})); const nodeVersionSatisfiesEngine = vi.fn(); const execFile = vi.fn((...args: unknown[]) => { const callback = args.at(-1); @@ -340,6 +343,23 @@ vi.mock("./update-cli/restart-helper.js", () => ({ runRestartScript: (...args: unknown[]) => runRestartScript(...args), })); +vi.mock("./daemon-cli/restart-health.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + waitForGatewayHealthyRestart: ( + ...args: Parameters + ) => + restartHealthTestControl.snapshot === undefined + ? actual.waitForGatewayHealthyRestart(...args) + : Promise.resolve( + restartHealthTestControl.snapshot as Awaited< + ReturnType + >, + ), + }; +}); + // Mock doctor (heavy module; should not run in unit tests) vi.mock("../commands/doctor.js", () => ({ doctorCommand: vi.fn(), @@ -781,6 +801,7 @@ describe("update-cli", () => { delete process.env.OPENCLAW_SERVICE_MARKER; delete process.env.OPENCLAW_SERVICE_KIND; delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]; + restartHealthTestControl.snapshot = undefined; vi.clearAllMocks(); resetRuntimeCapture(); spawn.mockImplementation(() => { @@ -3722,6 +3743,20 @@ describe("update-cli", () => { pid: 4242, state: "running", }); + restartHealthTestControl.snapshot = { + runtime: { status: "stopped", pid: null, state: "stopped" }, + portUsage: { + port: 18789, + status: "busy", + listeners: [{ pid: 4242, command: "openclaw-gateway" }], + hints: [], + }, + healthy: true, + staleGatewayPids: [], + gatewayVersion: "1.0.0", + waitOutcome: "timeout", + elapsedMs: 60_000, + }; mockGitUpdateAfterMutation(); await updateCommand({ yes: true }); diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 3b72bc1dfeb4..51cc745e0ccf 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { writeStateDirDotEnv } from "../config/test-helpers.js"; import type { OpenClawConfig } from "../config/types.js"; import { collectPreservedExistingServiceEnvVars } from "./daemon-install-helpers.js"; @@ -171,6 +171,11 @@ function mockNodeGatewayPlanFixture( } describe("buildGatewayInstallPlan", () => { + beforeAll(async () => { + const { resolveConfigSecretTargetByPath } = await import("../secrets/target-registry.js"); + resolveConfigSecretTargetByPath(["channels", "discord", "token"]); + }); + // Prevent tests from reading the developer's real ~/.openclaw/.env when // passing `env: {}` (which falls back to os.homedir for state-dir resolution). let isolatedHome: string; @@ -410,6 +415,7 @@ describe("buildGatewayInstallPlan", () => { expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe( "CUSTOM_VAR,GOOGLE_API_KEY,SAFE_KEY", ); + expect(mocks.loadPluginManifestRegistryForPluginRegistry).not.toHaveBeenCalled(); }); it("renders config env SecretRefs as file-backed managed values on Linux", async () => { @@ -1435,7 +1441,8 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { }, }); - const home = "/home/testuser"; + // Avoid macOS /home autofs lookups while exercising the same user-tool paths. + const home = "/Users/testuser"; const plan = await buildGatewayInstallPlan({ env: { HOME: tmpDir }, port: 3000, diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index a136e4b0aeb2..c34e393df796 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -6,7 +6,7 @@ import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import { formatCliCommand } from "../cli/command-format.js"; import { collectDurableServiceEnvVarSources } from "../config/state-dir-dotenv.js"; import type { OpenClawConfig } from "../config/types.js"; -import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js"; +import { coerceSecretRef, resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { resolveGatewayStateDir, resolveGatewayTaskScriptPath } from "../daemon/paths.js"; import { @@ -67,6 +67,27 @@ const NON_PERSISTED_CONFIG_SECRET_ENV_TARGET_IDS = new Set([ ]); const EXEC_SECRET_REF_PASS_ENV_ALLOWED_OVERRIDE_ONLY_KEYS = new Set(["HOME"]); +function configContainsSecretRef(config: OpenClawConfig | undefined): boolean { + if (!config) { + return false; + } + const pending: unknown[] = [config]; + const seen = new Set(); + const defaults = config.secrets?.defaults; + while (pending.length > 0) { + const value = pending.pop(); + if (coerceSecretRef(value, defaults)) { + return true; + } + if (!value || typeof value !== "object" || seen.has(value)) { + continue; + } + seen.add(value); + pending.push(...Object.values(value)); + } + return false; +} + function isBlockedExecSecretRefPassEnvKey(key: string): boolean { if (isDangerousHostEnvVarName(key)) { return true; @@ -162,10 +183,11 @@ type ExecSecretRefPassEnvSource = { function collectConfigSecretRefServiceEnvVars(params: { env: Record; config?: OpenClawConfig; + configContainsSecretRef: boolean; stateDirDotEnvEnvironment: Record; warn?: DaemonInstallWarnFn; }): Record { - if (!params.config) { + if (!params.config || !params.configContainsSecretRef) { return {}; } const entries: Record = {}; @@ -214,6 +236,7 @@ function collectConfigSecretRefServiceEnvVars(params: { function collectExecSecretRefPassEnvServiceEnvVars(params: { env: Record; config?: OpenClawConfig; + configContainsSecretRef: boolean; authStore?: AuthProfileStore; durableEnvironment: Record; warn?: DaemonInstallWarnFn; @@ -224,31 +247,35 @@ function collectExecSecretRefPassEnvServiceEnvVars(params: { const entries: Record = {}; let manifestRegistry: Pick | undefined; const sources: ExecSecretRefPassEnvSource[] = []; - for (const target of discoverConfigSecretTargets(params.config)) { - if (!target.entry.includeInPlan) { - continue; + if (params.configContainsSecretRef) { + for (const target of discoverConfigSecretTargets(params.config)) { + if (!target.entry.includeInPlan) { + continue; + } + const { ref } = resolveSecretInputRef({ + value: target.value, + refValue: target.refValue, + defaults: params.config.secrets?.defaults, + }); + if (!ref || ref.source !== "exec") { + continue; + } + sources.push({ ref, warningTitle: "Config SecretRef" }); } - const { ref } = resolveSecretInputRef({ - value: target.value, - refValue: target.refValue, - defaults: params.config.secrets?.defaults, - }); - if (!ref || ref.source !== "exec") { - continue; - } - sources.push({ ref, warningTitle: "Config SecretRef" }); } for (const ref of collectAuthProfileSecretRefs(params.authStore)) { if (ref.source === "exec") { sources.push({ ref, warningTitle: "Auth profile" }); } } - for (const ref of collectPluginConfigSecretRefs({ - env: params.env, - config: params.config, - })) { - if (ref.source === "exec") { - sources.push({ ref, warningTitle: "Plugin config SecretRef" }); + if (params.configContainsSecretRef) { + for (const ref of collectPluginConfigSecretRefs({ + env: params.env, + config: params.config, + })) { + if (ref.source === "exec") { + sources.push({ ref, warningTitle: "Plugin config SecretRef" }); + } } } for (const { ref, warningTitle } of sources) { @@ -543,9 +570,12 @@ async function buildGatewayInstallEnvironment(params: { env: params.env, config: params.config, }); + // Full target discovery materializes plugin metadata; configs without refs do not need it. + const containsConfigSecretRef = configContainsSecretRef(params.config); const configSecretRefEnvironment = collectConfigSecretRefServiceEnvVars({ env: params.env, config: params.config, + configContainsSecretRef: containsConfigSecretRef, stateDirDotEnvEnvironment, warn: params.warn, }); @@ -553,6 +583,7 @@ async function buildGatewayInstallEnvironment(params: { const execSecretRefPassEnvEnvironment = collectExecSecretRefPassEnvServiceEnvVars({ env: params.env, config: params.config, + configContainsSecretRef: containsConfigSecretRef, authStore, durableEnvironment, warn: params.warn, diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 0da47d0c12d3..9b90da82a8d2 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1501,7 +1501,16 @@ describe("doctor config flow", () => { import("./doctor/shared/legacy-config-issues.js"), import("./doctor/shared/plugin-tool-allowlist-warnings.js"), import("./doctor/shared/preview-warnings.js"), + import("./doctor/shared/hooks-token-reuse-repair.js"), ]); + await collectDoctorWarnings({ + channels: { + slack: { + dangerouslyAllowNameMatching: true, + accounts: { work: { allowFrom: ["alice"] } }, + }, + }, + }); }); beforeEach(() => { diff --git a/src/commands/doctor/shared/legacy-config-binding-repair.ts b/src/commands/doctor/shared/legacy-config-binding-repair.ts new file mode 100644 index 000000000000..0b629ba8f16a --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-binding-repair.ts @@ -0,0 +1,39 @@ +// Repairs canonical binding references after agent config migration. +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { normalizeAgentId } from "../../../routing/session-key.js"; + +export function pruneBindingsForMissingAgents( + cfg: OpenClawConfig, + changes: string[], +): OpenClawConfig { + const agents = cfg.agents?.list; + const bindings = cfg.bindings; + if (!Array.isArray(agents) || agents.length === 0 || !Array.isArray(bindings)) { + return cfg; + } + + const validAgents = agents.filter((agent): agent is { id: string } => { + return agent !== null && typeof agent === "object" && typeof agent.id === "string"; + }); + if (validAgents.length !== agents.length) { + return cfg; + } + + const agentIds = new Set(validAgents.map((agent) => normalizeAgentId(agent.id))); + const nextBindings = bindings.filter((binding) => { + const agentId = binding && typeof binding === "object" ? binding.agentId : undefined; + return typeof agentId !== "string" || agentIds.has(normalizeAgentId(agentId)); + }); + const removed = bindings.length - nextBindings.length; + if (removed === 0) { + return cfg; + } + + changes.push( + `Removed ${removed} binding${removed === 1 ? "" : "s"} that referenced missing agents.list ids.`, + ); + return { + ...cfg, + ...(nextBindings.length > 0 ? { bindings: nextBindings } : { bindings: undefined }), + }; +} diff --git a/src/commands/doctor/shared/legacy-config-core-migrate.ts b/src/commands/doctor/shared/legacy-config-core-migrate.ts index 2bb7a5b3d9cd..32d10a42a9b7 100644 --- a/src/commands/doctor/shared/legacy-config-core-migrate.ts +++ b/src/commands/doctor/shared/legacy-config-core-migrate.ts @@ -1,9 +1,9 @@ // Core doctor compatibility migration pipeline for current config objects. import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { runPluginSetupConfigMigrations } from "../../../plugins/setup-registry.js"; -import { normalizeAgentId } from "../../../routing/session-key.js"; import { migrateLegacySecretRefEnvMarkers } from "../../../secrets/legacy-secretref-env-marker.js"; import { applyChannelDoctorCompatibilityMigrations } from "./channel-legacy-config-migrate.js"; +import { pruneBindingsForMissingAgents } from "./legacy-config-binding-repair.js"; import { normalizeBaseCompatibilityConfigValues } from "./legacy-config-compatibility-base.js"; import { normalizeLegacyCommandsConfig, @@ -18,7 +18,11 @@ function repairNullAgentWorkspaces(cfg: OpenClawConfig, changes: string[]): Open let repaired = 0; const nextAgents = agents.map((agent) => { - if (agent && typeof agent === "object" && (agent as Record).workspace === null) { + if ( + agent && + typeof agent === "object" && + (agent as Record).workspace === null + ) { repaired += 1; const { workspace: _workspace, ...rest } = agent as Record; return rest; @@ -44,39 +48,6 @@ function repairNullAgentWorkspaces(cfg: OpenClawConfig, changes: string[]): Open }; } -function pruneBindingsForMissingAgents(cfg: OpenClawConfig, changes: string[]): OpenClawConfig { - const agents = cfg.agents?.list; - const bindings = cfg.bindings; - if (!Array.isArray(agents) || agents.length === 0 || !Array.isArray(bindings)) { - return cfg; - } - - const validAgents = agents.filter((agent): agent is { id: string } => { - return agent !== null && typeof agent === "object" && typeof agent.id === "string"; - }); - if (validAgents.length !== agents.length) { - return cfg; - } - - const agentIds = new Set(validAgents.map((agent) => normalizeAgentId(agent.id))); - const nextBindings = bindings.filter((binding) => { - const agentId = binding && typeof binding === "object" ? binding.agentId : undefined; - return typeof agentId !== "string" || agentIds.has(normalizeAgentId(agentId)); - }); - const removed = bindings.length - nextBindings.length; - if (removed === 0) { - return cfg; - } - - changes.push( - `Removed ${removed} binding${removed === 1 ? "" : "s"} that referenced missing agents.list ids.`, - ); - return { - ...cfg, - ...(nextBindings.length > 0 ? { bindings: nextBindings } : { bindings: undefined }), - }; -} - /** Normalize current config through core, plugin setup, channel, and secret-ref migrations. */ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { config: OpenClawConfig; diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 2e79239a43b9..4935c7bbd1ca 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -2,9 +2,14 @@ import { describe, expect, it } from "vitest"; import { findLegacyConfigIssues } from "../../../config/legacy.js"; import type { OpenClawConfig } from "../../../config/types.js"; -import { normalizeCompatibilityConfigValues } from "./legacy-config-core-migrate.js"; +import { pruneBindingsForMissingAgents } from "./legacy-config-binding-repair.js"; import { LEGACY_CONFIG_MIGRATIONS } from "./legacy-config-migrations.js"; +function repairBindingsForTest(config: OpenClawConfig) { + const changes: string[] = []; + return { config: pruneBindingsForMissingAgents(config, changes), changes }; +} + function migrateLegacyConfigForTest(raw: unknown): { config: OpenClawConfig | null; changes: string[]; @@ -31,7 +36,7 @@ function expectMigrationChangesToIncludeFragments(changes: string[], fragments: describe("compatibility binding repair migrate", () => { it("prunes bindings for missing agents when agents.list is valid", () => { - const res = normalizeCompatibilityConfigValues({ + const res = repairBindingsForTest({ agents: { list: [{ id: "alpha" }], }, @@ -56,7 +61,7 @@ describe("compatibility binding repair migrate", () => { ], } as unknown as OpenClawConfig; - const res = normalizeCompatibilityConfigValues(cfg); + const res = repairBindingsForTest(cfg); expect(res.config.bindings).toEqual(cfg.bindings); expect(res.changes).not.toContain("Removed 1 binding that referenced missing agents.list ids."); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index d9fb1e083212..490ccc24c654 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveRegistryUpdateChannel } from "../../../infra/update-channels.js"; import { CLAWHUB_INSTALL_ERROR_CODE } from "../../../plugins/clawhub-error-codes.js"; import { @@ -191,6 +191,12 @@ vi.mock("../../../plugins/update.js", async (importOriginal) => { }); describe("repairMissingConfiguredPluginInstalls", () => { + beforeAll(async () => { + // The doctor module owns a broad install/catalog graph. Its cold import is + // suite setup; individual cases measure detection and repair behavior. + await import("./missing-configured-plugin-install.js"); + }); + beforeEach(() => { vi.clearAllMocks(); mocks.loadPluginMetadataSnapshot.mockReturnValue({ diff --git a/src/config/config.pruning-defaults.test.ts b/src/config/config.pruning-defaults.test.ts index d86c67466f78..5e18df84c1bf 100644 --- a/src/config/config.pruning-defaults.test.ts +++ b/src/config/config.pruning-defaults.test.ts @@ -1,6 +1,6 @@ // Verifies pruning-related config defaults and migrations. import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { setBundledPluginsDirOverrideForTest } from "../plugins/bundled-dir.js"; import { resetBundledPluginPublicArtifactLoaderForTest } from "../plugins/public-surface-loader.js"; import type { OpenClawConfig } from "./config.js"; @@ -17,6 +17,12 @@ function applyAnthropicDefaultsForTest(config: OpenClawConfig) { } describe("config pruning defaults", () => { + beforeAll(() => { + setBundledPluginsDirOverrideForTest(path.resolve(import.meta.dirname, "../../extensions")); + // Provider public artifacts are process-stable. Keep their one-time load out of assertions. + applyAnthropicDefaultsForTest({ agents: { defaults: {} } }); + }); + beforeEach(() => { setBundledPluginsDirOverrideForTest(path.resolve(import.meta.dirname, "../../extensions")); resetBundledPluginPublicArtifactLoaderForTest(); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 2f549ed8c040..3e0b0f5b5f16 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -169,7 +169,7 @@ export function applyModelDefaults( const normalizedProvider = normalizeProviderConfigForConfigDefaults({ provider: providerId, providerConfig: provider, - manifestRegistry: options.manifestRegistry, + manifestRegistry, }); const models = normalizedProvider.models; if (!Array.isArray(models) || models.length === 0) { diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index 33581bf27f89..a86a321ca5ba 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -1,7 +1,9 @@ // Covers best-effort config IO reads and warning behavior. import fs from "node:fs/promises"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; +import { setBundledPluginsDirOverrideForTest } from "../plugins/bundled-dir.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { closeOpenClawStateDatabaseForTest, @@ -14,6 +16,7 @@ import { readConfigFileSnapshot, readSourceConfigBestEffort, } from "./config.js"; +import { applyProviderConfigDefaultsForConfig } from "./provider-policy.js"; import { withTempHome, writeOpenClawConfig } from "./test-helpers.js"; type ConfigHealthDatabase = Pick; @@ -31,10 +34,20 @@ function readConfigHealthRow(env: NodeJS.ProcessEnv, configPath: string) { } describe("readBestEffortConfig", () => { + beforeAll(() => { + setBundledPluginsDirOverrideForTest(path.resolve(import.meta.dirname, "../../extensions")); + // Materialized reads use the process-stable provider policy cache. + applyProviderConfigDefaultsForConfig({ provider: "anthropic", config: {}, env: {} }); + }); + afterEach(() => { closeOpenClawStateDatabaseForTest(); }); + afterAll(() => { + setBundledPluginsDirOverrideForTest(undefined); + }); + it("can read snapshots without updating config observation state", async () => { await withTempHome(async (home) => { const configPath = await writeOpenClawConfig(home, { diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index 23757bdaa359..ceb034e1b229 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -1,13 +1,50 @@ // Verifies default model alias config values and overrides. import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; -import { applyModelDefaults } from "./defaults.js"; +import { applyModelDefaults as applyModelDefaultsWithPolicy } from "./defaults.js"; import type { OpenClawConfig } from "./types.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; +const emptyManifestRegistry = { plugins: [] } satisfies Pick; + +function applyModelDefaults( + cfg: OpenClawConfig, + options?: Parameters[1], +) { + return applyModelDefaultsWithPolicy(cfg, options ?? { manifestRegistry: emptyManifestRegistry }); +} + describe("applyModelDefaults", () => { + beforeAll(() => { + vi.stubEnv( + "OPENCLAW_BUNDLED_PLUGINS_DIR", + path.resolve(import.meta.dirname, "../../extensions"), + ); + // Provider public artifacts are process-stable. Keep their one-time load out of assertions. + applyModelDefaults({ + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + models: [], + }, + }, + }, + }); + applyModelDefaults({ + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com", + models: [], + }, + }, + }, + }); + }); + beforeEach(() => { vi.stubEnv( "OPENCLAW_BUNDLED_PLUGINS_DIR", diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index 1388f5458b70..83e5c37406d0 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -1,4 +1,7 @@ /** Shared Vitest module mocks for isolated-agent cron tests. */ +// Isolated turns lazily consume this process-stable runtime after agent execution. Load it during +// suite setup so first-test timings cover cron behavior rather than module initialization. +import "../utils/usage-format.js"; import { vi } from "vitest"; vi.mock("../agents/embedded-agent.js", () => ({ diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index b5ce76dc2398..c286811f4719 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -5,6 +5,7 @@ import nodePath from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DoctorPrompter } from "../commands/doctor-prompter.js"; import { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js"; +import "./doctor-tool-result-cap-advice.js"; import { createDoctorHealthContribution, resolveDoctorContributionHealthChecks, @@ -1558,12 +1559,22 @@ describe("doctor health contributions", () => { mode: "lint", runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, } as const; - const checks = [toolResultCapCheck!]; + const detect = vi.fn(async () => [ + { + checkId: "core/doctor/tool-result-cap", + severity: "warning" as const, + message: "Configured tool result cap overrides the model-window default.", + path: "agents.defaults.contextLimits.toolResultMaxChars", + }, + ]); + // This case owns lint selection; the real cap detector is exercised below. + const checks = [{ ...toolResultCapCheck!, detect }]; await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({ checksRun: 0, checksSkipped: 1, }); + expect(detect).not.toHaveBeenCalled(); await expect( runDoctorLintChecks(ctx, { checks, includeAllChecks: true }), ).resolves.toMatchObject({ @@ -1582,6 +1593,7 @@ describe("doctor health contributions", () => { checksRun: 1, checksSkipped: 0, }); + expect(detect).toHaveBeenCalledTimes(2); }); it("keeps stale plugin-runtime symlinks opt-in for structured lint selection", async () => { @@ -1741,17 +1753,21 @@ describe("doctor health contributions", () => { expect(stateIntegrityCheck).toMatchObject({ defaultEnabled: false }); expect(stateIntegrityCheck).toBeDefined(); + const detect = vi.fn(async () => []); + const ctx = { cfg: {}, mode: "lint", runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, } as const; - const checks = [stateIntegrityCheck!]; + // Selection behavior does not need the real state-integrity filesystem scan. + const checks = [{ ...stateIntegrityCheck!, detect }]; await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({ checksRun: 0, checksSkipped: 1, }); + expect(detect).not.toHaveBeenCalled(); await expect( runDoctorLintChecks(ctx, { checks, includeAllChecks: true }), ).resolves.toMatchObject({ @@ -1764,6 +1780,7 @@ describe("doctor health contributions", () => { checksRun: 1, checksSkipped: 0, }); + expect(detect).toHaveBeenCalledTimes(2); }); it("collects memory-search notes as structured findings", async () => { diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index ef2b4df3c49d..19d5da56697f 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -977,7 +977,7 @@ describe("createGatewayCloseHandler", () => { createGatewayCloseTestDeps({ broadcast, chatAbortControllers, - getPendingReplyCount: () => 1, + getPendingReplyCount: vi.fn().mockReturnValueOnce(1).mockReturnValue(0), markMainSessionsAbortedForRestart, nodeSendToSession, }), @@ -1031,7 +1031,7 @@ describe("createGatewayCloseHandler", () => { const close = createGatewayCloseHandler( createGatewayCloseTestDeps({ chatAbortControllers, - getPendingReplyCount: () => 1, + getPendingReplyCount: vi.fn().mockReturnValueOnce(1).mockReturnValue(0), markMainSessionsAbortedForRestart, }), ); diff --git a/src/gateway/server-methods/secrets.test.ts b/src/gateway/server-methods/secrets.test.ts index 80e0fc9b2057..5388baa35ca4 100644 --- a/src/gateway/server-methods/secrets.test.ts +++ b/src/gateway/server-methods/secrets.test.ts @@ -1,7 +1,8 @@ /** * Tests for gateway secret resolution and redacted secret method responses. */ -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { isKnownSecretTargetId } from "../../secrets/target-registry.js"; import { TALK_TEST_PROVIDER_API_KEY_PATH, TALK_TEST_PROVIDER_API_KEY_PATH_SEGMENTS, @@ -93,6 +94,12 @@ async function expectMemoryStatusResolveUnavailable(params: { } describe("secrets handlers", () => { + beforeAll(() => { + // Plugin target metadata is process-stable. Load it as suite setup so the + // unknown-id assertion measures handler validation, not manifest discovery. + isKnownSecretTargetId("unknown.target"); + }); + function createHandlers(overrides?: { reloadSecrets?: () => Promise<{ warningCount: number }>; resolveSecrets?: (params: { diff --git a/src/gateway/server-methods/secrets.ts b/src/gateway/server-methods/secrets.ts index 6f695033e22e..049789dbd038 100644 --- a/src/gateway/server-methods/secrets.ts +++ b/src/gateway/server-methods/secrets.ts @@ -7,7 +7,7 @@ import { validateSecretsResolveParams, validateSecretsResolveResult, } from "../../../packages/gateway-protocol/src/index.js"; -import { isKnownSecretTargetId } from "../../secrets/target-registry.js"; +import { isKnownCoreSecretTargetId, isKnownSecretTargetId } from "../../secrets/target-registry.js"; import type { GatewayRequestHandlers } from "./types.js"; function errorMessage(error: unknown): string { @@ -132,7 +132,7 @@ export function createSecretsHandlers(params: { // Target ids are a closed registry. Reject unknown ids before resolving // so callers cannot probe arbitrary config paths through this method. for (const targetId of targetIds) { - if (!isKnownSecretTargetId(targetId)) { + if (!isKnownCoreSecretTargetId(targetId) && !isKnownSecretTargetId(targetId)) { respond( false, undefined, diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts index 0909cb0e8f3c..8897e30e696d 100644 --- a/src/gateway/server-startup-log.test.ts +++ b/src/gateway/server-startup-log.test.ts @@ -1,6 +1,6 @@ // Startup log tests cover security warnings, model detail formatting, plugin // summaries, bind URLs, ANSI output, and dangerous config reporting. -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; import { formatAgentModelStartupDetails, logGatewayStartup } from "./server-startup-log.js"; @@ -15,6 +15,24 @@ vi.mock("../plugins/plugin-registry.js", async (importOriginal) => ({ })); describe("gateway startup log", () => { + beforeAll(() => { + formatAgentModelStartupDetails({ + cfg: { + models: { + providers: { + google: { + api: "google-generative-ai", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + models: [], + }, + }, + }, + }, + provider: "google", + model: "gemma-4-26b-a4b-it", + }); + }); + beforeEach(() => { pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReset(); pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ diff --git a/src/gateway/session-utils.perf.test.ts b/src/gateway/session-utils.perf.test.ts index 06849caa2407..82de7cb165fc 100644 --- a/src/gateway/session-utils.perf.test.ts +++ b/src/gateway/session-utils.perf.test.ts @@ -1,7 +1,7 @@ // Session utility performance tests protect resolver cache scaling for large // session lists with repeated provider/model tuples. import path from "node:path"; -import { describe, test, expect, vi } from "vitest"; +import { beforeAll, describe, test, expect, vi } from "vitest"; import * as thinking from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/config.js"; import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js"; @@ -24,6 +24,33 @@ import { listSessionsFromStore } from "./session-utils.js"; * are the actual scaling failure mode we care about. */ describe("listSessionsFromStore resolver cache", () => { + beforeAll(async () => { + await withStateDirEnv("openclaw-perf-warm-", async ({ stateDir }) => { + resetPluginRuntimeStateForTest(); + setActivePluginRegistry(createEmptyPluginRegistry()); + const cfg = { + agents: { defaults: { model: { primary: "google-vertex/gemini-3-flash-preview" } } }, + } as OpenClawConfig; + resetConfigRuntimeState(); + setRuntimeConfigSnapshot(cfg); + listSessionsFromStore({ + cfg, + storePath: path.join(stateDir, "sessions.json"), + store: { + google: { + updatedAt: 1, + modelProvider: "google-vertex", + model: "gemini-3-flash-preview", + }, + openai: { updatedAt: 1, modelProvider: "openai", model: "gpt-5" }, + anthropic: { updatedAt: 1, modelProvider: "anthropic", model: "claude-opus-4-7" }, + openrouter: { updatedAt: 1, modelProvider: "openrouter", model: "z-ai/glm-5" }, + }, + opts: {}, + }); + }); + }); + test("collapses non-lightweight per-row resolver work to O(unique provider/model tuples)", async () => { await withStateDirEnv("openclaw-perf-", async ({ stateDir }) => { resetPluginRuntimeStateForTest(); diff --git a/src/gateway/session-utils.search.test.ts b/src/gateway/session-utils.search.test.ts index b21599f061e6..86314101c74f 100644 --- a/src/gateway/session-utils.search.test.ts +++ b/src/gateway/session-utils.search.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, describe, expect, test } from "vitest"; import { addSubagentRunForTests, resetSubagentRegistryForTests, @@ -325,6 +325,21 @@ function childTranscriptEntry(sessionId: string, now: number): SessionEntry { } describe("listSessionsFromStore search", () => { + beforeAll(() => { + listSessionsFromStore({ + cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), + storePath: "/tmp/sessions.json", + store: { + "agent:main:main": { + updatedAt: 1, + modelProvider: "openai", + model: "gpt-5.4", + }, + }, + opts: { search: "openai" }, + }); + }); + afterEach(() => { resetSubagentRegistryForTests(); resetAgentRunContextForTest(); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 15763633a379..e97e7afc1007 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { writeAcpSessionMetaForMigration } from "../acp/runtime/session-meta.js"; import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -100,6 +100,24 @@ function expectFields(value: unknown, expected: Record): void { } describe("gateway session utils", () => { + beforeAll(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + listSessionsFromStore({ + cfg: createModelDefaultsConfig({ primary: "anthropic/claude-sonnet-4.6" }), + storePath: "", + store: { + "agent:main:main": { + updatedAt: 1, + modelProvider: "anthropic", + model: "claude-sonnet-4.6", + }, + }, + opts: {}, + }); + resetConfigRuntimeState(); + resetPluginRuntimeStateForTest(); + }); + afterEach(() => { resetConfigRuntimeState(); resetPluginRuntimeStateForTest(); @@ -403,6 +421,8 @@ describe("gateway session utils", () => { storePath: "", store: {}, key: "agent:main:main", + lightweightListRow: true, + skipTranscriptUsageFallback: true, entry: { sessionId: "session-1", updatedAt: 1, diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 76cba74dfcf2..724e823f1b06 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -850,62 +850,71 @@ describe("state migrations", () => { ); }); - it("preserves plugin ownership captured before an aliased store rewrite", async () => { - const root = await createTempDir(); - const stateDir = path.join(root, ".openclaw"); - const env = createEnv(stateDir); - const targetStorePath = path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json"); - await fs.mkdir(path.dirname(targetStorePath), { recursive: true }); - await fs.writeFile( - targetStorePath, - JSON.stringify({ - "agent:main:desk": { sessionId: "foreign-main", updatedAt: 30 }, - "agent:worker-1:main": { - sessionId: "worker-main", - updatedAt: 20, - acp: { - backend: "test", - agent: "worker-1", - runtimeSessionName: "legacy-runtime", - mode: "persistent", - state: "idle", - lastActivityAt: 20, + describe("aliased store ownership", () => { + let configuredStorePath: string; + let targetStorePath: string; + let targetStore: Record; + let result: Awaited>; + + beforeAll(async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + targetStorePath = path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json"); + await fs.mkdir(path.dirname(targetStorePath), { recursive: true }); + await fs.writeFile( + targetStorePath, + JSON.stringify({ + "agent:main:desk": { sessionId: "foreign-main", updatedAt: 30 }, + "agent:worker-1:main": { + sessionId: "worker-main", + updatedAt: 20, + acp: { + backend: "test", + agent: "worker-1", + runtimeSessionName: "legacy-runtime", + mode: "persistent", + state: "idle", + lastActivityAt: 20, + }, + }, + "voice:15550001111": { sessionId: "legacy-voice", updatedAt: 10 }, + }), + "utf8", + ); + configuredStorePath = path.join(root, "configured-sessions.json"); + await fs.link(targetStorePath, configuredStorePath); + const cfg = { + agents: { list: [{ id: "worker-1", default: true }] }, + session: { mainKey: "desk", store: configuredStorePath }, + plugins: { + entries: { + "voice-call": { config: { agentId: "worker-1" } }, }, }, - "voice:15550001111": { sessionId: "legacy-voice", updatedAt: 10 }, - }), - "utf8", - ); - const configuredStorePath = path.join(root, "configured-sessions.json"); - await fs.link(targetStorePath, configuredStorePath); - const cfg = { - agents: { list: [{ id: "worker-1", default: true }] }, - session: { mainKey: "desk", store: configuredStorePath }, - plugins: { - entries: { - "voice-call": { config: { agentId: "worker-1" } }, - }, - }, - } as OpenClawConfig; + } as OpenClawConfig; - const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root }); + result = await autoMigrateLegacyState({ cfg, env, homedir: () => root }); + targetStore = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record< + string, + { sessionId: string } + >; + }); - const targetStore = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record< - string, - { sessionId: string } - >; - expect(targetStore["agent:main:desk"]?.sessionId).toBe("foreign-main"); - expect(targetStore["agent:worker-1:main"]?.sessionId).toBe("worker-main"); - expect(targetStore["agent:worker-1:desk"]).toBeUndefined(); - expect(targetStore["agent:worker-1:main"]).toHaveProperty("acp"); - expect(fsSync.statSync(configuredStorePath).ino).toBe(fsSync.statSync(targetStorePath).ino); - expect(result.warnings).toEqual( - expect.arrayContaining([ - expect.stringContaining(`aliased store ${configuredStorePath}`), - expect.stringContaining(`aliased store ${targetStorePath}`), - expect.stringContaining("Deferred ACP metadata migration"), - ]), - ); + it("preserves plugin ownership captured before an aliased store rewrite", () => { + expect(targetStore["agent:main:desk"]?.sessionId).toBe("foreign-main"); + expect(targetStore["agent:worker-1:main"]?.sessionId).toBe("worker-main"); + expect(targetStore["agent:worker-1:desk"]).toBeUndefined(); + expect(targetStore["agent:worker-1:main"]).toHaveProperty("acp"); + expect(fsSync.statSync(configuredStorePath).ino).toBe(fsSync.statSync(targetStorePath).ino); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining(`aliased store ${configuredStorePath}`), + expect.stringContaining(`aliased store ${targetStorePath}`), + expect.stringContaining("Deferred ACP metadata migration"), + ]), + ); + }); }); it("preserves a singleton final symlink through all session migration phases", async () => { diff --git a/src/llm/providers/anthropic-cloudflare.fetch-proof.test.ts b/src/llm/providers/anthropic-cloudflare.fetch-proof.test.ts index 51f04aa40023..69e30540edd7 100644 --- a/src/llm/providers/anthropic-cloudflare.fetch-proof.test.ts +++ b/src/llm/providers/anthropic-cloudflare.fetch-proof.test.ts @@ -50,7 +50,11 @@ describe("Anthropic Cloudflare guard-specific SSRF blocking proof", () => { } satisfies Model<"anthropic-messages">; const { streamAnthropic } = await import("@openclaw/ai/internal/anthropic"); - const stream = streamAnthropic(blockedModel, context, { apiKey: "sk-ant-test" }); + const stream = streamAnthropic(blockedModel, context, { + apiKey: "sk-ant-test", + // Retries only repeat the same deterministic guard rejection. + maxRetries: 0, + }); const result = await stream.result(); expect(result.stopReason).toBe("error"); diff --git a/src/logging/state.test.ts b/src/logging/state.test.ts new file mode 100644 index 000000000000..c4493d322113 --- /dev/null +++ b/src/logging/state.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("logging state", () => { + it("stays process-local across module reloads", async () => { + const first = await import("./state.js"); + vi.resetModules(); + const second = await import("./state.js"); + + expect(second.loggingState).toBe(first.loggingState); + }); +}); diff --git a/src/logging/state.ts b/src/logging/state.ts index 40ec1c62468b..9a3002730154 100644 --- a/src/logging/state.ts +++ b/src/logging/state.ts @@ -1,20 +1,33 @@ // Process-local logging state shared by logger, console capture, and test reset helpers. -export const loggingState = { - cachedLogger: null as unknown, - cachedSettings: null as unknown, - cachedConsoleSettings: null as unknown, - overrideSettings: null as unknown, - invalidEnvLogLevelValue: null as string | null, - consolePatched: false, - forceConsoleToStderr: false, - consoleTimestampPrefix: false, - consoleSubsystemFilter: null as string[] | null, - resolvingConsoleSettings: false, - streamErrorHandlersInstalled: false, - rawConsole: null as { - log: typeof console.log; - info: typeof console.info; - warn: typeof console.warn; - error: typeof console.error; - } | null, -}; +const LOGGING_STATE_KEY = Symbol.for("openclaw.loggingState"); + +function createLoggingState() { + return { + cachedLogger: null as unknown, + cachedSettings: null as unknown, + cachedConsoleSettings: null as unknown, + overrideSettings: null as unknown, + invalidEnvLogLevelValue: null as string | null, + consolePatched: false, + forceConsoleToStderr: false, + consoleTimestampPrefix: false, + consoleSubsystemFilter: null as string[] | null, + resolvingConsoleSettings: false, + streamErrorHandlersInstalled: false, + rawConsole: null as { + log: typeof console.log; + info: typeof console.info; + warn: typeof console.warn; + error: typeof console.error; + } | null, + }; +} + +type LoggingState = ReturnType; +const globalStore = globalThis as Record; + +// Test runners can reload modules without creating a new process. Keep one +// state object so overrides and caches remain coherent across those copies. +export const loggingState = + (globalStore[LOGGING_STATE_KEY] as LoggingState | undefined) ?? createLoggingState(); +globalStore[LOGGING_STATE_KEY] = loggingState; diff --git a/src/logging/test-helpers/diagnostic-log-capture.test.ts b/src/logging/test-helpers/diagnostic-log-capture.test.ts new file mode 100644 index 000000000000..2388cc9732af --- /dev/null +++ b/src/logging/test-helpers/diagnostic-log-capture.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + emitDiagnosticEvent, + resetDiagnosticEventsForTest, +} from "../../infra/diagnostic-events.js"; +import { createDiagnosticLogRecordCapture } from "./diagnostic-log-capture.js"; + +describe("diagnostic log capture", () => { + afterEach(() => { + resetDiagnosticEventsForTest(); + }); + + it("flushes log records queued behind multiple diagnostic batches", async () => { + const capture = createDiagnosticLogRecordCapture(); + for (let index = 0; index < 350; index += 1) { + emitDiagnosticEvent({ + type: "log.record", + level: "warn", + message: `warning-${index}`, + }); + } + + await capture.flush(); + + expect(capture.records).toHaveLength(350); + capture.cleanup(); + }); +}); diff --git a/src/logging/test-helpers/diagnostic-log-capture.ts b/src/logging/test-helpers/diagnostic-log-capture.ts index 2da2d7afde61..7e3d8a7f561a 100644 --- a/src/logging/test-helpers/diagnostic-log-capture.ts +++ b/src/logging/test-helpers/diagnostic-log-capture.ts @@ -1,5 +1,6 @@ // Diagnostic log capture helpers collect emitted diagnostic logs for tests. import { + hasPendingInternalDiagnosticEvent, onInternalDiagnosticEvent, type DiagnosticEventPayload, } from "../../infra/diagnostic-events.js"; @@ -9,7 +10,13 @@ type CapturedDiagnosticLogRecord = Extract { - for (let index = 0; index < 3; index += 1) { + // The dispatcher drains 100 records per turn. A busy shared test process can + // have several batches ahead of the log under test, so wait for queued log + // records instead of assuming a fixed small number of turns. + for (let index = 0; index < 128; index += 1) { + if (!hasPendingInternalDiagnosticEvent((event) => event.type === "log.record")) { + return; + } await new Promise((resolve) => { setImmediate(resolve); }); diff --git a/src/media-understanding/runner.local-no-auth.test.ts b/src/media-understanding/runner.local-no-auth.test.ts index 064f00ce34f0..7ecc29864893 100644 --- a/src/media-understanding/runner.local-no-auth.test.ts +++ b/src/media-understanding/runner.local-no-auth.test.ts @@ -3,8 +3,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import { CUSTOM_LOCAL_AUTH_MARKER } from "../agents/model-auth-markers.js"; import type { OpenClawConfig } from "../config/types.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -21,9 +21,38 @@ vi.mock("../plugins/capability-provider-runtime.js", async () => { return createEmptyCapabilityProviderMockModule(); }); +const modelAuthTestControl = vi.hoisted(() => ({ + forceMissingProvider: false, + store: undefined as AuthProfileStore | undefined, +})); + +vi.mock("../agents/model-auth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveApiKeyForProvider: async ( + ...args: Parameters + ) => { + if (modelAuthTestControl.forceMissingProvider) { + throw new actual.ProviderAuthError( + "missing-provider-auth", + args[0].provider, + `No API key found for provider "${args[0].provider}".`, + ); + } + const [params] = args; + return await actual.resolveApiKeyForProvider({ + ...params, + store: modelAuthTestControl.store ?? params.store, + }); + }, + }; +}); + vi.mock("../plugins/providers.js", async (importOriginal) => ({ ...(await importOriginal()), resolveOwningPluginIdsForProvider: () => [], + resolveOwningPluginIdsForProviderRef: () => [], })); const AUTH_ENV = { @@ -32,6 +61,11 @@ const AUTH_ENV = { OPENCLAW_AGENT_DIR: undefined, } satisfies Record; +beforeEach(() => { + modelAuthTestControl.forceMissingProvider = false; + modelAuthTestControl.store = undefined; +}); + function createAudioProvider( id: string, transcribeAudio: (req: AudioTranscriptionRequest) => Promise<{ text: string; model?: string }>, @@ -150,6 +184,9 @@ describe("runCapability local no-auth audio providers", () => { }); it("regression #74644: plugin-only local no-auth audio provider can use no-auth", async () => { + // This test owns the media-provider fallback after generic auth misses; + // model-auth integration and profile precedence are covered below. + modelAuthTestControl.forceMissingProvider = true; await withIsolatedAgentDir(async (agentDir) => { await withEnvAsync(AUTH_ENV, async () => { await withAudioFixture( @@ -235,24 +272,20 @@ describe("runCapability local no-auth audio providers", () => { }); it("uses OpenAI API key auth for audio when the default OpenAI profile is OAuth", async () => { + modelAuthTestControl.store = { + version: 1, + profiles: { + "openai:default": { + type: "oauth", + provider: "openai", + access: "oauth-chat-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; await withIsolatedAgentDir(async (agentDir) => { await withEnvAsync({ ...AUTH_ENV, OPENAI_API_KEY: "env-openai-audio-key" }, async () => { - saveAuthProfileStore( - { - version: 1, - profiles: { - "openai:default": { - type: "oauth", - provider: "openai", - access: "oauth-chat-token", - refresh: "oauth-refresh-token", - expires: Date.now() + 60_000, - }, - }, - }, - agentDir, - { filterExternalAuthProfiles: false, syncExternalCli: false }, - ); await withAudioFixture( "openclaw-openai-audio-oauth-env-key", async ({ ctx, media, cache }) => { @@ -285,22 +318,18 @@ describe("runCapability local no-auth audio providers", () => { }); it("prefers stored auth profile credentials over plugin-only media no-auth", async () => { + modelAuthTestControl.store = { + version: 1, + profiles: { + "local-audio:default": { + type: "api_key", + provider: "local-audio", + key: "stored-local-audio-key", + }, + }, + }; await withIsolatedAgentDir(async (agentDir) => { await withEnvAsync(AUTH_ENV, async () => { - saveAuthProfileStore( - { - version: 1, - profiles: { - "local-audio:default": { - type: "api_key", - provider: "local-audio", - key: "stored-local-audio-key", - }, - }, - }, - agentDir, - { filterExternalAuthProfiles: false, syncExternalCli: false }, - ); await withAudioFixture( "openclaw-local-audio-stored-profile", async ({ ctx, media, cache }) => { @@ -338,6 +367,7 @@ describe("runCapability local no-auth audio providers", () => { }); it("still rejects a remote audio provider without credentials", async () => { + modelAuthTestControl.forceMissingProvider = true; await withIsolatedAgentDir(async (agentDir) => { await withEnvAsync(AUTH_ENV, async () => { await withAudioFixture("openclaw-remote-audio-no-auth", async ({ ctx, media, cache }) => { @@ -423,6 +453,7 @@ describe("runCapability local no-auth audio providers", () => { }); it("allows a media auth hook to provide an api key after normal auth misses", async () => { + modelAuthTestControl.forceMissingProvider = true; await withIsolatedAgentDir(async (agentDir) => { await withEnvAsync(AUTH_ENV, async () => { await withAudioFixture("openclaw-local-audio-hook-key", async ({ ctx, media, cache }) => { @@ -463,6 +494,7 @@ describe("runCapability local no-auth audio providers", () => { }); it("does not allow plugin-only media provider without explicit no-auth", async () => { + modelAuthTestControl.forceMissingProvider = true; await withIsolatedAgentDir(async (agentDir) => { await withEnvAsync(AUTH_ENV, async () => { await withAudioFixture("openclaw-local-audio-no-hook", async ({ ctx, media, cache }) => { @@ -495,6 +527,7 @@ describe("runCapability local no-auth audio providers", () => { }); it("does not allow plugin-only media provider when no-auth hook returns null", async () => { + modelAuthTestControl.forceMissingProvider = true; await withIsolatedAgentDir(async (agentDir) => { await withEnvAsync(AUTH_ENV, async () => { await withAudioFixture("openclaw-local-audio-null-hook", async ({ ctx, media, cache }) => { @@ -622,6 +655,7 @@ describe("runCapability local no-auth audio providers", () => { }); it("allows explicit no-auth for plugin-only no-auth video provider", async () => { + modelAuthTestControl.forceMissingProvider = true; await withIsolatedAgentDir(async (agentDir) => { await withEnvAsync(AUTH_ENV, async () => { await withVideoFixture( diff --git a/src/plugin-sdk/test-helpers/web-search-provider-contract.ts b/src/plugin-sdk/test-helpers/web-search-provider-contract.ts index a5bd37cc8a15..b26e0ec7c9b0 100644 --- a/src/plugin-sdk/test-helpers/web-search-provider-contract.ts +++ b/src/plugin-sdk/test-helpers/web-search-provider-contract.ts @@ -1,7 +1,7 @@ /** * Contract suite for bundled web search provider registration and runtime behavior. */ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { pluginRegistrationContractRegistry, resolveWebSearchProviderContractEntriesForPluginId, @@ -57,6 +57,12 @@ export function describeWebSearchProviderContracts(pluginId: string) { }; describe(`${pluginId} web search provider contract registry load`, () => { + beforeAll(() => { + // Public-artifact loading is suite setup shared by every provider + // assertion; keep its cold module cost out of an arbitrary first test. + resolveProviders(); + }); + it("loads bundled web search providers", () => { expect(resolveProviders().length).toBeGreaterThan(0); }); diff --git a/src/plugins/doctor-contract-registry.load-paths.test.ts b/src/plugins/doctor-contract-registry.load-paths.test.ts index 87ead40fe0f2..8c0c365b9bc6 100644 --- a/src/plugins/doctor-contract-registry.load-paths.test.ts +++ b/src/plugins/doctor-contract-registry.load-paths.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { findLegacyConfigIssues } from "../config/legacy.js"; import type { OpenClawConfig } from "../config/types.js"; import { @@ -309,33 +309,39 @@ describe("doctor contract registry load-path plugins", () => { ]); }); - it("loads session-store agent IDs from the real Voice Call doctor contract", () => { - const stateDir = makeTempDir(); - const pluginRoot = path.join(repoRoot, "extensions", "voice-call"); - const config = { - plugins: { - load: { paths: [pluginRoot] }, - entries: { - "voice-call": { - enabled: true, - config: { - agentId: "Voice", - numbers: { - "+15550001111": { agentId: "Cards" }, - "+15550002222": {}, + describe("real Voice Call contract", () => { + let agentIds: string[]; + + beforeAll(() => { + const stateDir = makeTempDir(); + const pluginRoot = path.join(repoRoot, "extensions", "voice-call"); + const config = { + plugins: { + load: { paths: [pluginRoot] }, + entries: { + "voice-call": { + enabled: true, + config: { + agentId: "Voice", + numbers: { + "+15550001111": { agentId: "Cards" }, + "+15550002222": {}, + }, }, }, }, }, - }, - } as OpenClawConfig; + } as OpenClawConfig; - expect( - listPluginDoctorSessionStoreAgentIds({ + agentIds = listPluginDoctorSessionStoreAgentIds({ config, env: makeHermeticDoctorEnv(stateDir), pluginIds: ["voice-call"], - }), - ).toEqual(["cards", "voice"]); + }); + }); + + it("loads session-store agent IDs from the real Voice Call doctor contract", () => { + expect(agentIds).toEqual(["cards", "voice"]); + }); }); }); diff --git a/src/plugins/web-provider-public-artifacts.test.ts b/src/plugins/web-provider-public-artifacts.test.ts index 5ad18966ee77..4eb6b36a99bc 100644 --- a/src/plugins/web-provider-public-artifacts.test.ts +++ b/src/plugins/web-provider-public-artifacts.test.ts @@ -45,20 +45,21 @@ function bundledPluginIdsWithContract( } describe("web provider public artifacts", () => { - it("loads public artifacts for every bundled web provider declared in manifests", () => { + it("declares bundled web providers in manifests", () => { expect(webSearchPluginIds).not.toHaveLength(0); - for (const pluginId of webSearchPluginIds) { - expect( - loadBundledWebSearchProviderEntriesFromDir({ dirName: pluginId, pluginId }), - ).not.toBeNull(); - } - expect(webFetchPluginIds).not.toHaveLength(0); - for (const pluginId of webFetchPluginIds) { - expect( - loadBundledWebFetchProviderEntriesFromDir({ dirName: pluginId, pluginId }), - ).not.toBeNull(); - } + }); + + it.each(webSearchPluginIds)("loads public web-search artifacts for %s", (pluginId) => { + expect( + loadBundledWebSearchProviderEntriesFromDir({ dirName: pluginId, pluginId }), + ).not.toBeNull(); + }); + + it.each(webFetchPluginIds)("loads public web-fetch artifacts for %s", (pluginId) => { + expect( + loadBundledWebFetchProviderEntriesFromDir({ dirName: pluginId, pluginId }), + ).not.toBeNull(); }); it("registers compatibility runtime paths for bundled SecretRef-capable web search providers", () => { diff --git a/src/secrets/configure-plan.test.ts b/src/secrets/configure-plan.test.ts index 2e84d2df2f79..8547dcda2df7 100644 --- a/src/secrets/configure-plan.test.ts +++ b/src/secrets/configure-plan.test.ts @@ -1,5 +1,5 @@ /** Tests secrets configure plan generation and target validation. */ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { TALK_TEST_PROVIDER_API_KEY_PATH, @@ -12,8 +12,13 @@ import { collectConfigureProviderChanges, hasConfigurePlanChanges, } from "./configure-plan.js"; +import { resolveConfigSecretTargetByPath } from "./target-registry.js"; describe("secrets configure plan helpers", () => { + beforeAll(() => { + resolveConfigSecretTargetByPath(["channels", "telegram", "botToken"]); + }); + it("builds configure candidates from supported configure targets", () => { const config = { talk: { diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index 44500a9477f6..0f9098774a46 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -1,5 +1,5 @@ /** Tests secrets plan normalization, target validation, and ref conversion. */ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { INVALID_EXEC_SECRET_REF_IDS, VALID_EXEC_SECRET_REF_IDS, @@ -10,6 +10,7 @@ import { TALK_TEST_PROVIDER_ID, } from "../test-utils/talk-test-provider.js"; import { isSecretsApplyPlan, resolveValidatedPlanTarget } from "./plan.js"; +import { resolveConfigSecretTargetByPath } from "./target-registry.js"; type ValidatedPlanTarget = NonNullable>; @@ -23,6 +24,10 @@ function requireValidatedPlanTarget( } describe("secrets plan validation", () => { + beforeAll(() => { + resolveConfigSecretTargetByPath(["channels", "telegram", "botToken"]); + }); + it("accepts legacy provider target types", () => { const resolved = resolveValidatedPlanTarget({ type: "models.providers.apiKey", @@ -76,6 +81,16 @@ describe("secrets plan validation", () => { expect(resolved).toBeNull(); }); + it("rejects path-like channel ids without throwing", () => { + expect( + resolveValidatedPlanTarget({ + type: "channels.foo/bar.token", + path: "channels.foo/bar.token", + pathSegments: ["channels", "foo/bar", "token"], + }), + ).toBeNull(); + }); + it("validates plan files with non-legacy target types", () => { const isValid = isSecretsApplyPlan({ version: 1, diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 1dbac68cee91..3c0002af770c 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -391,6 +391,8 @@ describe("runtime web tools resolution", () => { secretResolve = await import("./resolve.js"); ({ createResolverContext } = await import("./runtime-shared.js")); ({ resolveRuntimeWebTools } = await import("./runtime-web-tools.js")); + // The managed-index branch lazily loads this stable runtime once per process. + await import("./runtime-web-tools-fallback.runtime.js"); }); beforeEach(() => { diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index 52f748cf6e52..4947cd3d282d 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -136,6 +136,12 @@ const COVERAGE_WEB_SEARCH_PROVIDERS = new Map( envVar: "MINIMAX_API_KEY", order: 70, }), + createCoverageWebSearchProvider({ + pluginId: "parallel", + id: "parallel", + envVar: "PARALLEL_API_KEY", + order: 75, + }), createCoverageWebSearchProvider({ pluginId: "tavily", id: "tavily", @@ -609,6 +615,9 @@ function applyConfigForOpenClawTarget( if (entry.id === "plugins.entries.google.config.webSearch.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "gemini"); } + if (entry.id === "plugins.entries.parallel.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "parallel"); + } if (entry.id === "plugins.entries.xai.config.webSearch.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "grok"); } @@ -864,6 +873,14 @@ describe("secrets runtime target coverage", () => { if (googleChatBatch) { await expectOpenClawCoverageBatchResolved("openclaw.json core", googleChatBatch); } + const webProviderBatch = OPENCLAW_PLUGIN_COVERAGE_BATCHES.find((batch) => + batch.some((entry) => entry.id.includes(".config.webSearch.")), + ); + if (webProviderBatch) { + // Warm the shared plugin snapshot once; individual target assertions then + // measure resolution work instead of one-time manifest discovery. + await expectOpenClawCoverageBatchResolved("openclaw.json plugins", webProviderBatch); + } }); describe("openclaw.json core and channel registry targets", () => { diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index bb6208485f2b..e9f700823693 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -115,7 +115,12 @@ function getCompiledChannelOpenClawTargets( channelId: string, ): CompiledTargetRegistryEntry[] | null { const normalizedChannelId = channelId.trim(); - if (!normalizedChannelId) { + if ( + !normalizedChannelId || + normalizedChannelId === "." || + normalizedChannelId === ".." || + /[\\/:]/.test(normalizedChannelId) + ) { return null; } if (compiledChannelOpenClawTargets.has(normalizedChannelId)) { @@ -144,6 +149,18 @@ function normalizeAllowedTargetIds(targetIds?: Iterable): Set | ); } +function configHasPluginEntries(config: OpenClawConfig): boolean { + return Boolean(config.plugins?.entries && Object.keys(config.plugins.entries).length > 0); +} + +function getConfiguredChannelOpenClawTargets( + config: OpenClawConfig, +): CompiledTargetRegistryEntry[] { + return Object.keys(config.channels ?? {}).flatMap( + (channelId) => getCompiledChannelOpenClawTargets(channelId) ?? [], + ); +} + function resolveDiscoveryEntries(params: { allowedTargetIds: Set | null; defaultEntries: CompiledTargetRegistryEntry[]; @@ -267,6 +284,13 @@ export function isKnownSecretTargetId(value: unknown): value is string { ); } +/** Checks the static core registry without materializing plugin/channel contracts. */ +export function isKnownCoreSecretTargetId(value: unknown): value is string { + return ( + typeof value === "string" && getCompiledCoreOpenClawTargetState().knownTargetIds.has(value) + ); +} + /** * Resolves a secrets apply-plan target against registered target type and path patterns. */ @@ -280,6 +304,15 @@ export function resolvePlanTargetAgainstRegistry(candidate: { if (coreEntries) { return resolvePlanTargetAgainstEntries(candidate, coreEntries); } + const explicitChannelId = + candidate.pathSegments[0] === "channels" ? (candidate.pathSegments[1]?.trim() ?? "") : ""; + if (explicitChannelId) { + const channelEntries = getCompiledChannelOpenClawTargets(explicitChannelId) ?? []; + const channelTypeEntries = buildTargetTypeIndex(channelEntries).get(candidate.type); + if (channelTypeEntries) { + return resolvePlanTargetAgainstEntries(candidate, channelTypeEntries); + } + } const entries = getCompiledSecretTargetRegistryState().targetsByType.get(candidate.type); return resolvePlanTargetAgainstEntries(candidate, entries); } @@ -397,17 +430,26 @@ export function discoverConfigSecretTargetsByIds( targetIds?: Iterable, ): DiscoveredConfigSecretTarget[] { const allowedTargetIds = normalizeAllowedTargetIds(targetIds); - const registryState = + const coreState = getCompiledCoreOpenClawTargetState(); + const hasOnlyCoreTargetIds = allowedTargetIds !== null && - Array.from(allowedTargetIds).every((targetId) => - getCompiledCoreOpenClawTargetState().knownTargetIds.has(targetId), - ) - ? getCompiledCoreOpenClawTargetState() - : getCompiledSecretTargetRegistryState(); + Array.from(allowedTargetIds).every((targetId) => coreState.knownTargetIds.has(targetId)); + const configuredEntries = hasOnlyCoreTargetIds + ? coreState.openClawCompiledSecretTargets + : allowedTargetIds !== null && !configHasPluginEntries(config) + ? [...coreState.openClawCompiledSecretTargets, ...getConfiguredChannelOpenClawTargets(config)] + : null; + const configuredEntriesById = configuredEntries + ? buildConfigTargetIdIndex(configuredEntries) + : null; + const canUseConfiguredEntries = + configuredEntries !== null && + Array.from(allowedTargetIds).every((targetId) => configuredEntriesById?.has(targetId)); + const registryState = canUseConfiguredEntries ? null : getCompiledSecretTargetRegistryState(); const discoveryEntries = resolveDiscoveryEntries({ allowedTargetIds, - defaultEntries: registryState.openClawCompiledSecretTargets, - entriesById: registryState.openClawTargetsById, + defaultEntries: configuredEntries ?? registryState?.openClawCompiledSecretTargets ?? [], + entriesById: configuredEntriesById ?? registryState?.openClawTargetsById ?? new Map(), }); return discoverSecretTargetsFromEntries(config, discoveryEntries); } diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts index 0cff3a37c7c7..bfa7ddad5440 100644 --- a/src/secrets/target-registry.fast-path.test.ts +++ b/src/secrets/target-registry.fast-path.test.ts @@ -28,6 +28,24 @@ const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ ], }; } + if (dirName === "telegram" && artifactBasename === "secret-contract-api.js") { + return { + secretTargetRegistryEntries: [ + { + id: "channels.telegram.botToken", + targetType: "channels.telegram.botToken", + configFile: "openclaw.json", + pathPattern: "channels.telegram.botToken", + refPathPattern: "channels.telegram.botTokenRef", + secretShape: "sibling_ref", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }; + } throw new Error( `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, ); @@ -43,7 +61,11 @@ vi.mock("../plugins/public-surface-loader.js", () => ({ loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, })); -import { resolveConfigSecretTargetByPath } from "./target-registry.js"; +import { + discoverConfigSecretTargetsByIds, + resolveConfigSecretTargetByPath, + resolvePlanTargetAgainstRegistry, +} from "./target-registry.js"; describe("secret target registry fast path", () => { beforeEach(() => { @@ -65,4 +87,38 @@ describe("secret target registry fast path", () => { }); expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); }); + + it("discovers selected core config targets without loading plugin metadata", () => { + const targets = discoverConfigSecretTargetsByIds( + { + gateway: { auth: { token: "test-token" } }, + channels: { telegram: { botToken: "ignored-token" } }, + }, + ["gateway.auth.token"], + ); + + expect(targets.map((target) => target.entry.id)).toEqual(["gateway.auth.token"]); + expect(loadBundledPluginPublicArtifactModuleSyncMock).not.toHaveBeenCalled(); + expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); + }); + + it("discovers selected configured channel targets without loading plugin metadata", () => { + const targets = discoverConfigSecretTargetsByIds( + { channels: { telegram: { botToken: "test-token" } } }, + ["channels.telegram.botToken"], + ); + + expect(targets.map((target) => target.entry.id)).toContain("channels.telegram.botToken"); + expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); + }); + + it("resolves channel plan targets without loading plugin metadata", () => { + const target = resolvePlanTargetAgainstRegistry({ + type: "channels.telegram.botToken", + pathSegments: ["channels", "telegram", "botToken"], + }); + + expect(target?.entry.id).toBe("channels.telegram.botToken"); + expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index 30cbeae1baca..cbcc1e3f8557 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -1,5 +1,5 @@ /** Tests secret target registry matching and docs coverage. */ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { buildTalkTestProviderConfig, @@ -13,6 +13,10 @@ import { } from "./target-registry.js"; describe("secret target registry", () => { + beforeAll(() => { + resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); + }); + it("supports filtered discovery by target ids", () => { const config = { ...buildTalkTestProviderConfig({ source: "env", provider: "default", id: "TALK_API_KEY" }), @@ -110,9 +114,7 @@ describe("secret target registry", () => { "appServer", "authToken", ]); - expect(codexAuthTarget?.entry?.id).toBe( - "plugins.entries.codex.config.appServer.authToken", - ); + expect(codexAuthTarget?.entry?.id).toBe("plugins.entries.codex.config.appServer.authToken"); const codexHeaderTarget = resolveConfigSecretTargetByPath([ "plugins", @@ -123,8 +125,6 @@ describe("secret target registry", () => { "headers", "x-codex-client-session-token", ]); - expect(codexHeaderTarget?.entry?.id).toBe( - "plugins.entries.codex.config.appServer.headers.*", - ); + expect(codexHeaderTarget?.entry?.id).toBe("plugins.entries.codex.config.appServer.headers.*"); }); }); diff --git a/src/status/status-text.test.ts b/src/status/status-text.test.ts index d2cae15b4eea..958ee2988cea 100644 --- a/src/status/status-text.test.ts +++ b/src/status/status-text.test.ts @@ -1,28 +1,17 @@ import { describe, expect, it } from "vitest"; -import { buildStatusText } from "./status-text.js"; +import { resolveStatusChannelFeatureLine } from "./status-text.js"; describe("buildStatusText channel features", () => { it.each([ { richMessages: undefined, expected: "Telegram rich messages: off" }, { richMessages: false, expected: "Telegram rich messages: off" }, { richMessages: true, expected: "Telegram rich messages: on" }, - ])("shows Telegram rich message state for %s", async ({ richMessages, expected }) => { + ])("shows Telegram rich message state for %s", ({ richMessages, expected }) => { const telegram = richMessages === undefined ? {} : { richMessages }; - const text = await buildStatusText({ + const text = resolveStatusChannelFeatureLine({ cfg: { channels: { telegram } }, sessionEntry: { sessionId: `telegram-rich-${String(richMessages)}`, updatedAt: 0 }, - sessionKey: "agent:main:telegram:direct:584667058", statusChannel: "telegram", - provider: "anthropic", - model: "claude-haiku-4-5", - resolvedVerboseLevel: "off", - resolvedReasoningLevel: "off", - resolveDefaultThinkingLevel: async () => "medium", - isGroup: false, - defaultGroupActivation: () => "mention", - taskLineOverride: undefined, - pluginHealthLineOverride: undefined, - skipDefaultTaskLookup: true, }); expect(text).toContain(expected); @@ -33,8 +22,8 @@ describe("buildStatusText channel features", () => { } }); - it("uses Telegram account rich message overrides", async () => { - const text = await buildStatusText({ + it("uses Telegram account rich message overrides", () => { + const text = resolveStatusChannelFeatureLine({ cfg: { channels: { telegram: { @@ -48,26 +37,15 @@ describe("buildStatusText channel features", () => { updatedAt: 0, lastAccountId: "work", }, - sessionKey: "agent:main:telegram:work:direct:584667058", statusChannel: "telegram", - provider: "anthropic", - model: "claude-haiku-4-5", - resolvedVerboseLevel: "off", - resolvedReasoningLevel: "off", - resolveDefaultThinkingLevel: async () => "medium", - isGroup: false, - defaultGroupActivation: () => "mention", - taskLineOverride: undefined, - pluginHealthLineOverride: undefined, - skipDefaultTaskLookup: true, }); expect(text).toContain("Telegram rich messages: off"); expect(text).toContain("enable richMessages for this Telegram account"); }); - it("uses the current Telegram command account before the session records it", async () => { - const text = await buildStatusText({ + it("uses the current Telegram command account before the session records it", () => { + const text = resolveStatusChannelFeatureLine({ cfg: { channels: { telegram: { @@ -80,19 +58,8 @@ describe("buildStatusText channel features", () => { sessionId: "telegram-rich-command-account", updatedAt: 0, }, - sessionKey: "agent:main:telegram:work:direct:584667058", statusChannel: "telegram", statusAccountId: "work", - provider: "anthropic", - model: "claude-haiku-4-5", - resolvedVerboseLevel: "off", - resolvedReasoningLevel: "off", - resolveDefaultThinkingLevel: async () => "medium", - isGroup: false, - defaultGroupActivation: () => "mention", - taskLineOverride: undefined, - pluginHealthLineOverride: undefined, - skipDefaultTaskLookup: true, }); expect(text).toContain("Telegram rich messages: off"); diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 3e6f8692ee2a..1a82faec3565 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -69,7 +69,7 @@ const USAGE_OAUTH_ONLY_PROVIDERS = new Set([ ]); const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home"; -function resolveStatusChannelFeatureLine(params: { +export function resolveStatusChannelFeatureLine(params: { cfg: OpenClawConfig; statusChannel: string; statusAccountId?: string; diff --git a/src/test-utils/repo-files.ts b/src/test-utils/repo-files.ts index 99ee233d9ff6..10d79e69c5a7 100644 --- a/src/test-utils/repo-files.ts +++ b/src/test-utils/repo-files.ts @@ -1,9 +1,14 @@ // Test helpers for reading repository files through git-aware paths. import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; const gitTrackedFilesCache = new Map(); +function filterExistingRepoFiles(repoRoot: string, files: readonly string[]): string[] { + return files.filter((file) => fs.existsSync(path.join(repoRoot, file))); +} + /** Normalizes file paths to repo-style forward slash separators. */ export function toRepoPath(filePath: string): string { return filePath.replaceAll("\\", "/"); @@ -26,7 +31,7 @@ export function listGitTrackedFiles(params: { const cacheKey = JSON.stringify({ repoRoot, pathspecs }); const cached = gitTrackedFilesCache.get(cacheKey); if (cached !== undefined) { - return cached ? [...cached] : null; + return cached ? filterExistingRepoFiles(repoRoot, cached) : null; } const result = spawnSync("git", ["ls-files", "--", ...pathspecs], { cwd: repoRoot, @@ -45,5 +50,6 @@ export function listGitTrackedFiles(params: { .filter((line) => line.length > 0), ); gitTrackedFilesCache.set(cacheKey, files); - return [...files]; + // Staged deletions remain in `git ls-files`, but callers scan the working tree. + return filterExistingRepoFiles(repoRoot, files); } diff --git a/src/tui/commands.test.ts b/src/tui/commands.test.ts index 3d4a63a63c3c..3f0b8176338d 100644 --- a/src/tui/commands.test.ts +++ b/src/tui/commands.test.ts @@ -1,5 +1,5 @@ // Verifies TUI command definitions and parser metadata. -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getSlashCommands, helpText, parseCommand } from "./commands.js"; describe("parseCommand", () => { @@ -23,6 +23,11 @@ describe("parseCommand", () => { }); describe("getSlashCommands", () => { + beforeAll(() => { + // Provider thinking policies are process-stable; warm the fallback before timing assertions. + getSlashCommands({ provider: "anthropic", model: "claude-sonnet-4-6", thinkingLevels: [] }); + }); + it("provides level completions for built-in toggles", () => { const commands = getSlashCommands(); const verbose = commands.find((command) => command.name === "verbose"); @@ -71,7 +76,7 @@ describe("getSlashCommands", () => { ]); }); - it("falls back to provider-resolved levels when thinkingLevels is empty (#76482)", async () => { + it("falls back to provider-resolved levels when thinkingLevels is empty (#76482)", () => { const commands = getSlashCommands({ provider: "anthropic", model: "claude-sonnet-4-6", @@ -79,8 +84,12 @@ describe("getSlashCommands", () => { }); const think = commands.find((command) => command.name === "think"); // Should fall back to listThinkingLevelLabels, not return empty completions - const completions = await think?.getArgumentCompletions?.(""); - expect(completions?.length).toBeGreaterThan(0); + const completions = think?.getArgumentCompletions?.(""); + expect(Array.isArray(completions)).toBe(true); + if (!Array.isArray(completions)) { + throw new Error("expected synchronous thinking-level completions"); + } + expect(completions.length).toBeGreaterThan(0); }); it("merges dynamic gateway commands", () => { diff --git a/src/wizard/setup.migration-import.test.ts b/src/wizard/setup.migration-import.test.ts index de550c3df0e7..fd02ab75f345 100644 --- a/src/wizard/setup.migration-import.test.ts +++ b/src/wizard/setup.migration-import.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { inspectSetupMigrationFreshness, listSetupMigrationOptions, @@ -93,13 +93,17 @@ describe("setup migration import freshness", () => { }); describe("setup migration import options", () => { - it("offers bundled manifest migration providers before plugin activation", async () => { - const options = await listSetupMigrationOptions({ + let initialOptions: Awaited>; + + beforeAll(async () => { + initialOptions = await listSetupMigrationOptions({ baseConfig: {}, detections: [], }); + }); - expect(options).toEqual( + it("offers bundled manifest migration providers before plugin activation", () => { + expect(initialOptions).toEqual( expect.arrayContaining([ expect.objectContaining({ providerId: "codex", label: "Codex" }), expect.objectContaining({ providerId: "claude", label: "Claude" }), diff --git a/test/e2e/qa-lab/media/hosted-media-provider-live.test.ts b/test/e2e/qa-lab/media/hosted-media-provider-live.test.ts index 63de53c50450..ee29c2ecca70 100644 --- a/test/e2e/qa-lab/media/hosted-media-provider-live.test.ts +++ b/test/e2e/qa-lab/media/hosted-media-provider-live.test.ts @@ -1,5 +1,4 @@ // Hosted media provider live producer tests cover QA evidence wiring. -import { spawnSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -10,6 +9,7 @@ import { buildHostedMediaEvidence, classifyHostedMediaFailureStatus, findSkippedExplicitProviderSelections, + formatHelp, parseArgs, parseHostedMediaOptions, runCli, @@ -100,16 +100,11 @@ describe("hosted media provider live QA producer", () => { }); describe("hosted media provider live CLI", () => { - it("prints help through the real node --import tsx entrypoint", () => { - const result = spawnSync(process.execPath, ["--import", "tsx", SOURCE_PATH, "--help"], { - cwd: process.cwd(), - encoding: "utf8", - }); + it("prints help for the live media command", () => { + const help = formatHelp(); - expect(result.status).toBe(0); - expect(result.stdout).toContain("Media live harness"); - expect(result.stdout).toContain("pnpm test:live:media"); - expect(result.stderr).toBe(""); + expect(help).toContain("Media live harness"); + expect(help).toContain("pnpm test:live:media"); }); it("rejects unknown global providers for the selected suites", () => { diff --git a/test/e2e/qa-lab/media/hosted-media-provider-live.ts b/test/e2e/qa-lab/media/hosted-media-provider-live.ts index b56557cbddfe..21df3edfa618 100644 --- a/test/e2e/qa-lab/media/hosted-media-provider-live.ts +++ b/test/e2e/qa-lab/media/hosted-media-provider-live.ts @@ -481,8 +481,8 @@ export async function buildRunPlan( ); } -function printHelp(): void { - console.log(`Media live harness +export function formatHelp(): string { + return `Media live harness Usage: pnpm test:live:media @@ -509,7 +509,11 @@ Flags: --all-providers do not auto-filter by available auth --allow-empty exit 0 when auth filtering leaves no runnable providers --quiet | --no-quiet passed through to test:live -`); +`; +} + +function printHelp(): void { + console.log(formatHelp()); } export async function runSuite(params: { diff --git a/test/e2e/qa-lab/plugins/clawhub-release-candidate-install.test.ts b/test/e2e/qa-lab/plugins/clawhub-release-candidate-install.test.ts index f0566628e5d3..29e9d8f84a00 100644 --- a/test/e2e/qa-lab/plugins/clawhub-release-candidate-install.test.ts +++ b/test/e2e/qa-lab/plugins/clawhub-release-candidate-install.test.ts @@ -1,18 +1,18 @@ // ClawHub release candidate producer tests cover blocked script evidence output. -import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { validateQaEvidenceSummaryJson } from "../../../../extensions/qa-lab/api.js"; +import { runClawHubReleaseCandidateInstallProducer } from "./clawhub-release-candidate-install.js"; -const SOURCE_PATH = "test/e2e/qa-lab/plugins/clawhub-release-candidate-install.ts"; const tempRoots: string[] = []; afterEach(async () => { await Promise.all( tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), ); + vi.unstubAllEnvs(); }); describe("ClawHub release candidate install producer", () => { @@ -22,27 +22,19 @@ describe("ClawHub release candidate install producer", () => { ); tempRoots.push(artifactBase); const missingTarballEnv = "OPENCLAW_TEST_MISSING_RELEASE_CANDIDATE_TARBALL"; - const env = { ...process.env }; - delete env[missingTarballEnv]; + vi.stubEnv(missingTarballEnv, ""); - const result = spawnSync( - process.execPath, - [ - "--import", - "tsx", - SOURCE_PATH, - "--artifact-base", - artifactBase, - "--tarball-env", - missingTarballEnv, - ], - { cwd: process.cwd(), encoding: "utf8", env }, - ); - - expect(result.status).toBe(0); + const result = await runClawHubReleaseCandidateInstallProducer({ + artifactBase, + buildFromCheckout: false, + repoRoot: process.cwd(), + tarballEnv: missingTarballEnv, + }); + const evidencePath = path.join(artifactBase, "qa-evidence.json"); const evidence = validateQaEvidenceSummaryJson( - JSON.parse(await fs.readFile(path.join(artifactBase, "qa-evidence.json"), "utf8")), + JSON.parse(await fs.readFile(evidencePath, "utf8")), ); + expect(result).toEqual(evidence); expect(evidence.entries[0]).toMatchObject({ execution: { artifacts: [{ kind: "log", path: "parallels-npm-update.log", source: "script" }], @@ -54,6 +46,5 @@ describe("ClawHub release candidate install producer", () => { }, }, }); - expect(result.stdout).toContain("ClawHub release-candidate install status: blocked"); }); }); diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index 9d6deb9979c1..b53a68ec7621 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -554,8 +554,7 @@ describe("collectPluginClawHubReleasePlan", () => { const repoDir = createTempPluginRepo(); let trustedPublisherRequests = 0; let rateLimitedBodyCanceled = false; - let firstTrustedPublisherRequestAt: number | undefined; - let retryTrustedPublisherRequestAt: number | undefined; + const retryDelays: number[] = []; const fetchImpl: typeof fetch = async (input) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; @@ -566,7 +565,6 @@ describe("collectPluginClawHubReleasePlan", () => { if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") { trustedPublisherRequests += 1; if (trustedPublisherRequests === 1) { - firstTrustedPublisherRequestAt = Date.now(); return new Response( new ReadableStream({ cancel() { @@ -576,7 +574,6 @@ describe("collectPluginClawHubReleasePlan", () => { { status: 429 }, ); } - retryTrustedPublisherRequestAt = Date.now(); return new Response( JSON.stringify({ trustedPublisher: { @@ -598,13 +595,14 @@ describe("collectPluginClawHubReleasePlan", () => { selection: ["@openclaw/demo-plugin"], fetchImpl, registryBaseUrl: "https://clawhub.ai", + sleep: async (ms) => { + retryDelays.push(ms); + }, }); expect(trustedPublisherRequests).toBe(2); expect(rateLimitedBodyCanceled).toBe(true); - expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual( - (firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900, - ); + expect(retryDelays).toEqual([1_000]); expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]); }); @@ -613,8 +611,7 @@ describe("collectPluginClawHubReleasePlan", () => { const retryAfter = "Wed, 21 Oct 2030 07:28:00 GMT"; const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(retryAfter) - 1_000); let trustedPublisherRequests = 0; - let firstTrustedPublisherRequestAt: number | undefined; - let retryTrustedPublisherRequestAt: number | undefined; + const retryDelays: number[] = []; const fetchImpl: typeof fetch = async (input) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; @@ -625,10 +622,8 @@ describe("collectPluginClawHubReleasePlan", () => { if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") { trustedPublisherRequests += 1; if (trustedPublisherRequests === 1) { - firstTrustedPublisherRequestAt = performance.now(); return new Response("", { status: 429, headers: { "retry-after": retryAfter } }); } - retryTrustedPublisherRequestAt = performance.now(); return new Response( JSON.stringify({ trustedPublisher: { @@ -651,22 +646,22 @@ describe("collectPluginClawHubReleasePlan", () => { selection: ["@openclaw/demo-plugin"], fetchImpl, registryBaseUrl: "https://clawhub.ai", + sleep: async (ms) => { + retryDelays.push(ms); + }, }); } finally { nowSpy.mockRestore(); } expect(trustedPublisherRequests).toBe(2); - expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual( - (firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900, - ); + expect(retryDelays).toEqual([1_000]); }); it("falls back to the bounded retry schedule for an excessive Retry-After header", async () => { const repoDir = createTempPluginRepo(); let trustedPublisherRequests = 0; - let firstTrustedPublisherRequestAt: number | undefined; - let retryTrustedPublisherRequestAt: number | undefined; + const retryDelays: number[] = []; const fetchImpl: typeof fetch = async (input) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; @@ -677,10 +672,8 @@ describe("collectPluginClawHubReleasePlan", () => { if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") { trustedPublisherRequests += 1; if (trustedPublisherRequests === 1) { - firstTrustedPublisherRequestAt = Date.now(); return new Response("", { status: 429, headers: { "retry-after": "999999999999" } }); } - retryTrustedPublisherRequestAt = Date.now(); return new Response( JSON.stringify({ trustedPublisher: { @@ -702,12 +695,13 @@ describe("collectPluginClawHubReleasePlan", () => { selection: ["@openclaw/demo-plugin"], fetchImpl, registryBaseUrl: "https://clawhub.ai", + sleep: async (ms) => { + retryDelays.push(ms); + }, }); expect(trustedPublisherRequests).toBe(2); - expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual( - (firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900, - ); + expect(retryDelays).toEqual([1_000]); }); it("routes missing package rows to bootstrap candidates instead of normal candidates", async () => { diff --git a/test/scripts/bench-cli-startup.test.ts b/test/scripts/bench-cli-startup.test.ts index fdf518c8f158..a4792df914de 100644 --- a/test/scripts/bench-cli-startup.test.ts +++ b/test/scripts/bench-cli-startup.test.ts @@ -60,15 +60,7 @@ describe("bench-cli-startup", () => { it("rejects duplicate benchmark cases before running benchmarks", () => { const result = spawnSync( process.execPath, - [ - "--import", - "tsx", - "scripts/bench-cli-startup.ts", - "--case", - "version", - "--case", - "version", - ], + ["--import", "tsx", "scripts/bench-cli-startup.ts", "--case", "version", "--case", "version"], { cwd: join(__dirname, "../.."), encoding: "utf8", @@ -83,9 +75,9 @@ describe("bench-cli-startup", () => { }); it("rejects duplicate single-value controls before running benchmarks", () => { - expect(() => - testing.validateCliArgs(["--output", "one.json", "--output", "two.json"]), - ).toThrow("--output was provided more than once"); + expect(() => testing.validateCliArgs(["--output", "one.json", "--output", "two.json"])).toThrow( + "--output was provided more than once", + ); const result = spawnSync( process.execPath, @@ -158,6 +150,11 @@ describe("bench-cli-startup", () => { { cwd: join(__dirname, "../.."), encoding: "utf8", + env: { + ...process.env, + OPENCLAW_TEST_CLI_STARTUP_TIMEOUT_KILL_GRACE_MS: "50", + VITEST: "1", + }, timeout: 8_000, }, ); diff --git a/test/scripts/bundled-plugin-install-uninstall-probe.test.ts b/test/scripts/bundled-plugin-install-uninstall-probe.test.ts index ba2f45abf842..30a3bce129ae 100644 --- a/test/scripts/bundled-plugin-install-uninstall-probe.test.ts +++ b/test/scripts/bundled-plugin-install-uninstall-probe.test.ts @@ -701,7 +701,7 @@ describe("bundled plugin install/uninstall probe", () => { let descendantPid: number | undefined; try { const commandResult = runtimeSmoke - .runCommand(process.execPath, [commandPath], { detached: undefined, timeoutMs: 1000 }) + .runCommand(process.execPath, [commandPath], { detached: undefined, timeoutMs: 250 }) .catch((error: unknown) => error); await waitForFile(descendantPidPath, 1000); descendantPid = Number(fs.readFileSync(descendantPidPath, "utf8")); @@ -709,7 +709,7 @@ describe("bundled plugin install/uninstall probe", () => { if (!(error instanceof Error)) { throw new Error("expected runtime command to time out"); } - expect(error.message).toMatch(/timed out after 1000ms/u); + expect(error.message).toMatch(/timed out after 250ms/u); await waitForDead(descendantPid, 2000); } finally { diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index 1108a6d8da15..d366f5d881b4 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -210,7 +210,7 @@ function makeSlowVersionCrabbox(helpText: string): string { const script = [ "#!/usr/bin/env node", "const args = process.argv.slice(2);", - 'if (args[0] === "--version") { setTimeout(() => process.exit(0), 6_000); }', + 'if (args[0] === "--version") { setTimeout(() => process.exit(0), 1_000); }', `else if (args[0] === "run" && args[1] === "--help") { process.stdout.write(${JSON.stringify(helpText)}); }`, ].join("\n"); writeFileSync(crabboxPath, `${script}\n`, "utf8"); @@ -497,6 +497,7 @@ async function runSignalCleanupProof(sendSignals: (pid: number) => Promise { env: { OPENCLAW_FAKE_CRABBOX_DESCENDANT_PID_PATH: descendantPidPath, + OPENCLAW_TEST_CRABBOX_CHILD_KILL_GRACE_MS: "100", }, nodePreload: testTimingPreload({ clockScale: 20 }), }, @@ -575,7 +576,7 @@ afterAll(() => { } }); -describe.concurrent("scripts/crabbox-wrapper", () => { +describe("scripts/crabbox-wrapper", () => { const azureProviderHelp = "provider: hetzner, aws, azure, local-container, blacksmith-testbox, or cloudflare\n"; const advertisedProviderAliasHelp = [ @@ -3116,6 +3117,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => { it("times out hung sanity probes before rejecting the selected binary", () => { const helpText = "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n"; const result = runWrapper(helpText, ["--version"], { + env: { OPENCLAW_TEST_CRABBOX_METADATA_PROBE_TIMEOUT_MS: "100" }, extraPathEntries: [makeSlowVersionCrabbox(helpText)], nodePreload: testTimingPreload({ spawnTimeoutMs: 25 }), }); diff --git a/test/scripts/dev-tooling-safety.test.ts b/test/scripts/dev-tooling-safety.test.ts index 31c8657fcec7..b8a2f7ff1f6f 100644 --- a/test/scripts/dev-tooling-safety.test.ts +++ b/test/scripts/dev-tooling-safety.test.ts @@ -593,42 +593,17 @@ describe("script-specific dev tooling hardening", () => { ); }); - it("prints OpenAI realtime smoke help without launching live checks", () => { + it("formats OpenAI realtime smoke help without launching live checks", () => { expect(realtimeSmokeTesting.parseRealtimeSmokeArgs(["--help"])).toEqual({ help: true }); - - const result = spawnSync( - process.execPath, - ["--import", "tsx", "scripts/dev/realtime-talk-live-smoke.ts", "--help"], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); - - expect(result.status).toBe(0); - expect(result.stdout).toContain( + expect(realtimeSmokeTesting.usage()).toContain( "Usage: node --import tsx scripts/dev/realtime-talk-live-smoke.ts", ); - expect(result.stderr).toBe(""); }); - it("rejects unknown OpenAI realtime smoke args before launching live checks", () => { + it("rejects unknown OpenAI realtime smoke args before runtime setup", () => { expect(() => realtimeSmokeTesting.parseRealtimeSmokeArgs(["--wat"])).toThrow( "Unknown argument: --wat", ); - - const result = spawnSync( - process.execPath, - ["--import", "tsx", "scripts/dev/realtime-talk-live-smoke.ts", "--wat"], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); - - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr.trim()).toBe("Unknown argument: --wat"); }); it("bounds OpenAI realtime smoke response body reads by content-length", async () => { @@ -774,7 +749,7 @@ describe("script-specific dev tooling hardening", () => { ...process.env, CLAUDE_BIN: fakeClaudeBin, OPENCLAW_PROMPT_TEXT: "timeout cleanup proof", - OPENCLAW_PROMPT_TIMEOUT_MS: "1000", + OPENCLAW_PROMPT_TIMEOUT_MS: "500", OPENCLAW_PROMPT_TRANSPORT: "direct", }, stdio: "ignore", @@ -953,7 +928,7 @@ describe("script-specific dev tooling hardening", () => { }, }, 50, - 1_000, + 100, ); expect(stopped).toBe(true); @@ -1006,7 +981,7 @@ describe("script-specific dev tooling hardening", () => { `const child = childProcess.spawn(process.execPath, ['--input-type=module', '--eval', ${JSON.stringify(leaderScript)}], { detached: true, stdio: 'ignore' });`, "let stopPromise;", "const stopGateway = () => {", - " stopPromise ??= testing.stopGatewayPromptChild(child, { close: async () => {} }, 50, 1000);", + " stopPromise ??= testing.stopGatewayPromptChild(child, { close: async () => {} }, 50, 100);", " return stopPromise;", "};", "testing.installGatewayPromptParentSignalHandlers(child, stopGateway);", diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index a0b7cfa4eee7..49c2c023366e 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -1,6 +1,14 @@ // Docker All Scheduler tests cover docker all scheduler script behavior. import { spawn, spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; @@ -15,10 +23,12 @@ import { LOG_TAIL_MAX_BYTES, parseDockerAllCliArgs, resolveDockerPreflightPlatform, + runCleanupSmokePhase, runShellCaptureCommand, runShellCommand, SHELL_CAPTURE_MAX_CHARS, tailFile, + writeRunSummary, } from "../../scripts/test-docker-all.mjs"; import { createScriptTestHarness } from "./test-helpers.js"; @@ -196,12 +206,12 @@ describe("scripts/test-docker-all scheduler", () => { } }); - posixIt("writes Docker run artifacts when cleanup smoke fails", () => { + posixIt("writes Docker run artifacts when cleanup smoke fails", async () => { const root = mkdtempSync(`${tmpdir()}/openclaw-docker-all-cleanup-`); const logDir = path.join(root, "logs"); - const packageTgz = path.join(root, "openclaw-current.tgz"); const fakePnpm = path.join(root, "pnpm"); - writeFileSync(packageTgz, "fake package\n", "utf8"); + const phases: Array> = []; + mkdirSync(logDir, { recursive: true }); writeFileSync( fakePnpm, `#!/usr/bin/env node @@ -217,28 +227,30 @@ process.exit(0); chmodSync(fakePnpm, 0o755); try { - const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs"], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - OPENCLAW_CURRENT_PACKAGE_TGZ: packageTgz, - OPENCLAW_DOCKER_ALL_BUILD: "0", - OPENCLAW_DOCKER_ALL_LIVE_MODE: "skip", - OPENCLAW_DOCKER_ALL_LOG_DIR: logDir, - OPENCLAW_DOCKER_ALL_PARALLELISM: "16", - OPENCLAW_DOCKER_ALL_PREFLIGHT: "0", - OPENCLAW_DOCKER_ALL_START_STAGGER_MS: "0", - OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS: "0", - OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM: "16", - OPENCLAW_DOCKER_ALL_TIMINGS: "0", - PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`, + const baseEnv = { + ...process.env, + OPENCLAW_DOCKER_E2E_IMAGE: "openclaw-test-image", + PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`, + }; + const cleanupFailure = await runCleanupSmokePhase(baseEnv, logDir, phases); + expect(cleanupFailure).toMatchObject({ name: "cleanup-smoke", status: 42 }); + if (!cleanupFailure) { + throw new Error("expected cleanup smoke failure"); + } + await writeRunSummary(logDir, { + failures: [cleanupFailure], + image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, + images: { + bare: "openclaw-test-bare", + functional: "openclaw-test-image", }, + lanes: [], + phases, + profile: "local", + startedAt: new Date().toISOString(), + status: "failed", }); - expect(result.status).toBe(1); - expect(result.stderr).toContain("cleanup smoke failed intentionally"); - const summary = JSON.parse(readFileSync(path.join(logDir, "summary.json"), "utf8")); expect(summary.status).toBe("failed"); expect(summary.failures).toHaveLength(1); @@ -560,7 +572,7 @@ setInterval(() => {}, 1000); env: process.env, label: "timeout-leader-exits", timeoutKillGraceMs: 25, - timeoutMs: 1_000, + timeoutMs: 250, }); await waitFor(() => existsSync(grandchildPidPath)); diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 3e0df3d4bc4c..472c0c614a54 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -552,7 +552,7 @@ shift 2 join(binDir, "docker"), `#!/bin/sh printf "captured docker build log\\n" -/bin/sleep 2 +/bin/sleep 0.05 `, ); chmodSync(join(binDir, "docker"), 0o755); @@ -567,7 +567,8 @@ export OPENCLAW_DOCKER_BUILD_HEARTBEAT_SECONDS=1 source "$ROOT_DIR/scripts/lib/docker-build.sh" -output="$(docker_build_run e2e-build -t demo-image .)" +printf "captured docker build log\\n" >"$TMPDIR/build.log" +output="$(docker_build_maybe_print_heartbeat e2e-build 1 1 "$TMPDIR/build.log")" [[ "$output" = *"Docker build e2e-build still running ("* ]] [[ "$output" = *"log bytes captured"* ]] [[ "$output" != *"captured docker build log"* ]] @@ -627,7 +628,7 @@ docker_build_run e2e-build -t demo-image . if (existsSync(filePath)) { return; } - await delay(100); + await delay(10); } throw new Error(`file was not written: ${filePath}`); }; @@ -642,7 +643,7 @@ docker_build_run e2e-build -t demo-image . } catch { return; } - await delay(100); + await delay(10); } throw new Error(`process stayed alive: ${pid}`); }; @@ -3099,10 +3100,11 @@ export ROOT_DIR TMPDIR source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" -output="$(run_logged_print_heartbeat plugins-run 1 bash -c 'printf "captured container log\\\\n"; /bin/sleep 4')" +printf "captured container log\\n" >"$TMPDIR/run.log" +output="$(docker_e2e_maybe_print_log_heartbeat plugins-run 1 1 "$TMPDIR/run.log")" [[ "$output" = *"still running plugins-run ("* ]] [[ "$output" = *"log bytes captured"* ]] -[[ "$output" = *"captured container log"* ]] +[[ "$output" != *"captured container log"* ]] `; execFileSync("bash", ["-lc", script], { encoding: "utf8" }); @@ -3121,17 +3123,18 @@ set -euo pipefail ROOT_DIR=${shellQuote(rootDir)} TMPDIR=${shellQuote(workDir)} export ROOT_DIR TMPDIR +export OPENCLAW_DOCKER_E2E_HEARTBEAT_TERM_GRACE_SECONDS=1 source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" command_pid_file="$TMPDIR/command.pid" ( - run_logged_print_heartbeat plugins-run 30 bash -c 'printf "%s" "$$" > "$1"; while true; do /bin/sleep 1; done' bash "$command_pid_file" + run_logged_print_heartbeat plugins-run 30 bash -c 'trap "exit 0" TERM; printf "%s" "$$" > "$1"; while true; do /bin/sleep 0.05; done' bash "$command_pid_file" ) & wrapper_pid="$!" for _ in $(seq 1 100); do [ -s "$command_pid_file" ] && break - /bin/sleep 0.1 + /bin/sleep 0.01 done if [ ! -s "$command_pid_file" ]; then kill -TERM "$wrapper_pid" 2>/dev/null || true @@ -3140,13 +3143,12 @@ if [ ! -s "$command_pid_file" ]; then fi command_pid="$(cat "$command_pid_file")" kill -TERM "$wrapper_pid" -/bin/sleep 2 for _ in $(seq 1 50); do if ! kill -0 "$command_pid" 2>/dev/null; then wait "$wrapper_pid" 2>/dev/null || true exit 0 fi - /bin/sleep 0.1 + /bin/sleep 0.01 done kill -TERM "$command_pid" 2>/dev/null || true kill -TERM "$wrapper_pid" 2>/dev/null || true @@ -3217,8 +3219,10 @@ docker() { test -n "$cidfile" printf "container-term\\n" >"$cidfile" + printf "started\\n" >"$TMPDIR/docker-started" printf "docker running\\n" - while true; do /bin/sleep 10; done + trap 'exit 143' TERM + while true; do /bin/sleep 0.05; done } export -f docker @@ -3227,15 +3231,16 @@ export -f docker ) & wrapper_pid="$!" for _ in $(seq 1 50); do - [ -s "$TMPDIR/docker-rm-seen" ] && break - /bin/sleep 0.1 + [ -s "$TMPDIR/docker-started" ] && break + /bin/sleep 0.01 kill -0 "$wrapper_pid" 2>/dev/null || true done +test -s "$TMPDIR/docker-started" kill -TERM "$wrapper_pid" 2>/dev/null || true wait "$wrapper_pid" 2>/dev/null || true for _ in $(seq 1 50); do grep -qx "container-term" "$TMPDIR/docker-rm-seen" 2>/dev/null && break - /bin/sleep 0.1 + /bin/sleep 0.01 done grep -qx "container-term" "$TMPDIR/docker-rm-seen" test -z "$(find "$TMPDIR" -maxdepth 1 -name 'openclaw-docker-e2e-container.*' -print)" diff --git a/test/scripts/docs-i18n-behavior.test.ts b/test/scripts/docs-i18n-behavior.test.ts deleted file mode 100644 index 83f9a026e2ed..000000000000 --- a/test/scripts/docs-i18n-behavior.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Docs i18n behavior tests keep JSON fixture edits tied to the Go baseline suite. -import { spawnSync } from "node:child_process"; -import { describe, expect, it } from "vitest"; - -const hasGoToolchain = spawnSync("go", ["version"], { encoding: "utf8" }).status === 0; - -describe.skipIf(!hasGoToolchain)("docs-i18n behavior baselines", () => { - it("keeps behavior fixtures passing", () => { - const result = spawnSync( - "go", - ["test", "./...", "-run", "TestDocsI18nBehaviorBaselines", "-count=1"], - { - cwd: "scripts/docs-i18n", - encoding: "utf8", - }, - ); - - expect(result.error).toBeUndefined(); - expect(result.status, result.stderr || result.stdout).toBe(0); - }); -}); diff --git a/test/scripts/docs-i18n.test.ts b/test/scripts/docs-i18n.test.ts index 93f63c5cdf7f..2ce878020ee5 100644 --- a/test/scripts/docs-i18n.test.ts +++ b/test/scripts/docs-i18n.test.ts @@ -1,17 +1,52 @@ -// Docs i18n tests cover the Go module backing docs translation. +// Docs i18n tests cover the Go module and behavior fixtures backing docs translation. import { spawnSync } from "node:child_process"; -import { describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; const hasGoToolchain = spawnSync("go", ["version"], { encoding: "utf8" }).status === 0; describe.skipIf(!hasGoToolchain)("docs-i18n Go module", () => { - it("passes Go tests", () => { - const result = spawnSync("go", ["test", "./...", "-count=1"], { + let binaryPath = ""; + let tempDir = ""; + + beforeAll(() => { + const tempRoot = tmpdir() === "/tmp" ? "/var/tmp" : tmpdir(); + tempDir = mkdtempSync(path.join(tempRoot, "openclaw-docs-i18n-test-")); + binaryPath = path.join( + tempDir, + process.platform === "win32" ? "docs-i18n.test.exe" : "docs-i18n.test", + ); + const result = spawnSync("go", ["test", "-c", "-o", binaryPath, "."], { cwd: "scripts/docs-i18n", encoding: "utf8", }); + if (result.error || result.status !== 0) { + throw result.error ?? new Error(result.stderr || result.stdout || "failed to build Go tests"); + } + }); + + afterAll(() => { + if (tempDir) { + rmSync(tempDir, { force: true, recursive: true }); + } + }); + + it.each([ + ["A-F", "^Test[A-F]"], + ["G-L", "^Test[G-L]"], + ["M-R", "^Test[M-R]"], + ["S-Z", "^Test[S-Z]"], + ])("passes Go tests in the %s partition", (_partition, pattern) => { + const result = spawnSync(binaryPath, ["-test.count=1", `-test.run=${pattern}`], { + cwd: "scripts/docs-i18n", + encoding: "utf8", + // The fixture verifies Codex auth never lands under the shared system temp directory. + env: { ...process.env, XDG_CACHE_HOME: path.join(tempDir, "cache") }, + }); expect(result.error).toBeUndefined(); - expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.status, [result.stderr, result.stdout].filter(Boolean).join("\n")).toBe(0); }); }); diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index f56d5c7189f5..111182a22bab 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -872,7 +872,7 @@ NODE [ "#!/usr/bin/env bash", 'if [[ "$1" == "prefix" && "$2" == "-g" ]]; then', - " sleep 3", + " sleep 2", " exit 0", "fi", 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "prefix" ]]; then', @@ -889,7 +889,7 @@ NODE const result = runInstallShell( [`source ${JSON.stringify(SCRIPT_PATH)}`, "npm_global_bin_dir"].join("\n"), { - OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "1", + OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "0.1", PATH: `${tmp}:${process.env.PATH ?? ""}`, }, ); diff --git a/test/scripts/issue-78851-model-resolution.test.ts b/test/scripts/issue-78851-model-resolution.test.ts index d0848e8b937d..c534234db12a 100644 --- a/test/scripts/issue-78851-model-resolution.test.ts +++ b/test/scripts/issue-78851-model-resolution.test.ts @@ -1,59 +1,61 @@ // Issue 78851 profiler CLI tests cover argument handling before work starts. import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; - -function runProfiler(...args: string[]) { - return spawnSync( - process.execPath, - ["--import", "tsx", "scripts/perf/issue-78851-model-resolution.ts", ...args], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); -} +import { + issue78851ModelResolutionHelpRequested, + issue78851ModelResolutionUsage, + parseIssue78851ModelResolutionOptions, +} from "../../scripts/perf/issue-78851-model-resolution-cli.js"; describe("issue 78851 model resolution profiler CLI", () => { it("prints help without starting the profiler", () => { - const result = runProfiler("--help"); + const usage = issue78851ModelResolutionUsage(); - expect(result.status).toBe(0); - expect(result.stdout).toContain("OpenClaw issue #78851 model-resolution profiler"); - expect(result.stdout).toContain( + expect(issue78851ModelResolutionHelpRequested(["--help"])).toBe(true); + expect(usage).toContain("OpenClaw issue #78851 model-resolution profiler"); + expect(usage).toContain( "node --import tsx scripts/perf/issue-78851-model-resolution.ts [options]", ); - expect(result.stderr).toBe(""); }); it("rejects unknown arguments before starting the profiler", () => { - const result = runProfiler("--wat"); - - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr.trim()).toBe("Unknown argument: --wat"); + expect(() => parseIssue78851ModelResolutionOptions(["--wat"])).toThrow( + "Unknown argument: --wat", + ); }); it("rejects partial numeric arguments before starting the profiler", () => { - const result = runProfiler("--providers", "48junk"); - - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr.trim()).toBe("--providers must be a positive integer"); + expect(() => parseIssue78851ModelResolutionOptions(["--providers", "48junk"])).toThrow( + "--providers must be a positive integer", + ); }); it("rejects short flag values before starting the profiler", () => { - const result = runProfiler("--providers", "-h"); + expect(() => parseIssue78851ModelResolutionOptions(["--providers", "-h"])).toThrow( + "--providers requires a value", + ); + }); + + it("rejects invalid arguments even when help is also requested", () => { + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/perf/issue-78851-model-resolution.ts", + "--wat", + "--help", + ], + { encoding: "utf8" }, + ); expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr.trim()).toBe("--providers requires a value"); + expect(result.stderr).toContain("Unknown argument: --wat"); }); it("rejects duplicate value flags before starting the profiler", () => { - const result = runProfiler("--providers", "48", "--providers", "96"); - - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr.trim()).toBe("--providers was provided more than once"); + expect(() => + parseIssue78851ModelResolutionOptions(["--providers", "48", "--providers", "96"]), + ).toThrow("--providers was provided more than once"); }); }); diff --git a/test/scripts/kitchen-sink-plugin-assertions.test.ts b/test/scripts/kitchen-sink-plugin-assertions.test.ts index 567e79f3c97c..f60d10161e5f 100644 --- a/test/scripts/kitchen-sink-plugin-assertions.test.ts +++ b/test/scripts/kitchen-sink-plugin-assertions.test.ts @@ -842,6 +842,7 @@ exit "$status" const scratchRoot = path.join(parent, "scratch"); const fixtureDir = path.join(scratchRoot, "clawhub-fixture"); const nodeShim = path.join(fakeBin, "node"); + const sleepShim = path.join(fakeBin, "sleep"); try { mkdirSync(fakeBin, { recursive: true }); mkdirSync(fixtureDir, { recursive: true }); @@ -852,11 +853,24 @@ exit "$status" "printf 'DO_NOT_DUMP_CLAWHUB_PREFIX\\n'", "head -c 2048 /dev/zero | tr '\\0' x", "printf '\\nFIXTURE_TAIL_MARKER\\n'", - "sleep 30", + "/bin/sleep 30", "", ].join("\n"), ); chmodSync(nodeShim, 0o755); + writeFileSync( + sleepShim, + [ + "#!/usr/bin/env bash", + "for _ in $(seq 1 50); do", + ' grep -q "FIXTURE_TAIL_MARKER" "$FIXTURE_DIR/clawhub-fixture.log" && exit 0', + " /bin/sleep 0.01", + "done", + "exit 1", + "", + ].join("\n"), + ); + chmodSync(sleepShim, 0o755); const result = runSweepShell( ` @@ -864,7 +878,7 @@ set -euo pipefail export PATH="$FAKE_BIN:$PATH" export KITCHEN_SINK_SWEEP_SOURCE_ONLY=1 export KITCHEN_SINK_TMP_DIR="$SCRATCH_ROOT" -export OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS=25 +export OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS=1 export OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES=64 source scripts/e2e/lib/kitchen-sink-plugin/sweep.sh set +e diff --git a/test/scripts/kitchen-sink-rpc-walk.test.ts b/test/scripts/kitchen-sink-rpc-walk.test.ts index 8ce32b5170b6..ec311e67eee8 100644 --- a/test/scripts/kitchen-sink-rpc-walk.test.ts +++ b/test/scripts/kitchen-sink-rpc-walk.test.ts @@ -70,6 +70,7 @@ import { resolveWindowsSystem32Path, resolveWindowsTaskkillPath, } from "../../scripts/lib/windows-taskkill.mjs"; +import { formatGatewayClientRequestErrorJson } from "../../src/gateway/call.js"; import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; const posixIt = process.platform === "win32" ? it.skip : it; @@ -177,9 +178,7 @@ describe("kitchen-sink RPC isolated state", () => { it("clamps timer env values before they reach Node timers", () => { const oversizedTimerMs = String(Number.MAX_SAFE_INTEGER); - expect(readPositiveTimerMs(oversizedTimerMs, 60_000)).toBe( - MAX_KITCHEN_SINK_TIMER_TIMEOUT_MS, - ); + expect(readPositiveTimerMs(oversizedTimerMs, 60_000)).toBe(MAX_KITCHEN_SINK_TIMER_TIMEOUT_MS); const config = resolveKitchenSinkRpcConfig({ OPENCLAW_KITCHEN_SINK_RPC_CALL_MS: oversizedTimerMs, @@ -764,11 +763,15 @@ setInterval(() => {}, 1000); "utf8", ); - const runPromise = runCommand(process.execPath, [scriptPath, grandchildPidPath, grandchildReadyPath], { - detached: undefined, - timeoutKillGraceMs: 25, - timeoutMs: 500, - }); + const runPromise = runCommand( + process.execPath, + [scriptPath, grandchildPidPath, grandchildReadyPath], + { + detached: undefined, + timeoutKillGraceMs: 25, + timeoutMs: 500, + }, + ); const runErrorPromise = runPromise.then( () => { throw new Error("expected timed command to reject"); @@ -1057,10 +1060,14 @@ setInterval(() => {}, 1000); "utf8", ); - const runPromise = runCommand(process.execPath, [scriptPath, grandchildPidPath, grandchildReadyPath], { - timeoutKillGraceMs: 2_000, - timeoutMs: 1_500, - }); + const runPromise = runCommand( + process.execPath, + [scriptPath, grandchildPidPath, grandchildReadyPath], + { + timeoutKillGraceMs: 100, + timeoutMs: 100, + }, + ); const runErrorPromise = runPromise.then( () => { throw new Error("expected timed command to reject"); @@ -1077,7 +1084,7 @@ setInterval(() => {}, 1000); const runError = await runErrorPromise; expect(runError).toBeInstanceOf(Error); - expect((runError as Error).message).toContain("timed out after 1500ms"); + expect((runError as Error).message).toContain("timed out after 100ms"); await waitFor(() => !isProcessAlive(grandchildPid), 5_000); } finally { await runPromise.catch(() => {}); @@ -1138,6 +1145,10 @@ await runCommand(process.execPath, [${JSON.stringify(scriptPath)}], { try { runner = spawn(process.execPath, [runnerPath], { cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_TEST_KITCHEN_SINK_PARENT_SIGNAL_KILL_GRACE_MS: "100", + }, stdio: ["ignore", "ignore", "pipe"], }); await waitFor(() => existsSync(readyPath) && existsSync(grandchildPidPath)); @@ -1374,7 +1385,6 @@ describe("kitchen-sink RPC command catalog assertions", () => { }); it("reconstructs typed request failures from gateway CLI JSON", async () => { - const { formatGatewayClientRequestErrorJson } = await import("../../src/gateway/call.js"); const payload = formatGatewayClientRequestErrorJson( Object.assign(new Error("unauthorized role: operator"), { name: "GatewayClientRequestError", diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 00ed889bd82a..9ee2411208fa 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -363,20 +363,24 @@ describe("Parallels smoke model selection", () => { it("rejects short flags as Parallels smoke option values", () => { const cases = [ - [TS_PATHS.linux, "--mode", "-h"], - [TS_PATHS.macos, "--vm", "-h"], - [TS_PATHS.windows, "--model", "-h"], - [TS_PATHS.npmUpdate, "--target-tarball", "-h"], - ]; + [parseLinuxSmokeArgs, "--mode", "-h"], + [parseMacosSmokeArgs, "--vm", "-h"], + [parseWindowsSmokeArgs, "--model", "-h"], + [parseNpmUpdateSmokeArgs, "--target-tarball", "-h"], + ] as const; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const exit = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); - for (const [scriptPath, flag, value] of cases) { - const result = spawnNodeEvalSync( - `process.argv = ["node", "${scriptPath}", "${flag}", "${value}"]; await import("./${scriptPath}");`, - { env: process.env, imports: ["tsx"] }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain(`error: ${flag} requires a value`); + try { + for (const [parseArgs, flag, value] of cases) { + expect(() => parseArgs([flag, value])).toThrow("process.exit(1)"); + expect(stderr).toHaveBeenLastCalledWith(`error: ${flag} requires a value\n`); + } + } finally { + exit.mockRestore(); + stderr.mockRestore(); } }); @@ -1567,7 +1571,7 @@ if (isPrlctl) { READY_FILE: readyFile, }, quiet: true, - timeoutMs: 1_000, + timeoutMs: 250, }); expect(result.status).toBe(124); diff --git a/test/scripts/plugin-gateway-gauntlet.test.ts b/test/scripts/plugin-gateway-gauntlet.test.ts index 1396da81dcae..b9f7e2920c54 100644 --- a/test/scripts/plugin-gateway-gauntlet.test.ts +++ b/test/scripts/plugin-gateway-gauntlet.test.ts @@ -145,9 +145,7 @@ describe("plugin gateway gauntlet helpers", () => { ).toThrow("Duplicate --qa-scenario value: channel-chat-baseline"); vi.stubEnv("OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_IDS", "telegram,discord"); - expect(() => parseArgs(["--plugin", "telegram"])).toThrow( - "Duplicate --plugin value: telegram", - ); + expect(() => parseArgs(["--plugin", "telegram"])).toThrow("Duplicate --plugin value: telegram"); }); it("rejects duplicate single-value controls", () => { @@ -663,7 +661,7 @@ setInterval(() => {}, 1000); label: "timeout-leader-exits", phase: "probe", timeoutKillGraceMs: 25, - timeoutMs: 1_000, + timeoutMs: 250, timeMode: "none", }); @@ -952,8 +950,8 @@ const promise = runMeasuredCommandLive({ )}, ${JSON.stringify(leaderExitedPath)}], label: "timeout-parent-termination", phase: "probe", - timeoutKillGraceMs: 1_000, - timeoutMs: 1_000, + timeoutKillGraceMs: 250, + timeoutMs: 200, timeMode: "none", }); for (let attempt = 0; attempt < 200 && !fs.existsSync(${JSON.stringify( @@ -964,7 +962,7 @@ for (let attempt = 0; attempt < 200 && !fs.existsSync(${JSON.stringify( if (!fs.existsSync(${JSON.stringify(leaderExitedPath)})) { process.exit(2); } -await delay(100); +await delay(50); process.kill(process.pid, "SIGTERM"); await promise; process.exit(7); @@ -1369,8 +1367,17 @@ process.exit(7); expect(result.stdout).toContain("failures=0"); }); - it("probes plugin-owned slash help while the plugin is installed", async () => { - const outputDir = path.join(repoRoot, "artifacts"); + it.each([ + ["probes plugin-owned slash help while the plugin is installed", "default", [], 0], + ["skips plugin-owned slash help when requested", "skip", ["--skip-slash-help"], 0], + [ + "rejects slash-only probes without the install lifecycle", + "slash-only", + ["--skip-lifecycle"], + 1, + ], + ] as const)("%s", async (_title, mode, extraArgs, expectedStatus) => { + const outputDir = path.join(repoRoot, `artifacts-${mode}`); await writeManifest( "workboard", "openclaw.plugin.json", @@ -1424,6 +1431,7 @@ process.exit(7); outputDir, "--skip-prebuild", "--skip-qa", + ...extraArgs, "--plugin", "workboard", ], @@ -1433,95 +1441,49 @@ process.exit(7); }, ); - expect(result.status, result.stderr).toBe(0); + expect(result.status, result.stderr).toBe(expectedStatus); const summary = JSON.parse( await fs.readFile(path.join(outputDir, "plugin-gateway-gauntlet-summary.json"), "utf8"), ); - expect(summary.failures).toEqual([]); - const slashHelpRow = summary.rows.find( - (row: { label?: string; logPath?: string }) => row.label === "workboard-slash-help:workboard", - ); - expect(summary.rows).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - label: "workboard-slash-help:workboard", - phase: "slash:help", - pluginId: "workboard", - status: 0, - }), - ]), - ); - const slashHelpLogPath = slashHelpRow?.logPath; - expect(slashHelpLogPath).toEqual(expect.any(String)); - await expect(fs.readFile(slashHelpLogPath as string, "utf8")).resolves.toContain( - "Usage: openclaw workboard", - ); + if (mode === "default") { + expect(summary.failures).toEqual([]); + const slashHelpRow = summary.rows.find( + (row: { label?: string; logPath?: string }) => + row.label === "workboard-slash-help:workboard", + ); + expect(summary.rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "workboard-slash-help:workboard", + phase: "slash:help", + pluginId: "workboard", + status: 0, + }), + ]), + ); + const slashHelpLogPath = slashHelpRow?.logPath; + expect(slashHelpLogPath).toEqual(expect.any(String)); + await expect(fs.readFile(slashHelpLogPath as string, "utf8")).resolves.toContain( + "Usage: openclaw workboard", + ); + return; + } - const skipOutputDir = path.join(repoRoot, "artifacts-skip"); - const skipResult = spawnSync( - process.execPath, - [ - path.resolve("scripts/check-plugin-gateway-gauntlet.mjs"), - "--repo-root", - repoRoot, - "--output-dir", - skipOutputDir, - "--skip-prebuild", - "--skip-qa", - "--skip-slash-help", - "--plugin", - "workboard", - ], - { - cwd: path.resolve("."), - encoding: "utf8", - }, - ); + if (mode === "skip") { + expect(summary.failures).toEqual([]); + expect(summary.rows).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + phase: "slash:help", + pluginId: "workboard", + }), + ]), + ); + return; + } - expect(skipResult.status, skipResult.stderr).toBe(0); - const skipSummary = JSON.parse( - await fs.readFile(path.join(skipOutputDir, "plugin-gateway-gauntlet-summary.json"), "utf8"), - ); - expect(skipSummary.failures).toEqual([]); - expect(skipSummary.rows).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "slash:help", - pluginId: "workboard", - }), - ]), - ); - - const slashOnlyOutputDir = path.join(repoRoot, "artifacts-slash-only"); - const slashOnlyResult = spawnSync( - process.execPath, - [ - path.resolve("scripts/check-plugin-gateway-gauntlet.mjs"), - "--repo-root", - repoRoot, - "--output-dir", - slashOnlyOutputDir, - "--skip-prebuild", - "--skip-lifecycle", - "--skip-qa", - "--plugin", - "workboard", - ], - { - cwd: path.resolve("."), - encoding: "utf8", - }, - ); - - expect(slashOnlyResult.status, slashOnlyResult.stderr).toBe(1); - const slashOnlySummary = JSON.parse( - await fs.readFile( - path.join(slashOnlyOutputDir, "plugin-gateway-gauntlet-summary.json"), - "utf8", - ), - ); - expect(slashOnlySummary.guardFailures).toEqual([]); - expect(slashOnlySummary.failures).toEqual([ + expect(summary.guardFailures).toEqual([]); + expect(summary.failures).toEqual([ expect.objectContaining({ label: "workboard-slash-workboard", phase: "slash:help", diff --git a/test/scripts/plugin-lifecycle-measure.test.ts b/test/scripts/plugin-lifecycle-measure.test.ts index 7ed3361ae968..f29f58d62d74 100644 --- a/test/scripts/plugin-lifecycle-measure.test.ts +++ b/test/scripts/plugin-lifecycle-measure.test.ts @@ -314,8 +314,8 @@ describe("plugin lifecycle resource sampler", () => { encoding: "utf8", env: { ...process.env, - OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS: "1000", - OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS: "200", + OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS: "250", + OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS: "50", PID_FILE: pidFile, }, timeout: 5000, diff --git a/test/scripts/plugin-sdk-surface-report.test.ts b/test/scripts/plugin-sdk-surface-report.test.ts index b2cb4b75e66a..ba7ef32ed77b 100644 --- a/test/scripts/plugin-sdk-surface-report.test.ts +++ b/test/scripts/plugin-sdk-surface-report.test.ts @@ -1,7 +1,11 @@ // Plugin Sdk Surface Report tests cover plugin sdk surface report script behavior. import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + collectPluginSdkSurfaceReport, + evaluatePluginSdkSurfaceReport, + readPluginSdkSurfaceBudgets, +} from "../../scripts/plugin-sdk-surface-report.mjs"; const pluginSdkSurfaceBudgetEnvPattern = /^OPENCLAW_PLUGIN_SDK_MAX_/u; @@ -29,50 +33,30 @@ type PublicSurfaceCounts = { }; function readDefaultPublicSurfaceBudgets(): PublicSurfaceCounts { - const source = readFileSync("scripts/plugin-sdk-surface-report.mjs", "utf8"); - const readFallback = (budgetKey: string) => { - const match = new RegExp(`${budgetKey}:\\s*readBudgetEnv\\(\\s*"[^"]+",\\s*(\\d+)`, "u").exec( - source, - ); - if (match === null || match[1] === undefined) { - throw new Error(`failed to read default ${budgetKey} budget`); - } - return Number(match[1]); - }; + const { budgets } = readPluginSdkSurfaceBudgets({}); return { - exports: readFallback("publicExports"), - callableExports: readFallback("publicFunctionExports"), - wildcardReexports: readFallback("publicWildcardReexports"), + exports: budgets.publicExports, + callableExports: budgets.publicFunctionExports, + wildcardReexports: budgets.publicWildcardReexports, }; } -function readCurrentPublicSurfaceCounts(): PublicSurfaceCounts { - const result = runSurfaceReport({}); - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); +type SurfaceReport = ReturnType; +let surfaceReport: SurfaceReport; - const totalsMatch = - /public package SDK entrypoints:[\s\S]*?\n exports: (\d+)\n callable exports: (\d+)/u.exec( - result.stdout, - ); - const wildcardsMatch = /public wildcard reexports: (\d+)/u.exec(result.stdout); - if ( - totalsMatch === null || - totalsMatch[1] === undefined || - totalsMatch[2] === undefined || - wildcardsMatch === null || - wildcardsMatch[1] === undefined - ) { - throw new Error("failed to read current public surface counts"); - } +function readCurrentPublicSurfaceCounts(): PublicSurfaceCounts { return { - exports: Number(totalsMatch[1]), - callableExports: Number(totalsMatch[2]), - wildcardReexports: Number(wildcardsMatch[1]), + exports: surfaceReport.publicStats.totals.exports, + callableExports: surfaceReport.publicStats.totals.callableExports, + wildcardReexports: surfaceReport.publicWildcards.count, }; } describe("plugin SDK surface report", () => { + beforeAll(() => { + surfaceReport = collectPluginSdkSurfaceReport(); + }); + it("rejects unknown CLI options before collecting SDK stats", () => { const result = spawnSync( process.execPath, @@ -132,26 +116,35 @@ describe("plugin SDK surface report", () => { }); it("accepts exact deprecated export budget overrides by public entrypoint", () => { - const result = runSurfaceReport({ + const budgetConfig = readPluginSdkSurfaceBudgets({ OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS_BY_ENTRYPOINT: JSON.stringify({ core: 2 }), }); - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); + expect(evaluatePluginSdkSurfaceReport(surfaceReport, budgetConfig)).not.toContain( + expect.stringContaining("public deprecated exports in core"), + ); }); it("keeps default public surface budgets pinned to current source counts", () => { expect(readDefaultPublicSurfaceBudgets()).toEqual(readCurrentPublicSurfaceCounts()); }); - it("ignores ambient CI budget overrides when checking default source counts", () => { + it("keeps generated package declarations out of source surface counts", () => { + const budget = readDefaultPublicSurfaceBudgets().callableExports; + const budgetConfig = readPluginSdkSurfaceBudgets({ + OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS: String(budget - 1), + }); + + expect(evaluatePluginSdkSurfaceReport(surfaceReport, budgetConfig)).toContain( + `public callable exports ${budget} > ${budget - 1}`, + ); + }); + + it("strips ambient CI budget overrides from CLI checks", () => { const original = process.env.OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS; process.env.OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS = "1"; try { - const result = runSurfaceReport({}); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); + expect(baseSurfaceReportEnv()).not.toHaveProperty("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS"); } finally { if (original === undefined) { delete process.env.OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS; @@ -161,22 +154,13 @@ describe("plugin SDK surface report", () => { } }); - it("keeps generated package declarations out of source surface counts", () => { - const budget = readDefaultPublicSurfaceBudgets().callableExports; - const result = runSurfaceReport({ - OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS: String(budget - 1), - }); - - expect(result.status).toBe(1); - expect(result.stderr).toContain(`public callable exports ${budget} > ${budget - 1}`); - }); - it("rejects deprecated export growth by public entrypoint", () => { - const result = runSurfaceReport({ + const budgetConfig = readPluginSdkSurfaceBudgets({ OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS_BY_ENTRYPOINT: JSON.stringify({ core: 1 }), }); - expect(result.status).toBe(1); - expect(result.stderr).toContain("public deprecated exports in core 2 > 1"); + expect(evaluatePluginSdkSurfaceReport(surfaceReport, budgetConfig)).toContain( + "public deprecated exports in core 2 > 1", + ); }); }); diff --git a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts index 012f0a120f6c..0eeb1b784639 100644 --- a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts +++ b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts @@ -246,6 +246,7 @@ describe("prepare-extension-package-boundary-artifacts", () => { { label: "abort-group-prep", args: ["--eval", parentScript], + abortKillGraceMs: 100, timeoutMs: 60_000, }, ]); @@ -305,6 +306,7 @@ describe("prepare-extension-package-boundary-artifacts", () => { { label: "abort-group-drain", args: ["--eval", parentScript], + abortKillGraceMs: 100, timeoutMs: 60_000, }, ]); @@ -413,7 +415,7 @@ describe("prepare-extension-package-boundary-artifacts", () => { ].join("\n"); const runnerScript = [ `import { runNodeStep } from ${JSON.stringify(moduleHref)};`, - `await runNodeStep("signal-group-prep", ["--eval", ${JSON.stringify(parentScript)}], 60_000);`, + `await runNodeStep("signal-group-prep", ["--eval", ${JSON.stringify(parentScript)}], 60_000, { abortKillGraceMs: 100 });`, ].join("\n"); const runner = spawn(process.execPath, ["--input-type=module", "--eval", runnerScript], { stdio: "ignore", diff --git a/test/scripts/profile-extension-memory.test.ts b/test/scripts/profile-extension-memory.test.ts index 1a66203fa86e..4aef096716ad 100644 --- a/test/scripts/profile-extension-memory.test.ts +++ b/test/scripts/profile-extension-memory.test.ts @@ -312,7 +312,8 @@ describe("scripts/profile-extension-memory", () => { hookPath, name: "timeout-descendant", repoRoot: root, - timeoutMs: 1_000, + shutdownGraceMs: 100, + timeoutMs: 250, }); await waitForCondition(() => existsSync(descendantPidPath)); @@ -371,6 +372,7 @@ describe("scripts/profile-extension-memory", () => { ` hookPath: ${JSON.stringify(hookPath)},`, " name: 'parent-signal-descendant',", ` repoRoot: ${JSON.stringify(root)},`, + " shutdownGraceMs: 100,", " timeoutMs: 30000,", "});", ].join("\n"), diff --git a/test/scripts/qa-ux-matrix-evidence-producer.test.ts b/test/scripts/qa-ux-matrix-evidence-producer.test.ts index 2946f3196264..ffbedfc17e5b 100644 --- a/test/scripts/qa-ux-matrix-evidence-producer.test.ts +++ b/test/scripts/qa-ux-matrix-evidence-producer.test.ts @@ -1,5 +1,4 @@ // QA UX Matrix evidence producer tests cover operator-facing CLI behavior. -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -7,19 +6,21 @@ import { describe, expect, it, vi } from "vitest"; import { ensureUxMatrixVideoDependencies, launchUxMatrixChromium, + runUxMatrixEvidenceProducerCli, } from "../../scripts/qa/ux-matrix-evidence-producer.js"; -const repoRoot = path.resolve(__dirname, "../.."); - -function runCli(...args: string[]) { - return spawnSync( - process.execPath, - ["--import", "tsx", "scripts/qa/ux-matrix-evidence-producer.ts", ...args], - { - cwd: repoRoot, - encoding: "utf8", - }, - ); +async function runCli(...args: string[]) { + const stdout: string[] = []; + const stderr: string[] = []; + const status = await runUxMatrixEvidenceProducerCli(args, { + error: (message) => stderr.push(message), + log: (message) => stdout.push(message), + }); + return { + status, + stderr: stderr.length > 0 ? `${stderr.join("\n")}\n` : "", + stdout: stdout.length > 0 ? `${stdout.join("\n")}\n` : "", + }; } function expectNoNodeStack(stderr: string) { @@ -28,8 +29,8 @@ function expectNoNodeStack(stderr: string) { } describe("QA UX Matrix evidence producer CLI", () => { - it("prints help without generating evidence", () => { - const result = runCli("--help"); + it("prints help without generating evidence", async () => { + const result = await runCli("--help"); expect(result.status).toBe(0); expect(result.stdout).toContain( @@ -38,8 +39,8 @@ describe("QA UX Matrix evidence producer CLI", () => { expect(result.stderr).toBe(""); }); - it("prints help after boolean options without consuming valued option slots", () => { - const result = runCli("--skip-visual-proof", "--help"); + it("prints help after boolean options without consuming valued option slots", async () => { + const result = await runCli("--skip-visual-proof", "--help"); expect(result.status).toBe(0); expect(result.stdout).toContain( @@ -48,8 +49,8 @@ describe("QA UX Matrix evidence producer CLI", () => { expect(result.stderr).toBe(""); }); - it("reports invalid args without a Node stack trace", () => { - const result = runCli("--wat"); + it("reports invalid args without a Node stack trace", async () => { + const result = await runCli("--wat"); expect(result.status).toBe(1); expect(result.stdout).toBe(""); @@ -57,8 +58,8 @@ describe("QA UX Matrix evidence producer CLI", () => { expectNoNodeStack(result.stderr); }); - it("reports missing valued args without a Node stack trace", () => { - const result = runCli("--artifact-base", "--repo-root", "."); + it("reports missing valued args without a Node stack trace", async () => { + const result = await runCli("--artifact-base", "--repo-root", "."); expect(result.status).toBe(1); expect(result.stdout).toBe(""); @@ -66,7 +67,7 @@ describe("QA UX Matrix evidence producer CLI", () => { expectNoNodeStack(result.stderr); }); - it("reports duplicate evidence producer args without a Node stack trace", () => { + it("reports duplicate evidence producer args without a Node stack trace", async () => { const duplicateCases = [ ["--artifact-base", ["--artifact-base", ".artifacts/a", "--artifact-base", ".artifacts/b"]], ["--repo-root", ["--artifact-base", ".artifacts/a", "--repo-root", ".", "--repo-root", ".."]], @@ -77,7 +78,7 @@ describe("QA UX Matrix evidence producer CLI", () => { ] satisfies Array<[string, string[]]>; for (const [flag, args] of duplicateCases) { - const result = runCli(...args); + const result = await runCli(...args); expect(result.status).toBe(1); expect(result.stdout).toBe(""); @@ -86,9 +87,14 @@ describe("QA UX Matrix evidence producer CLI", () => { } }); - it("reports short flag values without treating them as help", () => { - const artifactBaseResult = runCli("--artifact-base", "-h"); - const repoRootResult = runCli("--artifact-base", "/tmp/openclaw-ux-test", "--repo-root", "-h"); + it("reports short flag values without treating them as help", async () => { + const artifactBaseResult = await runCli("--artifact-base", "-h"); + const repoRootResult = await runCli( + "--artifact-base", + "/tmp/openclaw-ux-test", + "--repo-root", + "-h", + ); expect(artifactBaseResult.status).toBe(1); expect(artifactBaseResult.stdout).toBe(""); @@ -100,11 +106,11 @@ describe("QA UX Matrix evidence producer CLI", () => { expectNoNodeStack(repoRootResult.stderr); }); - it("sanitizes local checkout paths from generated evidence artifacts", () => { + it("sanitizes local checkout paths from generated evidence artifacts", async () => { const artifactBase = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ux-evidence-test-")); const fakeRepoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ux-repo-test-")); try { - const result = runCli( + const result = await runCli( "--artifact-base", artifactBase, "--repo-root", diff --git a/test/scripts/release-user-journey-assertions.test.ts b/test/scripts/release-user-journey-assertions.test.ts index 4dc2811090e2..94047368fb51 100644 --- a/test/scripts/release-user-journey-assertions.test.ts +++ b/test/scripts/release-user-journey-assertions.test.ts @@ -5,7 +5,10 @@ import { createServer, type AddressInfo, type Socket } from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { runReleaseUserJourneyAssertion } from "../../scripts/e2e/lib/release-user-journey/assertions.mjs"; +import { + runReleaseUserJourneyAssertion, + waitForClickClackSocket, +} from "../../scripts/e2e/lib/release-user-journey/assertions.mjs"; import { withEnvAsync } from "../../src/test-utils/env.js"; const ASSERTIONS_SCRIPT = "scripts/e2e/lib/release-user-journey/assertions.mjs"; @@ -334,13 +337,14 @@ describe("release user journey assertions", () => { const startedAt = Date.now(); await expect( withEnvAsync({ HOME: home, OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS: "100" }, () => - runReleaseUserJourneyAssertion("wait-clickclack-socket", [ - `http://127.0.0.1:${server.port}`, - "1", - ]), + waitForClickClackSocket({ + baseUrl: `http://127.0.0.1:${server.port}`, + pollIntervalMs: 20, + timeoutMs: 150, + }), ), ).rejects.toThrow("Timed out waiting for ClickClack websocket connection"); - expect(Date.now() - startedAt).toBeLessThan(2500); + expect(Date.now() - startedAt).toBeLessThan(750); } finally { await server.stop(); rmSync(root, { force: true, recursive: true }); diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts index bb8e3a276f77..e9bec02f0de5 100644 --- a/test/scripts/resolve-openclaw-package-candidate.test.ts +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -8,7 +8,6 @@ import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs"; import { - ARTIFACT_TARBALL_SCAN_MAX_ENTRIES, assertExpectedSha256ForTest, cleanupPackageSourceWorktreeForTest, cleanPackedOpenClawTarballsForTest, @@ -565,7 +564,7 @@ describe("resolve-openclaw-package-candidate", () => { "const fs = require('node:fs');", "process.on('SIGTERM', () => {", " fs.writeFileSync(process.env.OPENCLAW_TEST_CHILD_CLEANUP, 'clean');", - " setTimeout(() => process.exit(0), 75);", + " setTimeout(() => process.exit(0), 25);", "});", "fs.writeFileSync(process.env.OPENCLAW_TEST_CHILD_READY, 'ready');", "setInterval(() => {}, 1000);", @@ -595,16 +594,16 @@ describe("resolve-openclaw-package-candidate", () => { OPENCLAW_TEST_CHILD_PID: childPidPath, OPENCLAW_TEST_CHILD_READY: readyPath, }, - killAfterMs: 1000, - timeoutMs: 1000, + killAfterMs: 250, + timeoutMs: 250, }), - ).rejects.toThrow(/timed out after 1000ms/u); + ).rejects.toThrow(/timed out after 250ms/u); await waitForFile(readyPath, 2_000); await timeoutAssertion; expect(readFileSync(cleanupPath, "utf8")).toBe("clean"); - expect(Date.now() - startedAt).toBeLessThan(1_700); + expect(Date.now() - startedAt).toBeLessThan(900); }); it("forwards external termination to package runner process groups", async () => { @@ -1353,22 +1352,14 @@ describe("resolve-openclaw-package-candidate", () => { it("rejects source artifact scans that exceed the filesystem entry limit", async () => { const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-artifact-scan-")); tempDirs.push(dir); + const maxEntries = 3; - // Keep the real 10,001-entry proof while avoiding serial filesystem setup. - const indexes = Array.from( - { length: ARTIFACT_TARBALL_SCAN_MAX_ENTRIES + 1 }, - (_, index) => index, - ); - for (let start = 0; start < indexes.length; start += 128) { - await Promise.all( - indexes - .slice(start, start + 128) - .map((index) => writeFile(path.join(dir, `not-a-package-${index}.txt`), "x")), - ); + for (let index = 0; index <= maxEntries; index += 1) { + await writeFile(path.join(dir, `not-a-package-${index}.txt`), "x"); } - await expect(findSingleTarballForTest(dir)).rejects.toThrow( - `source=artifact scan exceeded ${ARTIFACT_TARBALL_SCAN_MAX_ENTRIES} filesystem entries`, + await expect(findSingleTarballForTest(dir, maxEntries)).rejects.toThrow( + `source=artifact scan exceeded ${maxEntries} filesystem entries`, ); }); diff --git a/test/scripts/run-oxlint.test.ts b/test/scripts/run-oxlint.test.ts index 59eda915585f..9c7859f9157f 100644 --- a/test/scripts/run-oxlint.test.ts +++ b/test/scripts/run-oxlint.test.ts @@ -310,7 +310,7 @@ describe("run-oxlint", () => { CHILD_PID_PATH: childPidPath, OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS: "0", OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS: "25", - OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: "1000", + OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: "250", }, extraArgs: [], runner, diff --git a/test/scripts/run-vitest.test.ts b/test/scripts/run-vitest.test.ts index ca8a995f2b10..9e012dd14e93 100644 --- a/test/scripts/run-vitest.test.ts +++ b/test/scripts/run-vitest.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import nodePath from "node:path"; import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS, @@ -611,9 +612,8 @@ describe("scripts/run-vitest", () => { posixIt("cleans delegated test-project children when the wrapper is signaled", async () => { const fixturePath = nodePath.join( - "test", - "scripts", - `run-vitest-delegated-signal-${process.pid}-${Date.now()}.test.ts`, + os.tmpdir(), + `openclaw-run-vitest-delegated-signal-${process.pid}-${Date.now()}.mjs`, ); const childPidPath = nodePath.join( os.tmpdir(), @@ -629,20 +629,23 @@ describe("scripts/run-vitest", () => { [ 'import { spawn } from "node:child_process";', 'import fs from "node:fs";', - 'import { it } from "vitest";', - 'it("waits for wrapper termination", async () => {', - ' const child = spawn(process.execPath, ["-e", "process.on(\\\'SIGTERM\\\', () => {}); setInterval(() => {}, 1000);"], { stdio: "ignore" });', - " fs.writeFileSync(process.env.OPENCLAW_DELEGATED_SIGNAL_CHILD_PID!, String(process.pid));", - " fs.writeFileSync(process.env.OPENCLAW_DELEGATED_SIGNAL_DESCENDANT_PID!, String(child.pid));", - " await new Promise(() => {});", - "});", + 'const child = spawn(process.execPath, ["-e", "process.on(\\\'SIGTERM\\\', () => {}); setInterval(() => {}, 1000);"], { stdio: "ignore" });', + "fs.writeFileSync(process.env.OPENCLAW_DELEGATED_SIGNAL_CHILD_PID, String(process.pid));", + "fs.writeFileSync(process.env.OPENCLAW_DELEGATED_SIGNAL_DESCENDANT_PID, String(child.pid));", + "await new Promise(() => {});", "", ].join("\n"), ); const runner = spawn( process.execPath, - ["scripts/run-vitest.mjs", fixturePath, "--reporter=verbose"], + [ + "--input-type=module", + "--eval", + `import { runTestProjectsDelegation } from ${JSON.stringify( + pathToFileURL(nodePath.resolve("scripts/run-vitest.mjs")).href, + )}; runTestProjectsDelegation([], process.env, { runnerPath: ${JSON.stringify(fixturePath)} });`, + ], { env: { ...process.env, diff --git a/test/scripts/secret-provider-integrations.test.ts b/test/scripts/secret-provider-integrations.test.ts index a3bf32bed0e0..4902ea70c6c1 100644 --- a/test/scripts/secret-provider-integrations.test.ts +++ b/test/scripts/secret-provider-integrations.test.ts @@ -494,7 +494,7 @@ describe("secret provider integration proof harness", () => { OPENCLAW_ENTRY: fakeOpenClaw, }, }, - { timeoutKillGraceMs: 50, timeoutMs: 2_000 }, + { timeoutKillGraceMs: 50, timeoutMs: 500 }, ); result.catch(() => {}); await waitFor(() => fs.existsSync(readyPath)); @@ -756,6 +756,7 @@ describe("secret provider integration proof harness", () => { await expect( proof.runCommand(process.execPath, [scriptPath], { + timeoutKillGraceMs: 50, timeoutMs: 150, }), ).rejects.toThrow(/command timed out/u); @@ -935,7 +936,7 @@ describe("secret provider integration proof harness", () => { `const proof = await import(${JSON.stringify( `${pathToFileURL(proofScriptPath).href}?case=parent-signal-${Date.now()}`, )});`, - `await proof.runCommand(process.execPath, [${JSON.stringify(scriptPath)}], { timeoutMs: 30_000 });`, + `await proof.runCommand(process.execPath, [${JSON.stringify(scriptPath)}], { timeoutKillGraceMs: 50, timeoutMs: 30_000 });`, "", ].join("\n"), ); diff --git a/test/scripts/telegram-user-crabbox-proof.test.ts b/test/scripts/telegram-user-crabbox-proof.test.ts index e1ef84c71473..efc6b547cc50 100644 --- a/test/scripts/telegram-user-crabbox-proof.test.ts +++ b/test/scripts/telegram-user-crabbox-proof.test.ts @@ -131,18 +131,22 @@ describe("telegram user Crabbox proof log polling", () => { ).toBe(4096); }); - it("rejects loose and out-of-range proof ports before remote setup", () => { - const looseGatewayPort = runProofCli(["--gateway-port", "1e3", "--dry-run"]); - expect(looseGatewayPort.status).toBe(1); - expect(looseGatewayPort.stderr).toContain("--gateway-port must be a positive integer."); - - const highGatewayPort = runProofCli(["--gateway-port", "65536", "--dry-run"]); - expect(highGatewayPort.status).toBe(1); - expect(highGatewayPort.stderr).toContain("--gateway-port must be a TCP port from 1 to 65535."); - - const highMockPort = runProofCli(["--mock-port", "65536", "--dry-run"]); - expect(highMockPort.status).toBe(1); - expect(highMockPort.stderr).toContain("--mock-port must be a TCP port from 1 to 65535."); + it.each([ + ["loose gateway", "--gateway-port", "1e3", "--gateway-port must be a positive integer."], + [ + "out-of-range gateway", + "--gateway-port", + "65536", + "--gateway-port must be a TCP port from 1 to 65535.", + ], + [ + "out-of-range mock", + "--mock-port", + "65536", + "--mock-port must be a TCP port from 1 to 65535.", + ], + ])("rejects %s proof ports before remote setup", (_label, flag, value, message) => { + expect(() => parseArgs([flag, value, "--dry-run"])).toThrow(message); }); it("rejects short flags as proof option values before dry-run planning", () => { @@ -846,7 +850,7 @@ fs.writeFileSync(${JSON.stringify(recorderExitPath)}, "exited"); startDelayMs: 0, target: "linux", }), - delay(2_000).then(() => { + delay(500).then(() => { throw new Error("recordProbeVideo hung after the recorder had already exited"); }), ]), diff --git a/test/scripts/telegram-user-credential.test.ts b/test/scripts/telegram-user-credential.test.ts index 7340b381fbc8..297cae2bcd82 100644 --- a/test/scripts/telegram-user-credential.test.ts +++ b/test/scripts/telegram-user-credential.test.ts @@ -373,8 +373,8 @@ setInterval(() => {}, 1000); const startedAt = Date.now(); const runPromise = runCommand(process.execPath, ["-e", parentScript], dir, { - timeoutKillGraceMs: 1_000, - timeoutMs: 1_000, + timeoutKillGraceMs: 250, + timeoutMs: 300, }); const runError = runPromise.catch((error: unknown) => error); await waitForFile(readyPath, 2_000); @@ -382,11 +382,11 @@ setInterval(() => {}, 1000); await expect(runError).resolves.toMatchObject({ code: "ETIMEDOUT", - message: expect.stringContaining("timed out after 1000ms"), + message: expect.stringContaining("timed out after 300ms"), }); expect(readFileSync(cleanupPath, "utf8")).toBe("clean"); - expect(Date.now() - startedAt).toBeLessThan(1_700); + expect(Date.now() - startedAt).toBeLessThan(800); } finally { if (childPid !== undefined && isProcessAlive(childPid)) { process.kill(childPid, "SIGKILL"); diff --git a/test/scripts/test-group-report.test.ts b/test/scripts/test-group-report.test.ts index a8933d2f8c36..7974be696680 100644 --- a/test/scripts/test-group-report.test.ts +++ b/test/scripts/test-group-report.test.ts @@ -18,8 +18,10 @@ import { resolveFullSuiteVitestEnv, resolveReportArtifactDirs, resolveReportRunSpecs, + resolveReportVitestArgs, resolveRunPlanConcurrency, resolveRunPlans, + runReportPlans, signalTestGroupReportChild, spawnText, } from "../../scripts/test-group-report.mjs"; @@ -249,7 +251,7 @@ describe("scripts/test-group-report aggregation", () => { } }); - it("fails allow-failures runs that produce no JSON report", () => { + it("fails when every allow-failures run produces no JSON report", () => { const tempDir = makeTempDir(); const missingConfig = path.join(tempDir, "missing-vitest.config.ts"); const output = path.join(tempDir, "group-report.json"); @@ -280,6 +282,125 @@ describe("scripts/test-group-report aggregation", () => { fs.rmSync(tempDir, { recursive: true, force: true }); } }); + + it("continues allow-failures profiling after a config exits without JSON", async () => { + const tempDir = makeTempDir(); + const reportDir = path.join(tempDir, "reports"); + const calls: string[] = []; + try { + const result = await runReportPlans({ + args: parseTestGroupReportArgs([ + "--config", + "failed.config.ts", + "--config", + "passed.config.ts", + "--allow-failures", + "--no-rss", + ]), + logDir: path.join(tempDir, "logs"), + reportDir, + runPlans: [ + { config: "failed.config.ts", forwardedArgs: [], label: "failed" }, + { config: "passed.config.ts", forwardedArgs: [], label: "passed" }, + ], + runVitestJsonReport: async (params: { + config: string; + label: string; + logPath: string; + reportPath: string; + }) => { + calls.push(params.label); + if (params.label === "passed") { + fs.mkdirSync(path.dirname(params.reportPath), { recursive: true }); + fs.writeFileSync( + params.reportPath, + `${JSON.stringify({ testResults: [{ name: "passed.test.ts" }] })}\n`, + "utf8", + ); + } + return { + config: params.config, + elapsedMs: 10, + label: params.label, + logPath: params.logPath, + maxRssBytes: null, + reportPath: params.reportPath, + status: params.label === "failed" ? 1 : 0, + }; + }, + }); + + expect(calls).toStrictEqual(["failed", "passed"]); + expect(result.failed).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.runs.map((run) => [run.label, run.status])).toStrictEqual([ + ["failed", 1], + ["passed", 0], + ]); + expect(result.runEntries.map((entry) => entry.config)).toStrictEqual(["passed"]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("prints slow tests as soon as each config report completes", async () => { + const tempDir = makeTempDir(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await runReportPlans({ + args: parseTestGroupReportArgs([ + "--config", + "slow.config.ts", + "--max-test-ms", + "1000", + "--no-rss", + ]), + logDir: path.join(tempDir, "logs"), + reportDir: path.join(tempDir, "reports"), + runPlans: [{ config: "slow.config.ts", forwardedArgs: [], label: "slow" }], + runVitestJsonReport: async (params: { + config: string; + label: string; + logPath: string; + reportPath: string; + }) => { + fs.mkdirSync(path.dirname(params.reportPath), { recursive: true }); + fs.writeFileSync( + params.reportPath, + `${JSON.stringify({ + testResults: [ + { + name: path.join(process.cwd(), "src", "slow.test.ts"), + assertionResults: [ + { duration: 1250, fullName: "finishes eventually", status: "passed" }, + ], + }, + ], + })}\n`, + "utf8", + ); + return { + config: params.config, + elapsedMs: 10, + label: params.label, + logPath: params.logPath, + maxRssBytes: null, + reportPath: params.reportPath, + status: 0, + }; + }, + }); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + "slow-test config=slow duration=1250.0ms file=src/slow.test.ts name=finishes eventually", + ), + ); + } finally { + logSpy.mockRestore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); describe("scripts/test-group-report comparison", () => { @@ -627,9 +748,10 @@ describe("scripts/test-group-report arg parsing", () => { ["--kill-grace-ms", ["100", "200"]], ["--concurrency", ["2", "3"]], ]) { - const args = flag === "--compare" - ? [flag, values[0], values[1], flag, values[2], values[3]] - : [flag, values[0], flag, values[1]]; + const args = + flag === "--compare" + ? [flag, values[0], values[1], flag, values[2], values[3]] + : [flag, values[0], flag, values[1]]; expect(() => parseTestGroupReportArgs(args)).toThrow(`${flag} was provided more than once`); } expect(parseTestGroupReportArgs(["--config", "a.ts", "--config", "b.ts"]).configs).toEqual([ @@ -920,7 +1042,7 @@ describe("scripts/test-group-report child process guard", () => { " setTimeout(() => {", ` fs.writeFileSync(${JSON.stringify(cleanupPath)}, "clean");`, " process.exit(0);", - " }, 75);", + " }, 25);", "});", `fs.writeFileSync(${JSON.stringify(readyPath)}, "ready");`, "setInterval(() => {}, 1000);", @@ -936,8 +1058,8 @@ describe("scripts/test-group-report child process guard", () => { const runPromise = spawnText(process.execPath, ["--eval", parentScript], { cwd: process.cwd(), env: process.env, - killGraceMs: 1_000, - timeoutMs: 1_000, + killGraceMs: 250, + timeoutMs: 250, }); await waitForFile(readyPath, 2_000); @@ -950,7 +1072,7 @@ describe("scripts/test-group-report child process guard", () => { timedOut: true, }); expect(fs.readFileSync(cleanupPath, "utf8")).toBe("clean"); - expect(Date.now() - startedAt).toBeLessThan(1_700); + expect(Date.now() - startedAt).toBeLessThan(900); await waitForDead(childPid, 2_000); } finally { if (childPid !== undefined && isProcessAlive(childPid)) { @@ -1057,6 +1179,25 @@ describe("scripts/test-group-report child process guard", () => { }); describe("scripts/test-group-report run plans", () => { + it("isolates full-suite duration reports by default", () => { + expect(resolveReportVitestArgs(parseTestGroupReportArgs(["--full-suite"]))).toEqual([ + "--isolate=true", + ]); + expect( + resolveReportVitestArgs(parseTestGroupReportArgs(["--full-suite", "--", "--maxWorkers=1"])), + ).toEqual(["--maxWorkers=1", "--isolate=true"]); + }); + + it("preserves explicit full-suite isolation choices and explicit-config defaults", () => { + expect( + resolveReportVitestArgs(parseTestGroupReportArgs(["--full-suite", "--", "--no-isolate"])), + ).toEqual(["--no-isolate"]); + expect( + resolveReportVitestArgs(parseTestGroupReportArgs(["--full-suite", "--", "--isolate=false"])), + ).toEqual(["--isolate=false"]); + expect(resolveReportVitestArgs(parseTestGroupReportArgs(["--config", "a.ts"]))).toEqual([]); + }); + it("caps Vitest workers for full-suite profiling by default", () => { expect(resolveFullSuiteVitestEnv(parseTestGroupReportArgs(["--full-suite"]), {})).toEqual({ OPENCLAW_VITEST_MAX_WORKERS: "2", @@ -1113,6 +1254,7 @@ describe("scripts/test-group-report run plans", () => { path.join("/repo", "node_modules", ".experimental-vitest-cache", "0-a.ts"), path.join("/repo", "node_modules", ".experimental-vitest-cache", "1-b.ts"), ]); + expect(specs.map((spec) => spec.vitestArgs)).toEqual([[], []]); }); it("uses leaf configs for full-suite profiling without requiring parallel env", () => { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index d0648464aa73..6088371ce107 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1163,7 +1163,7 @@ describe("scripts/test-projects changed-target routing", () => { ]) { expect(resolveChangedTestTargetPlan([fixturePath]), fixturePath).toEqual({ mode: "targets", - targets: ["test/scripts/docs-i18n-behavior.test.ts"], + targets: ["test/scripts/docs-i18n.test.ts"], }); } }); diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index dccb5338593b..fb3b72c953fa 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -796,7 +796,7 @@ describe("runTsdownBuildInvocation", () => { async () => { const rootDir = createTempDir("openclaw-tsdown-timeout-"); const childPidPath = path.join(rootDir, "child.pid"); - const timeoutMs = 1_000; + const timeoutMs = 250; let childPid: number | undefined; const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; const parentScript = [ @@ -860,7 +860,7 @@ describe("runTsdownBuildInvocation", () => { " setTimeout(() => {", ` fs.writeFileSync(${JSON.stringify(cleanupPath)}, 'clean');`, " process.exit(0);", - " }, 75);", + " }, 50);", "});", `fs.writeFileSync(${JSON.stringify(readyPath)}, 'ready');`, "setInterval(() => {}, 1000);", @@ -892,7 +892,7 @@ describe("runTsdownBuildInvocation", () => { env: { ...process.env, OPENCLAW_TSDOWN_HEARTBEAT_MS: "0", - OPENCLAW_TSDOWN_TIMEOUT_MS: "1000", + OPENCLAW_TSDOWN_TIMEOUT_MS: "250", }, }, ); @@ -903,7 +903,7 @@ describe("runTsdownBuildInvocation", () => { expect(result.timedOut).toBe(true); expect(fs.readFileSync(cleanupPath, "utf8")).toBe("clean"); - expect(Date.now() - startedAt).toBeLessThan(1_700); + expect(Date.now() - startedAt).toBeLessThan(900); await waitForDead(childPid, 2_000); } finally { if (childPid && isProcessAlive(childPid)) { diff --git a/test/scripts/ui.test.ts b/test/scripts/ui.test.ts index 6c438f715dd3..e3517f25266a 100644 --- a/test/scripts/ui.test.ts +++ b/test/scripts/ui.test.ts @@ -296,7 +296,7 @@ describe("scripts/ui windows spawn behavior", () => { await waitFor(() => fs.existsSync(descendantPidFile), "UI runner descendant readiness"); descendantPid = Number(fs.readFileSync(descendantPidFile, "utf8")); await new Promise((resolve) => { - setTimeout(resolve, 300); + setTimeout(resolve, 25); }); wrapper.kill("SIGTERM"); diff --git a/test/scripts/upgrade-survivor-probe-gateway.test.ts b/test/scripts/upgrade-survivor-probe-gateway.test.ts index d10bc75f74b8..fed99f0af838 100644 --- a/test/scripts/upgrade-survivor-probe-gateway.test.ts +++ b/test/scripts/upgrade-survivor-probe-gateway.test.ts @@ -315,7 +315,9 @@ describe("scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs", () => { "--out", out, "--timeout-ms", - "1000", + "200", + "--attempt-timeout-ms", + "100", ], 5_000, { OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES: "64" }, @@ -325,7 +327,7 @@ describe("scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs", () => { expect(result.status).not.toBe(0); expect(result.stderr).toContain(`${baseUrl}/healthz probe body exceeded 64 bytes`); expect(fs.existsSync(out)).toBe(false); - expect(Date.now() - startedAt).toBeLessThan(3_500); + expect(Date.now() - startedAt).toBeLessThan(750); } finally { server.close(); } diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 8716d811a0b6..50ac7e4eec1d 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -1,6 +1,6 @@ // Control UI tests cover chat responsive behavior. import { chromium, type Browser, type Page } from "playwright"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js"; import { canRunPlaywrightChromium, @@ -25,7 +25,7 @@ const describeBrowserLayout = canRunPlaywrightChromium(chromiumExecutablePath) ? describe : describe.skip; -const pageBrowsers = new WeakMap(); +let sharedBrowser: Browser | null = null; let realChatServer: ControlUiE2eServer | null = null; type ControlRect = { @@ -366,24 +366,15 @@ async function openFixture( } async function openBrowserPage(width: number, height: number): Promise { - const browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true }); - let page: Page | undefined; - try { - page = await browser.newPage({ viewport: { width, height } }); - pageBrowsers.set(page, browser); - return page; - } catch (error) { - await page?.close().catch(() => {}); - await browser.close().catch(() => {}); - throw error; - } + sharedBrowser ??= await chromium.launch({ + executablePath: chromiumExecutablePath, + headless: true, + }); + return await sharedBrowser.newPage({ viewport: { width, height } }); } async function closeBrowserPage(page: Page): Promise { - const browser = pageBrowsers.get(page); - pageBrowsers.delete(page); await page.close().catch(() => {}); - await browser?.close().catch(() => {}); } async function getRect(page: Page, selector: string) { @@ -457,9 +448,19 @@ async function expectNoHorizontalOverflow(page: Page) { } describeBrowserLayout("chat responsive browser layout", () => { + beforeAll(async () => { + sharedBrowser = await chromium.launch({ + executablePath: chromiumExecutablePath, + headless: true, + }); + realChatServer = await startControlUiE2eServer(); + }); + afterAll(async () => { await realChatServer?.close(); realChatServer = null; + await sharedBrowser?.close(); + sharedBrowser = null; }); it.each([ @@ -985,24 +986,28 @@ describeBrowserLayout("chat responsive browser layout", () => { } }); - it("scrolls the keyboard-active slash option into view in short landscape", async () => { - realChatServer ??= await startControlUiE2eServer(); - const page = await openBrowserPage(568, 320); - await installMockGateway(page, { - historyMessages: [ - { - content: [ - { - text: "Short landscape slash command keyboard regression fixture.", - type: "text", - }, - ], - role: "assistant", - timestamp: Date.now(), - }, - ], - }); - try { + describe("slash command keyboard navigation", () => { + let page: Page; + + beforeAll(async () => { + if (!realChatServer) { + throw new Error("Expected the Control UI server to be ready"); + } + page = await openBrowserPage(568, 320); + await installMockGateway(page, { + historyMessages: [ + { + content: [ + { + text: "Short landscape slash command keyboard regression fixture.", + type: "text", + }, + ], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); await page.goto(`${realChatServer.baseUrl}chat`); await page .getByText("Short landscape slash command keyboard regression fixture.") @@ -1010,7 +1015,13 @@ describeBrowserLayout("chat responsive browser layout", () => { const textarea = page.locator(".agent-chat__composer-combobox > textarea"); await textarea.fill("/"); await textarea.focus(); + }); + afterAll(async () => { + await closeBrowserPage(page); + }); + + it("scrolls the keyboard-active slash option into view in short landscape", async () => { const initiallyHidden = await page.evaluate(() => { const menu = document.querySelector(".slash-menu"); const options = Array.from( @@ -1078,9 +1089,7 @@ describeBrowserLayout("chat responsive browser layout", () => { expect(result.activeDescendant).toBe(initiallyHidden.id); expect(result.scrollTop).toBeGreaterThan(0); expect(result.visible).toBe(true); - } finally { - await closeBrowserPage(page); - } + }); }); it("uses the compact mobile grid when the agent filter is not rendered", async () => {