diff --git a/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts b/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts index 3df2ad1de3fd..21f271945a96 100644 --- a/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts +++ b/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts @@ -11,6 +11,10 @@ import type { GatewayAccount, GatewayPluginRuntime } from "./types.js"; const acknowledgeInteractionMock = vi.hoisted(() => vi.fn(async () => undefined)); const sendTextMock = vi.hoisted(() => vi.fn(async () => ({ id: "message-1", timestamp: 1 }))); +function waitForQqInteraction(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + vi.mock("../messaging/sender.js", () => ({ accountToCreds: (account: GatewayAccount) => ({ appId: account.appId, @@ -139,7 +143,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); - await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); + await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); expect(acknowledgeInteractionMock).toHaveBeenCalledWith( { appId: "app", clientSecret: "secret" }, @@ -167,7 +171,7 @@ describe("createInteractionHandler approval buttons", () => { }), ); - await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); + await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); expect(acknowledgeInteractionMock).toHaveBeenCalledWith( { appId: "app", clientSecret: "secret" }, @@ -185,7 +189,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent({ group_member_openid: "OWNER_OPENID" })); - await vi.waitFor(() => + await waitForQqInteraction(() => expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), ); }); @@ -208,7 +212,7 @@ describe("createInteractionHandler approval buttons", () => { }), ); - await vi.waitFor(() => + await waitForQqInteraction(() => expect(resolveApprovalMock).toHaveBeenCalledWith({ approvalId: "exec:looks-like-exec/1", approvalKind: "plugin", @@ -234,7 +238,7 @@ describe("createInteractionHandler approval buttons", () => { }), ); - await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); + await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); expect(acknowledgeInteractionMock).toHaveBeenCalledWith( { appId: "app", clientSecret: "secret" }, @@ -271,7 +275,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent({ group_member_openid: "OWNER_OPENID" })); - await vi.waitFor(() => + await waitForQqInteraction(() => expect(log.info).toHaveBeenCalledWith( "Approval already resolved: id=exec:abc12345, status=denied, decision=deny", ), @@ -306,7 +310,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent({ group_member_openid: "OWNER_OPENID" })); - await vi.waitFor(() => + await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalledWith( { appId: "app", clientSecret: "secret" }, "interaction-1", @@ -314,11 +318,11 @@ describe("createInteractionHandler approval buttons", () => { { content: "Approval response received." }, ), ); - await vi.waitFor(() => expect(resolveApprovalMock).toHaveBeenCalled()); + await waitForQqInteraction(() => expect(resolveApprovalMock).toHaveBeenCalled()); expect(sendTextMock).not.toHaveBeenCalled(); releaseResolution(appliedApprovalResult); - await vi.waitFor(() => expect(sendTextMock).toHaveBeenCalled()); + await waitForQqInteraction(() => expect(sendTextMock).toHaveBeenCalled()); }); it("uses the direct user openid when a group member openid is unavailable", async () => { @@ -335,7 +339,7 @@ describe("createInteractionHandler approval buttons", () => { }), ); - await vi.waitFor(() => + await waitForQqInteraction(() => expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), ); }); @@ -347,7 +351,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); - await vi.waitFor(() => + await waitForQqInteraction(() => expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), ); }); @@ -378,7 +382,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); - await vi.waitFor(() => + await waitForQqInteraction(() => expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), ); }); @@ -403,7 +407,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); - await vi.waitFor(() => + await waitForQqInteraction(() => expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), ); }); @@ -424,7 +428,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); - await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); + await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); expect(acknowledgeInteractionMock).toHaveBeenCalledWith( { appId: "app", clientSecret: "secret" }, @@ -468,7 +472,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); - await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); + await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); expect(acknowledgeInteractionMock).toHaveBeenCalledWith( { appId: "app", clientSecret: "secret" }, @@ -487,7 +491,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent({ group_member_openid: undefined, user_openid: undefined })); - await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); + await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); expect(acknowledgeInteractionMock).toHaveBeenCalledWith( { appId: "app", clientSecret: "secret" }, @@ -507,7 +511,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); - await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); + await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); expect(acknowledgeInteractionMock).toHaveBeenCalledWith( { appId: "app", clientSecret: "secret" }, diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index aedb4bafef36..a8631be9e647 100644 --- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -30,6 +30,10 @@ const { const SIGNAL_BASE_URL = "http://127.0.0.1:8080"; type MonitorSignalProviderOptions = NonNullable[0]>; +function waitForSignalDelivery(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) { return monitorSignalProvider({ config: config as OpenClawConfig, @@ -141,7 +145,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[1]).toBe("PFX final reply"); @@ -165,7 +169,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).toMatchObject({ @@ -193,7 +197,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).toMatchObject({ @@ -233,7 +237,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[0]).toBe("group:signal-group-id"); @@ -269,7 +273,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock.mock.calls.length).toBeGreaterThan(1); }); for (const call of sendMock.mock.calls) { @@ -306,7 +310,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock.mock.calls.length).toBeGreaterThan(1); }); expect(sendMock.mock.calls[0]?.[2]).toMatchObject({ @@ -345,7 +349,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(2); }); expect(sendMock.mock.calls[0]?.[2]).toMatchObject({ @@ -388,7 +392,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(2); }); for (const call of sendMock.mock.calls) { @@ -431,7 +435,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(2); }); for (const call of sendMock.mock.calls) { @@ -472,7 +476,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId"); @@ -504,7 +508,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId"); @@ -513,47 +517,56 @@ describe("monitorSignalProvider tool results", () => { }); it("keeps durable conversation events separate in batched reply mode", async () => { - setSignalToolResultTestConfig({ - ...createSignalToolResultConfig({ + vi.useFakeTimers(); + try { + setSignalToolResultTestConfig({ + ...createSignalToolResultConfig({ + autoStart: false, + replyToMode: "batched", + }), + messages: { inbound: { debounceMs: 10 } }, + }); + replyMock.mockResolvedValue({ text: "reply" }); + const abortController = new AbortController(); + streamMock.mockImplementation(async ({ onEvent }) => { + for (const [timestamp, message] of [ + [1700000000001, "first message"], + [1700000000002, "second message"], + ] as const) { + await onEvent({ + event: "receive", + data: JSON.stringify({ + envelope: { + sourceNumber: "+15550001111", + sourceName: "Ada", + timestamp, + dataMessage: { message }, + }, + }), + }); + } + try { + await vi.advanceTimersByTimeAsync(2_000); + expect(replyMock).toHaveBeenCalledTimes(2); + } finally { + abortController.abort(); + } + }); + + await runMonitorWithMocks({ autoStart: false, - replyToMode: "batched", - }), - messages: { inbound: { debounceMs: 10 } }, - }); - replyMock.mockResolvedValue({ text: "reply" }); - const abortController = new AbortController(); - streamMock.mockImplementation(async ({ onEvent }) => { - for (const [timestamp, message] of [ - [1700000000001, "first message"], - [1700000000002, "second message"], - ] as const) { - await onEvent({ - event: "receive", - data: JSON.stringify({ - envelope: { - sourceNumber: "+15550001111", - sourceName: "Ada", - timestamp, - dataMessage: { message }, - }, - }), - }); + baseUrl: SIGNAL_BASE_URL, + abortSignal: abortController.signal, + }); + + expect(sendMock).toHaveBeenCalledTimes(2); + for (const call of sendMock.mock.calls) { + expect(call[2]).not.toHaveProperty("replyToId"); + expect(call[2]).not.toHaveProperty("replyToAuthor"); + expect(call[2]).not.toHaveProperty("replyToBody"); } - await vi.waitFor(() => expect(replyMock).toHaveBeenCalledTimes(2)); - abortController.abort(); - }); - - await runMonitorWithMocks({ - autoStart: false, - baseUrl: SIGNAL_BASE_URL, - abortSignal: abortController.signal, - }); - - expect(sendMock).toHaveBeenCalledTimes(2); - for (const call of sendMock.mock.calls) { - expect(call[2]).not.toHaveProperty("replyToId"); - expect(call[2]).not.toHaveProperty("replyToAuthor"); - expect(call[2]).not.toHaveProperty("replyToBody"); + } finally { + vi.useRealTimers(); } }); @@ -575,7 +588,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).toMatchObject({ @@ -604,7 +617,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId"); @@ -630,7 +643,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId"); @@ -659,7 +672,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId"); @@ -688,7 +701,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).toMatchObject({ @@ -723,7 +736,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId"); @@ -760,7 +773,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId"); @@ -920,7 +933,7 @@ describe("monitorSignalProvider tool results", () => { ], }); - await vi.waitFor(() => { + await waitForSignalDelivery(() => { expect(sendMock).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/telegram/src/bot-native-command-menu.test.ts b/extensions/telegram/src/bot-native-command-menu.test.ts index 1319590cbd4b..b54fe09e4e0f 100644 --- a/extensions/telegram/src/bot-native-command-menu.test.ts +++ b/extensions/telegram/src/bot-native-command-menu.test.ts @@ -9,6 +9,10 @@ import { const TELEGRAM_COMMAND_TEXT_LIMIT = 5700; +function waitForTelegramMenu(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + type SyncMenuOptions = { deleteMyCommands: ReturnType; setMyCommands: ReturnType; @@ -289,7 +293,7 @@ describe("bot-native-command-menu", () => { botIdentity: "bot-a", }); - await vi.waitFor(() => { + await waitForTelegramMenu(() => { expect(setMyCommands).toHaveBeenCalled(); }); @@ -314,7 +318,7 @@ describe("bot-native-command-menu", () => { botIdentity: "bot-a", }); - await vi.waitFor(() => { + await waitForTelegramMenu(() => { expect(setMyCommands).toHaveBeenCalledTimes(2); }); @@ -348,7 +352,7 @@ describe("bot-native-command-menu", () => { botIdentity: "bot-a", }); - await vi.waitFor(() => { + await waitForTelegramMenu(() => { expect(setMyCommands).toHaveBeenCalledTimes(4); }); @@ -386,7 +390,7 @@ describe("bot-native-command-menu", () => { botIdentity: "bot-a", }); - await vi.waitFor(() => { + await waitForTelegramMenu(() => { expect(setMyCommands).toHaveBeenCalledTimes(4); }); @@ -413,7 +417,7 @@ describe("bot-native-command-menu", () => { accountId, botIdentity: "bot-a", }); - await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); syncMenuCommandsWithMocks({ deleteMyCommands, @@ -444,7 +448,7 @@ describe("bot-native-command-menu", () => { accountId, botIdentity: "bot-a", }); - await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); syncMenuCommandsWithMocks({ deleteMyCommands, @@ -454,7 +458,7 @@ describe("bot-native-command-menu", () => { accountId, botIdentity: "bot-a", }); - await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); }); it("resyncs delimiter-like command lists without hash collisions", async () => { @@ -471,7 +475,7 @@ describe("bot-native-command-menu", () => { accountId, botIdentity: "bot-a", }); - await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); syncMenuCommandsWithMocks({ deleteMyCommands, @@ -484,7 +488,7 @@ describe("bot-native-command-menu", () => { accountId, botIdentity: "bot-a", }); - await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); }); it("skips sync when command hash is unchanged (#32017)", async () => { @@ -504,7 +508,7 @@ describe("bot-native-command-menu", () => { botIdentity: "bot-a", }); - await vi.waitFor(() => { + await waitForTelegramMenu(() => { expect(setMyCommands).toHaveBeenCalledTimes(2); }); @@ -535,7 +539,7 @@ describe("bot-native-command-menu", () => { accountId, botIdentity: "token-bot-a", }); - await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); syncMenuCommandsWithMocks({ deleteMyCommands, @@ -545,7 +549,7 @@ describe("bot-native-command-menu", () => { accountId, botIdentity: "token-bot-b", }); - await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); }); it("does not cache empty-menu hash when deleteMyCommands fails", async () => { @@ -565,7 +569,7 @@ describe("bot-native-command-menu", () => { accountId, botIdentity: "bot-a", }); - await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2)); + await waitForTelegramMenu(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2)); syncMenuCommandsWithMocks({ deleteMyCommands, @@ -575,7 +579,7 @@ describe("bot-native-command-menu", () => { accountId, botIdentity: "bot-a", }); - await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(4)); + await waitForTelegramMenu(() => expect(deleteMyCommands).toHaveBeenCalledTimes(4)); }); it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => { @@ -600,7 +604,7 @@ describe("bot-native-command-menu", () => { botIdentity: "bot-a", }); - await vi.waitFor(() => { + await waitForTelegramMenu(() => { expect(setMyCommands).toHaveBeenCalledTimes(3); }); const firstPayload = setMyCommandsPayload(setMyCommands, 0); @@ -640,7 +644,7 @@ describe("bot-native-command-menu", () => { botIdentity: "bot-a", }); - await vi.waitFor(() => { + await waitForTelegramMenu(() => { expect(setMyCommands).toHaveBeenCalledTimes(5); }); expect(setMyCommandsPayload(setMyCommands, 0)).toHaveLength(100); @@ -669,7 +673,7 @@ describe("bot-native-command-menu", () => { botIdentity: "bot-a", }); - await vi.waitFor(() => { + await waitForTelegramMenu(() => { expect(setMyCommands).toHaveBeenCalledTimes(3); }); expect(runtimeLog).toHaveBeenCalledWith( diff --git a/src/gateway/server/ws-connection/message-handler.worker.test.ts b/src/gateway/server/ws-connection/message-handler.worker.test.ts index 5a8bcd6a4660..bd3fbf2b7262 100644 --- a/src/gateway/server/ws-connection/message-handler.worker.test.ts +++ b/src/gateway/server/ws-connection/message-handler.worker.test.ts @@ -119,6 +119,10 @@ const INFERENCE_EVENT: WorkerInferenceEventFrame = { }; const cleanups: Array<() => void> = []; +function waitForWorkerProtocol(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + type InferenceSink = { connectionId: string; send(frame: WorkerInferenceEventFrame | WorkerInferenceTerminalFrame): void; @@ -224,7 +228,7 @@ function attachHarness( async function admit(harness: ReturnType): Promise { harness.sendConnect(); - await vi.waitFor(() => expect(harness.responses).toHaveLength(1)); + await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(1)); } describe("dedicated worker websocket protocol", () => { @@ -252,7 +256,7 @@ describe("dedicated worker websocket protocol", () => { const harness = attachHarness({ admissionFailure: reason }); harness.sendConnect(); - await vi.waitFor(() => expect(harness.close).toHaveBeenCalledWith(1008, reason)); + await waitForWorkerProtocol(() => expect(harness.close).toHaveBeenCalledWith(1008, reason)); expect(harness.responses[0]).toMatchObject({ ok: false, error: { details: { reason } } }); expect(harness.logWsControl.warn).toHaveBeenCalledWith( `worker admission rejected reason=${reason}`, @@ -269,7 +273,9 @@ describe("dedicated worker websocket protocol", () => { await admit(harness); harness.sendRequest(method, params); - await vi.waitFor(() => expect(harness.close).toHaveBeenCalledWith(1008, "method-not-allowed")); + await waitForWorkerProtocol(() => + expect(harness.close).toHaveBeenCalledWith(1008, "method-not-allowed"), + ); expect(harness.logGateway.warn).toHaveBeenCalledWith( "worker protocol request rejected reason=method-not-allowed", ); @@ -279,7 +285,7 @@ describe("dedicated worker websocket protocol", () => { const valid = attachHarness(); await admit(valid); valid.sendRequest("worker.heartbeat", { sentAtMs: 1, status: "busy" }); - await vi.waitFor(() => expect(valid.responses).toHaveLength(2)); + await waitForWorkerProtocol(() => expect(valid.responses).toHaveLength(2)); expect(valid.responses[1]).toMatchObject({ ok: true, payload: { status: "ok", ownerEpoch: 1 }, @@ -297,7 +303,7 @@ describe("dedicated worker websocket protocol", () => { }); await admit(unsupported); unsupported.sendRequest("worker.inference.start", INFERENCE_START); - await vi.waitFor(() => + await waitForWorkerProtocol(() => expect(unsupported.close).toHaveBeenCalledWith(1008, "method-not-allowed"), ); expect(unsupported.service.startInference).not.toHaveBeenCalled(); @@ -311,7 +317,7 @@ describe("dedicated worker websocket protocol", () => { await admit(harness); harness.sendRequest("worker.inference.start", INFERENCE_START); - await vi.waitFor(() => expect(harness.responses).toHaveLength(3)); + await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(3)); expect(harness.responses[1]).toMatchObject({ ok: true, payload: { status: "accepted" }, @@ -320,7 +326,7 @@ describe("dedicated worker websocket protocol", () => { expect(harness.service.startInference).toHaveBeenCalledOnce(); harness.sendRequest("worker.inference.cancel", INFERENCE_IDS, "cancel-1"); - await vi.waitFor(() => expect(harness.responses).toHaveLength(4)); + await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(4)); expect(harness.responses[3]).toMatchObject({ ok: true, payload: { status: "cancelled" }, @@ -333,7 +339,7 @@ describe("dedicated worker websocket protocol", () => { await admit(harness); harness.sendRequest("worker.transcript.commit", TRANSCRIPT_COMMIT); - await vi.waitFor(() => expect(harness.responses).toHaveLength(2)); + await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(2)); expect(harness.responses[1]).toMatchObject({ ok: true, payload: { entryIds: ["entry-1"], newLeafId: "entry-1" }, @@ -357,7 +363,7 @@ describe("dedicated worker websocket protocol", () => { }); await admit(unsupported); unsupported.sendRequest("worker.live-event", LIVE_EVENT); - await vi.waitFor(() => expect(unsupported.close).toHaveBeenCalled()); + await waitForWorkerProtocol(() => expect(unsupported.close).toHaveBeenCalled()); expect(unsupported.service.pushLiveEvent).not.toHaveBeenCalled(); const resync = attachHarness({ @@ -365,7 +371,7 @@ describe("dedicated worker websocket protocol", () => { }); await admit(resync); resync.sendRequest("worker.live-event", { ...LIVE_EVENT, seq: 7 }); - await vi.waitFor(() => + await waitForWorkerProtocol(() => expect(resync.responses[1]).toMatchObject({ error: { details: { reason: "resync-required" } }, }), @@ -378,7 +384,7 @@ describe("dedicated worker websocket protocol", () => { ...LIVE_EVENT, event: { kind: "assistant", payload: { delta: "x" } }, }); - await vi.waitFor(() => + await waitForWorkerProtocol(() => expect(invalid.responses[1]).toMatchObject({ error: { details: { reason: "invalid-event" } }, }), @@ -393,7 +399,9 @@ describe("dedicated worker websocket protocol", () => { await admit(harness); harness.sendRequest("worker.transcript.commit", TRANSCRIPT_COMMIT); - await vi.waitFor(() => expect(harness.close).toHaveBeenCalledWith(1008, "method-not-allowed")); + await waitForWorkerProtocol(() => + expect(harness.close).toHaveBeenCalledWith(1008, "method-not-allowed"), + ); expect(harness.service.commitTranscript).not.toHaveBeenCalled(); }); @@ -402,7 +410,7 @@ describe("dedicated worker websocket protocol", () => { await admit(harness); harness.sendRequest("worker.transcript.commit", TRANSCRIPT_COMMIT); - await vi.waitFor(() => expect(harness.responses).toHaveLength(2)); + await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(2)); expect(harness.responses[1]).toMatchObject({ ok: false, error: { details: { reason: "stale-base-leaf" } }, @@ -418,7 +426,7 @@ describe("dedicated worker websocket protocol", () => { sessionId: "foreign-session", }); - await vi.waitFor(() => expect(harness.responses).toHaveLength(2)); + await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(2)); expect(harness.responses[1]).toMatchObject({ ok: false, error: { details: { reason: "invalid-batch" } }, @@ -436,12 +444,14 @@ describe("dedicated worker websocket protocol", () => { sessionId: "foreign-session", }); - await vi.waitFor(() => expect(harness.responses).toHaveLength(2)); + await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(2)); expect(harness.responses[1]).toMatchObject({ ok: false, error: { details: { reason: "credential-replaced" } }, }); - await vi.waitFor(() => expect(harness.close).toHaveBeenCalledWith(1008, "credential-replaced")); + await waitForWorkerProtocol(() => + expect(harness.close).toHaveBeenCalledWith(1008, "credential-replaced"), + ); expect(harness.service.commitTranscript).not.toHaveBeenCalled(); }); @@ -449,7 +459,9 @@ describe("dedicated worker websocket protocol", () => { const harness = attachHarness({ validationFailure: "credential-replaced" }); harness.sendConnect(); - await vi.waitFor(() => expect(harness.close).toHaveBeenCalledWith(1008, "credential-replaced")); + await waitForWorkerProtocol(() => + expect(harness.close).toHaveBeenCalledWith(1008, "credential-replaced"), + ); expect(harness.setClient).not.toHaveBeenCalled(); }); @@ -459,7 +471,9 @@ describe("dedicated worker websocket protocol", () => { vi.mocked(harness.service.validateWorkerConnection).mockReturnValue("credential-replaced"); harness.sendRequest("worker.heartbeat", { sentAtMs: 1, status: "ready" }); - await vi.waitFor(() => expect(harness.close).toHaveBeenCalledWith(1008, "credential-replaced")); + await waitForWorkerProtocol(() => + expect(harness.close).toHaveBeenCalledWith(1008, "credential-replaced"), + ); }); it("fences a replaced connection before dispatch", async () => { @@ -468,7 +482,9 @@ describe("dedicated worker websocket protocol", () => { harness.client()!.invalidated = true; harness.sendRequest("worker.heartbeat", { sentAtMs: 1, status: "ready" }); - await vi.waitFor(() => expect(harness.close).toHaveBeenCalledWith(1008, "credential-replaced")); + await waitForWorkerProtocol(() => + expect(harness.close).toHaveBeenCalledWith(1008, "credential-replaced"), + ); expect(harness.service.validateWorkerConnection).toHaveBeenCalledOnce(); }); @@ -479,7 +495,7 @@ describe("dedicated worker websocket protocol", () => { expect(suspension).not.toBeNull(); try { harness.sendRequest("worker.heartbeat", { sentAtMs: 1, status: "ready" }); - await vi.waitFor(() => + await waitForWorkerProtocol(() => expect(harness.close).toHaveBeenCalledWith(1013, "gateway-unavailable"), ); expect(harness.service.validateWorkerConnection).toHaveBeenCalledOnce(); diff --git a/ui/src/components/onboarding-memory-import.test.ts b/ui/src/components/onboarding-memory-import.test.ts index 03f530f97954..f2ac2d486425 100644 --- a/ui/src/components/onboarding-memory-import.test.ts +++ b/ui/src/components/onboarding-memory-import.test.ts @@ -12,6 +12,10 @@ type OnboardingMemoryImportElement = HTMLElement & { updateComplete: Promise; }; +function waitForOnboardingMemoryImport(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + const guardKey = "openclaw.onboarding.memory-import"; function createProvider(providerId: string, fingerprint: string) { @@ -163,7 +167,9 @@ describe("OnboardingMemoryImport", () => { const context = createContext(request, { agentsLoaded: false }); await mount(context); - await vi.waitFor(() => expect(context.agents.ensureList).toHaveBeenCalledTimes(1)); + await waitForOnboardingMemoryImport(() => + expect(context.agents.ensureList).toHaveBeenCalledTimes(1), + ); expect(request).not.toHaveBeenCalled(); }); @@ -171,7 +177,9 @@ describe("OnboardingMemoryImport", () => { const request = vi.fn(async () => createPlan([])); const element = await mount(createContext(request)); - await vi.waitFor(() => expect(sessionStorage.getItem(guardKey)).toBe("done")); + await waitForOnboardingMemoryImport(() => + expect(sessionStorage.getItem(guardKey)).toBe("done"), + ); expect(request).toHaveBeenCalledWith("migrations.memory.plan", { agentId: "research", overwrite: false, @@ -185,7 +193,7 @@ describe("OnboardingMemoryImport", () => { }); const element = await mount(createContext(request)); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + await waitForOnboardingMemoryImport(() => expect(request).toHaveBeenCalledTimes(1)); await element.updateComplete; expect(element.querySelector("openclaw-modal-dialog")).toBeNull(); expect(sessionStorage.getItem(guardKey)).toBeNull(); @@ -207,7 +215,7 @@ describe("OnboardingMemoryImport", () => { })); const element = await mount(createContext(request)); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + await waitForOnboardingMemoryImport(() => expect(request).toHaveBeenCalledTimes(1)); await element.updateComplete; expect(element.querySelector("openclaw-modal-dialog")).toBeNull(); expect(sessionStorage.getItem(guardKey)).toBeNull(); @@ -223,7 +231,7 @@ describe("OnboardingMemoryImport", () => { ); const context = createContext(request); const element = await mount(context); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + await waitForOnboardingMemoryImport(() => expect(request).toHaveBeenCalledTimes(1)); context.gateway.snapshot.client = createContext(vi.fn()).gateway.snapshot.client; resolvePlan(createPlan()); @@ -246,7 +254,7 @@ describe("OnboardingMemoryImport", () => { .mockReturnValueOnce("00000000-0000-4000-8000-000000000001") .mockReturnValueOnce("00000000-0000-4000-8000-000000000002"); const element = await mount(createContext(request)); - await vi.waitFor(() => + await waitForOnboardingMemoryImport(() => expect( element.querySelector( "[data-test-id='onboarding-memory-import-import']", @@ -258,7 +266,7 @@ describe("OnboardingMemoryImport", () => { .querySelector("[data-test-id='onboarding-memory-import-import']") ?.click(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(3)); + await waitForOnboardingMemoryImport(() => expect(request).toHaveBeenCalledTimes(3)); const applyCalls = request.mock.calls.filter( ([method]) => method === "migrations.memory.apply", ); @@ -297,7 +305,7 @@ describe("OnboardingMemoryImport", () => { return createApplyResult("codex"); }); const element = await mount(createContext(originalRequest)); - await vi.waitFor(() => + await waitForOnboardingMemoryImport(() => expect( element.querySelector( "[data-test-id='onboarding-memory-import-import']", @@ -309,11 +317,11 @@ describe("OnboardingMemoryImport", () => { // never be applied through the old binding. The offer replans instead. element.context = createContext(replacementRequest); await element.updateComplete; - await vi.waitFor(() => expect(replacementRequest).toHaveBeenCalled()); + await waitForOnboardingMemoryImport(() => expect(replacementRequest).toHaveBeenCalled()); expect(replacementRequest.mock.calls[0]?.[0]).toBe("migrations.memory.plan"); expect(originalRequest).toHaveBeenCalledTimes(1); - await vi.waitFor(() => + await waitForOnboardingMemoryImport(() => expect( element.querySelector( "[data-test-id='onboarding-memory-import-import']", @@ -323,7 +331,7 @@ describe("OnboardingMemoryImport", () => { element .querySelector("[data-test-id='onboarding-memory-import-import']") ?.click(); - await vi.waitFor(() => + await waitForOnboardingMemoryImport(() => expect( replacementRequest.mock.calls.filter((call) => call[0] === "migrations.memory.apply"), ).toHaveLength(1), @@ -344,7 +352,7 @@ describe("OnboardingMemoryImport", () => { return result; }); const element = await mount(createContext(request)); - await vi.waitFor(() => + await waitForOnboardingMemoryImport(() => expect( element.querySelector( "[data-test-id='onboarding-memory-import-import']", @@ -356,14 +364,14 @@ describe("OnboardingMemoryImport", () => { .querySelector("[data-test-id='onboarding-memory-import-import']") ?.click(); - await vi.waitFor(() => expect(element.textContent).toContain("1 failed")); + await waitForOnboardingMemoryImport(() => expect(element.textContent).toContain("1 failed")); expect(element.textContent).toContain("Migrated 1"); }); it("sets the guard when skipped", async () => { const request = vi.fn(async () => createPlan()); const element = await mount(createContext(request)); - await vi.waitFor(() => + await waitForOnboardingMemoryImport(() => expect( element.querySelector("[data-test-id='onboarding-memory-import-skip']"), ).not.toBeNull(), @@ -389,7 +397,7 @@ describe("OnboardingMemoryImport", () => { return createApplyResult("claude", 1, 0); }); const element = await mount(createContext(request)); - await vi.waitFor(() => + await waitForOnboardingMemoryImport(() => expect( element.querySelector( "[data-test-id='onboarding-memory-import-import']", @@ -401,7 +409,7 @@ describe("OnboardingMemoryImport", () => { .querySelector("[data-test-id='onboarding-memory-import-import']") ?.click(); - await vi.waitFor(() => + await waitForOnboardingMemoryImport(() => expect( element.querySelector( "[data-test-id='onboarding-memory-import-continue']", diff --git a/ui/src/lib/workspace/bridge.test.ts b/ui/src/lib/workspace/bridge.test.ts index 27d74388b5cb..6cde2ee89c27 100644 --- a/ui/src/lib/workspace/bridge.test.ts +++ b/ui/src/lib/workspace/bridge.test.ts @@ -6,6 +6,10 @@ type WidgetBridgeDeps = Parameters[0]; let testWidgetSequence = 0; +function waitForWidgetBridge(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + beforeEach(() => { testWidgetSequence += 1; }); @@ -59,7 +63,7 @@ describe("getData binding gating", () => { resolveBinding, }); bridge.handleMessage({ v: 1, type: "workspace:getData", requestId: "r1", bindingId: "value" }); - await vi.waitFor(() => expect(posted).toHaveLength(1)); + await waitForWidgetBridge(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ type: "workspace:error", code: "capability_denied" }); expect(resolveBinding).not.toHaveBeenCalled(); }); @@ -67,7 +71,7 @@ describe("getData binding gating", () => { it("resolves a declared binding and posts data", async () => { const { bridge, posted } = makeBridge({ resolveBinding: async () => ({ revenue: 42 }) }); bridge.handleMessage({ v: 1, type: "workspace:getData", requestId: "r1", bindingId: "value" }); - await vi.waitFor(() => expect(posted).toHaveLength(1)); + await waitForWidgetBridge(() => expect(posted).toHaveLength(1)); expect(posted[0]).toEqual({ v: 1, type: "workspace:data", @@ -81,7 +85,7 @@ describe("getData binding gating", () => { const resolveBinding = vi.fn(async () => ({ ok: true })); const { bridge, posted } = makeBridge({ resolveBinding }); bridge.handleMessage({ v: 1, type: "workspace:getData", requestId: "r1", bindingId: "secret" }); - await vi.waitFor(() => expect(posted).toHaveLength(1)); + await waitForWidgetBridge(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ type: "workspace:error", code: "binding_denied", @@ -138,7 +142,7 @@ describe("sendPrompt capability + confirm + rate limit", () => { sendPrompt, }); bridge.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: "p1", text: "hi" }); - await vi.waitFor(() => expect(posted).toHaveLength(1)); + await waitForWidgetBridge(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ type: "workspace:error", code: "capability_denied" }); expect(confirmPrompt).not.toHaveBeenCalled(); expect(sendPrompt).not.toHaveBeenCalled(); @@ -152,7 +156,7 @@ describe("sendPrompt capability + confirm + rate limit", () => { sendPrompt, }); bridge.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: "p1", text: "hi" }); - await vi.waitFor(() => expect(posted).toHaveLength(1)); + await waitForWidgetBridge(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ type: "workspace:error", code: "prompt_declined" }); expect(sendPrompt).not.toHaveBeenCalled(); }); @@ -166,7 +170,7 @@ describe("sendPrompt capability + confirm + rate limit", () => { sendPrompt, }); bridge.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: "p1", text: "do it" }); - await vi.waitFor(() => expect(sendPrompt).toHaveBeenCalledTimes(1)); + await waitForWidgetBridge(() => expect(sendPrompt).toHaveBeenCalledTimes(1)); expect(confirmPrompt).toHaveBeenCalledWith("do it"); expect(sendPrompt).toHaveBeenCalledWith("do it"); }); @@ -183,7 +187,7 @@ describe("sendPrompt capability + confirm + rate limit", () => { bridge.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: "p1", text: "one" }); // Second request while the first confirm is still pending → rate_limited. bridge.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: "p2", text: "two" }); - await vi.waitFor(() => expect(posted).toHaveLength(1)); + await waitForWidgetBridge(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ type: "workspace:error", code: "rate_limited", @@ -207,12 +211,12 @@ describe("sendPrompt capability + confirm + rate limit", () => { // next (the in-flight limit is exercised by a separate test). for (let i = 0; i < 10; i += 1) { bridge.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: `p${i}`, text: "x" }); - await vi.waitFor(() => expect(sent).toBe(i + 1)); + await waitForWidgetBridge(() => expect(sent).toBe(i + 1)); } expect(posted).toHaveLength(0); // The 11th within the same rolling minute is rejected. bridge.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: "p10", text: "x" }); - await vi.waitFor(() => expect(posted).toHaveLength(1)); + await waitForWidgetBridge(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ type: "workspace:error", code: "rate_limited" }); expect(sent).toBe(10); }); @@ -282,13 +286,13 @@ describe("rate-limit state persists across bridge re-instantiation (remount)", ( requestId: `a${i}`, text: "x", }); - await vi.waitFor(() => expect(sent).toBe(i + 1)); + await waitForWidgetBridge(() => expect(sent).toBe(i + 1)); } // Remount: dispose the exhausted bridge and build a fresh one for the same name. first.bridge.dispose(); const second = makeNamed(); second.bridge.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: "b0", text: "x" }); - await vi.waitFor(() => expect(second.posted).toHaveLength(1)); + await waitForWidgetBridge(() => expect(second.posted).toHaveLength(1)); // Budget survived the remount → still rate_limited, no extra send. expect(second.posted[0]).toMatchObject({ type: "workspace:error", code: "rate_limited" }); expect(sent).toBe(10); @@ -324,11 +328,11 @@ describe("rate-limit state persists across bridge re-instantiation (remount)", ( // Exhaust widget-a's budget. for (let i = 0; i < 10; i += 1) { bridgeA.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: `a${i}`, text: "x" }); - await vi.waitFor(() => expect(sentA).toBe(i + 1)); + await waitForWidgetBridge(() => expect(sentA).toBe(i + 1)); } // widget-b is unaffected — its first send succeeds. bridgeB.handleMessage({ v: 1, type: "workspace:sendPrompt", requestId: "b0", text: "x" }); - await vi.waitFor(() => expect(sentB).toBe(1)); + await waitForWidgetBridge(() => expect(sentB).toBe(1)); expect(postedB).toHaveLength(0); }); }); @@ -352,7 +356,7 @@ describe("resolve-time binding re-check", () => { assertBindingAllowed: () => null, }); bridge.handleMessage({ v: 1, type: "workspace:getData", requestId: "r1", bindingId: "value" }); - await vi.waitFor(() => expect(posted).toHaveLength(1)); + await waitForWidgetBridge(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ type: "workspace:data", data: 42 }); expect(resolveBinding).toHaveBeenCalledOnce(); }); diff --git a/ui/src/pages/cron/cron-page.test.ts b/ui/src/pages/cron/cron-page.test.ts index 623c0f92fa14..a557224af41d 100644 --- a/ui/src/pages/cron/cron-page.test.ts +++ b/ui/src/pages/cron/cron-page.test.ts @@ -14,6 +14,10 @@ type CronTestPage = HTMLElement & { cronModelSuggestions: string[]; }; +function waitForCronPage(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + type TestGateway = ApplicationContext["gateway"] & { emitSnapshot: (patch: Partial) => void; emitRetiredEvent: (event: Parameters[0]) => void; @@ -159,7 +163,7 @@ describe("CronPage editor state sync", () => { const gateway = createGateway({ request } as unknown as GatewayBrowserClient, true); createPage(createContext(gateway, "writer")); - await vi.waitFor(() => { + await waitForCronPage(() => { expect(request).toHaveBeenCalledWith( "cron.list", expect.objectContaining({ agentId: "writer" }), @@ -191,21 +195,21 @@ describe("CronPage editor state sync", () => { const gateway = createGateway(client, true); const page = createPage(createContext(gateway), { render: true }); - await vi.waitFor(() => + await waitForCronPage(() => expect(page.querySelector('[data-test-id="cron-new-task"]')).not.toBeNull(), ); (page.querySelector('[data-suggestion="repoPulse"]') as HTMLButtonElement).click(); - await vi.waitFor(() => + await waitForCronPage(() => expect(page.querySelector('[data-test-id="cron-submit-run"]')).not.toBeNull(), ); (page.querySelector('[data-test-id="cron-submit-run"]') as HTMLButtonElement).click(); - await vi.waitFor(() => { + await waitForCronPage(() => { const methods = request.mock.calls.map((call) => call[0]); expect(methods.indexOf("cron.run")).toBeGreaterThan(methods.indexOf("cron.add")); }); expect(request).toHaveBeenCalledWith("cron.run", { id: "job-fresh", mode: "force" }); - await vi.waitFor(() => expect(page.cron.cronCreateOpen).toBe(false)); + await waitForCronPage(() => expect(page.cron.cronCreateOpen).toBe(false)); }); it("drills from the failing stat into run history filtered to errors", async () => { @@ -214,12 +218,12 @@ describe("CronPage editor state sync", () => { const gateway = createGateway(client, true); const page = createPage(createContext(gateway), { render: true }); - await vi.waitFor(() => + await waitForCronPage(() => expect(page.querySelector('[data-test-id="cron-stat-failing"]')).not.toBeNull(), ); (page.querySelector('[data-test-id="cron-stat-failing"]') as HTMLButtonElement).click(); - await vi.waitFor(() => expect(page.querySelector(".cron-activity")).not.toBeNull()); + await waitForCronPage(() => expect(page.querySelector(".cron-activity")).not.toBeNull()); expect(page.cron.cronRunsStatuses).toEqual(["error"]); expect(request).toHaveBeenCalledWith( "cron.runs", @@ -273,13 +277,13 @@ describe("CronPage editor state sync", () => { const gateway = createGateway(client, true); const page = createPage(createContext(gateway), { render: true }); - await vi.waitFor(() => expect(page.querySelector(".cron-table__row")).not.toBeNull()); + await waitForCronPage(() => expect(page.querySelector(".cron-table__row")).not.toBeNull()); (page.querySelector(".cron-table__row") as HTMLElement).click(); - await vi.waitFor(() => expect(page.cron.cronEditingJobId).toBe("job-1")); + await waitForCronPage(() => expect(page.cron.cronEditingJobId).toBe("job-1")); expect(page.cron.cronRunsScope).toBe("job"); expect(page.cron.cronForm.enabled).toBe(true); - await vi.waitFor(() => + await waitForCronPage(() => expect(page.querySelector('[data-test-id="cron-toggle-enabled"] wa-switch')).not.toBeNull(), ); const enabledToggle = page.querySelector( @@ -287,15 +291,15 @@ describe("CronPage editor state sync", () => { ) as HTMLElement & { checked: boolean }; enabledToggle.checked = false; enabledToggle.dispatchEvent(new Event("change", { bubbles: true })); - await vi.waitFor(() => expect(page.cron.cronForm.enabled).toBe(false)); + await waitForCronPage(() => expect(page.cron.cronForm.enabled).toBe(false)); expect(serverEnabled).toBe(false); const removeButton = Array.from(page.querySelectorAll(".cron-job-menu__item")).find( (item) => item.textContent?.trim() === "Remove", ) as HTMLButtonElement; removeButton.click(); - await vi.waitFor(() => expect(page.cron.cronEditingJobId).toBeNull()); - await vi.waitFor(() => expect(page.cron.cronRunsScope).toBe("all")); + await waitForCronPage(() => expect(page.cron.cronEditingJobId).toBeNull()); + await waitForCronPage(() => expect(page.cron.cronRunsScope).toBe("all")); }); }); @@ -360,10 +364,10 @@ describe("CronPage lifecycle", () => { await page.updateComplete; gateway.emitSnapshot({ connected: true }); - await vi.waitFor(() => expect(modelRequestCount).toBe(1)); + await waitForCronPage(() => expect(modelRequestCount).toBe(1)); gateway.emitSnapshot({ connected: false }); gateway.emitSnapshot({ connected: true }); - await vi.waitFor(() => expect(page.cronModelSuggestions).toEqual(["fresh/model"])); + await waitForCronPage(() => expect(page.cronModelSuggestions).toEqual(["fresh/model"])); staleModels.resolve({ models: [{ id: "stale/model" }] }); await Promise.resolve(); @@ -380,12 +384,12 @@ describe("CronPage lifecycle", () => { const firstContext = createContext(firstGateway); const secondContext = createContext(secondGateway); const page = createPage(firstContext); - await vi.waitFor(() => expect(request).toHaveBeenCalled()); + await waitForCronPage(() => expect(request).toHaveBeenCalled()); page.context = secondContext; page.requestUpdate(); await page.updateComplete; - await vi.waitFor(() => expect(page.cron.client).toBe(client)); + await waitForCronPage(() => expect(page.cron.client).toBe(client)); request.mockClear(); vi.mocked(secondContext.channels.refresh).mockClear(); diff --git a/ui/src/pages/memory-import/memory-import-page.test.ts b/ui/src/pages/memory-import/memory-import-page.test.ts index caf432ae0f8f..f02807ce7329 100644 --- a/ui/src/pages/memory-import/memory-import-page.test.ts +++ b/ui/src/pages/memory-import/memory-import-page.test.ts @@ -11,6 +11,10 @@ type MemoryImportPageElement = HTMLElement & { requestUpdate(): void; }; +function waitForMemoryImport(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + function createPlan(agentId = "research") { const workspace = `/tmp/openclaw-${agentId}`; return { @@ -110,7 +114,7 @@ describe("MemoryImportPage", () => { }); const page = await mountPage(createContext(request)); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(1)); await page.updateComplete; await Promise.resolve(); await page.updateComplete; @@ -124,7 +128,7 @@ describe("MemoryImportPage", () => { throw new Error("expected Refresh button"); } refresh.click(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(2)); }); it("keeps apply recovery results visible when the follow-up plan fails", async () => { @@ -167,7 +171,7 @@ describe("MemoryImportPage", () => { }); const page = await mountPage(createContext(request)); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-provider-button']"), ).not.toBeNull(), @@ -175,14 +179,14 @@ describe("MemoryImportPage", () => { page .querySelector("[data-test-id='memory-import-provider-button']") ?.click(); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-confirm']"), ).not.toBeNull(), ); page.querySelector("[data-test-id='memory-import-confirm']")?.click(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(3)); + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(3)); await page.updateComplete; expect(page.textContent).toContain("post-apply planning unavailable"); expect(page.textContent).toContain("replacement interrupted"); @@ -219,7 +223,7 @@ describe("MemoryImportPage", () => { }); const page = await mountPage(createContext(request)); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-provider-button']"), ).not.toBeNull(), @@ -227,15 +231,15 @@ describe("MemoryImportPage", () => { page .querySelector("[data-test-id='memory-import-provider-button']") ?.click(); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-confirm']"), ).not.toBeNull(), ); page.querySelector("[data-test-id='memory-import-confirm']")?.click(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(3)); - await vi.waitFor(() => + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(3)); + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-provider-button']") ?.disabled, @@ -273,7 +277,7 @@ describe("MemoryImportPage", () => { }); const page = await mountPage(createContext(request)); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-provider-button']"), ).not.toBeNull(), @@ -281,16 +285,16 @@ describe("MemoryImportPage", () => { page .querySelector("[data-test-id='memory-import-provider-button']") ?.click(); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-confirm']"), ).not.toBeNull(), ); page.querySelector("[data-test-id='memory-import-confirm']")?.click(); - await vi.waitFor(() => expect(page.textContent).toContain("response lost")); + await waitForMemoryImport(() => expect(page.textContent).toContain("response lost")); page.querySelector("[data-test-id='memory-import-confirm']")?.click(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(4)); + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(4)); const firstApply = request.mock.calls[1]?.[1] as { idempotencyKey?: string } | undefined; const retryApply = request.mock.calls[2]?.[1] as { idempotencyKey?: string } | undefined; expect(firstApply?.idempotencyKey).toMatch(/\S/u); @@ -307,7 +311,7 @@ describe("MemoryImportPage", () => { const context = createContext(request); const page = await mountPage(context); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-provider-button']"), ).not.toBeNull(), @@ -315,7 +319,7 @@ describe("MemoryImportPage", () => { page .querySelector("[data-test-id='memory-import-provider-button']") ?.click(); - await vi.waitFor(() => + await waitForMemoryImport(() => expect(page.querySelector("[data-test-id='memory-import-confirm']")).not.toBeNull(), ); @@ -330,7 +334,7 @@ describe("MemoryImportPage", () => { context.gateway.snapshot.client = replacementClient; context.gateway.snapshot.connected = true; page.requestUpdate(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(2)); expect(page.querySelector("[data-test-id='memory-import-confirm']")).toBeNull(); }); @@ -369,7 +373,7 @@ describe("MemoryImportPage", () => { const context = createContext(request); const page = await mountPage(context); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-provider-button']"), ).not.toBeNull(), @@ -377,11 +381,11 @@ describe("MemoryImportPage", () => { page .querySelector("[data-test-id='memory-import-provider-button']") ?.click(); - await vi.waitFor(() => + await waitForMemoryImport(() => expect(page.querySelector("[data-test-id='memory-import-confirm']")).not.toBeNull(), ); page.querySelector("[data-test-id='memory-import-confirm']")?.click(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(2)); const firstApply = request.mock.calls[1]?.[1] as { idempotencyKey?: string } | undefined; context.gateway.snapshot.connected = false; @@ -396,12 +400,12 @@ describe("MemoryImportPage", () => { context.gateway.snapshot.client = replacementClient; context.gateway.snapshot.connected = true; page.requestUpdate(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(3)); - await vi.waitFor(() => + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(3)); + await waitForMemoryImport(() => expect(page.querySelector("[data-test-id='memory-import-confirm']")).not.toBeNull(), ); page.querySelector("[data-test-id='memory-import-confirm']")?.click(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(4)); + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(4)); const retryApply = request.mock.calls[3]?.[1] as { idempotencyKey?: string } | undefined; expect(firstApply?.idempotencyKey).toMatch(/\S/u); @@ -430,7 +434,7 @@ describe("MemoryImportPage", () => { mutableContext.agents.state.agentsList.agents.push({ id: "writer", name: "Writer" }); const page = await mountPage(context); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-provider-button']"), ).not.toBeNull(), @@ -438,7 +442,7 @@ describe("MemoryImportPage", () => { page .querySelector("[data-test-id='memory-import-provider-button']") ?.click(); - await vi.waitFor(() => + await waitForMemoryImport(() => expect( page.querySelector("[data-test-id='memory-import-confirm']"), ).not.toBeNull(), @@ -449,7 +453,7 @@ describe("MemoryImportPage", () => { expect(request).toHaveBeenCalledTimes(1); page.requestUpdate(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + await waitForMemoryImport(() => expect(request).toHaveBeenCalledTimes(2)); await page.updateComplete; expect(request.mock.calls[1]?.[1]).toMatchObject({ agentId: "writer" }); expect(page.querySelector("[data-test-id='memory-import-confirm']")).toBeNull(); diff --git a/ui/src/pages/skill-workshop/skill-workshop-page.test.ts b/ui/src/pages/skill-workshop/skill-workshop-page.test.ts index 59412415fba1..77d010962d04 100644 --- a/ui/src/pages/skill-workshop/skill-workshop-page.test.ts +++ b/ui/src/pages/skill-workshop/skill-workshop-page.test.ts @@ -20,6 +20,10 @@ type SkillWorkshopPageTestElement = HTMLElement & { requestUpdate: () => void; }; +function waitForSkillWorkshop(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((next) => { @@ -201,7 +205,7 @@ describe("SkillWorkshopPage lifecycle", () => { page.requestUpdate(); await page.updateComplete; - await vi.waitFor(() => + await waitForSkillWorkshop(() => expect(secondRequest).toHaveBeenCalledWith("skills.proposals.list", { agentId: "research", }), @@ -232,7 +236,7 @@ describe("SkillWorkshopPage lifecycle", () => { updatedAt: "2026-07-08T00:00:00.000Z", proposals: [], }); - await vi.waitFor(() => expect(page.state?.skillWorkshopLoaded).toBe(true)); + await waitForSkillWorkshop(() => expect(page.state?.skillWorkshopLoaded).toBe(true)); expect(callsFor(request, "skills.proposals.list")).toHaveLength(1); }); @@ -246,7 +250,9 @@ describe("SkillWorkshopPage lifecycle", () => { page.context = createContext(request); document.body.append(page); await page.updateComplete; - await vi.waitFor(() => expect(page.state?.skillWorkshopError).toContain("gateway offline")); + await waitForSkillWorkshop(() => + expect(page.state?.skillWorkshopError).toContain("gateway offline"), + ); page.requestUpdate(); await page.updateComplete; @@ -273,7 +279,9 @@ describe("SkillWorkshopPage lifecycle", () => { await page.updateComplete; page.requestUpdate(); await page.updateComplete; - await vi.waitFor(() => expect(callsFor(request, "skills.proposals.list")).toHaveLength(1)); + await waitForSkillWorkshop(() => + expect(callsFor(request, "skills.proposals.list")).toHaveLength(1), + ); const loadingState = page.state; gatewayListener?.({ ...context.gateway.snapshot, connected: false }); @@ -337,7 +345,7 @@ describe("SkillWorkshopPage lifecycle", () => { await page.updateComplete; const revision = page.handleRevisionRequest("revise it", proposal, "research"); - await vi.waitFor(() => expect(oldSessions.list).toHaveBeenCalledTimes(1)); + await waitForSkillWorkshop(() => expect(oldSessions.list).toHaveBeenCalledTimes(1)); const newContext = createContext(vi.fn(async () => ({}))); page.context = newContext; @@ -391,14 +399,18 @@ describe("SkillWorkshopPage lifecycle", () => { page.context = createContext(oldRequest); document.body.append(page); await page.updateComplete; - await vi.waitFor(() => expect(callsFor(oldRequest, "skills.proposals.list")).toHaveLength(1)); - await vi.waitFor(() => + await waitForSkillWorkshop(() => + expect(callsFor(oldRequest, "skills.proposals.list")).toHaveLength(1), + ); + await waitForSkillWorkshop(() => expect(callsFor(oldRequest, "skills.proposals.historyStatus")).toHaveLength(1), ); - await vi.waitFor(() => expect(page.state?.skillWorkshopHistoryScan.loaded).toBe(true)); + await waitForSkillWorkshop(() => + expect(page.state?.skillWorkshopHistoryScan.loaded).toBe(true), + ); page.querySelector(".sw-history__action button")?.click(); - await vi.waitFor(() => + await waitForSkillWorkshop(() => expect(oldRequest).toHaveBeenCalledWith("skills.proposals.historyScan", { agentId: "research", direction: "older", @@ -457,10 +469,12 @@ describe("SkillWorkshopPage lifecycle", () => { page.context = createContext(firstRequest); document.body.append(page); await page.updateComplete; - await vi.waitFor(() => expect(page.state?.skillWorkshopHistoryScan.loaded).toBe(true)); + await waitForSkillWorkshop(() => + expect(page.state?.skillWorkshopHistoryScan.loaded).toBe(true), + ); page.querySelector(".sw-history__action button")?.click(); - await vi.waitFor(() => + await waitForSkillWorkshop(() => expect(firstRequest).toHaveBeenCalledWith("skills.proposals.historyScan", { agentId: "research", direction: "older", @@ -491,14 +505,14 @@ describe("SkillWorkshopPage lifecycle", () => { page.context = createContext(returnedRequest); page.requestUpdate(); await page.updateComplete; - await vi.waitFor(() => + await waitForSkillWorkshop(() => expect(callsFor(returnedRequest, "skills.proposals.historyStatus")).toHaveLength(1), ); scan.resolve({ ...scanStatus, hasScanned: true, reviewedSessions: 8 }); await Promise.resolve(); firstReturnedStatus.resolve(scanStatus); - await vi.waitFor(() => + await waitForSkillWorkshop(() => expect(callsFor(returnedRequest, "skills.proposals.historyStatus")).toHaveLength(2), ); expect(page.state?.skillWorkshopHistoryScan.result?.reviewedSessions).toBe(8); @@ -533,24 +547,30 @@ describe("SkillWorkshopPage lifecycle", () => { page.context = createContext(request); document.body.append(page); await page.updateComplete; - await vi.waitFor(() => expect(callsFor(request, "skills.proposals.list")).toHaveLength(1)); - await vi.waitFor(() => + await waitForSkillWorkshop(() => + expect(callsFor(request, "skills.proposals.list")).toHaveLength(1), + ); + await waitForSkillWorkshop(() => expect(callsFor(request, "skills.proposals.historyStatus")).toHaveLength(1), ); - await vi.waitFor(() => expect(page.state?.skillWorkshopHistoryScan.loaded).toBe(true)); + await waitForSkillWorkshop(() => + expect(page.state?.skillWorkshopHistoryScan.loaded).toBe(true), + ); page.querySelector(".sw-history__action button")?.click(); - await vi.waitFor(() => + await waitForSkillWorkshop(() => expect(request).toHaveBeenCalledWith("skills.proposals.historyScan", { agentId: "research", direction: "older", }), ); - await vi.waitFor(() => + await waitForSkillWorkshop(() => expect(callsFor(request, "skills.proposals.historyStatus")).toHaveLength(2), ); - await vi.waitFor(() => expect(callsFor(request, "skills.proposals.list")).toHaveLength(2)); + await waitForSkillWorkshop(() => + expect(callsFor(request, "skills.proposals.list")).toHaveLength(2), + ); expect(page.state?.skillWorkshopHistoryScan.error).toBe("late review failure"); }); }); @@ -604,13 +624,13 @@ describe("SkillWorkshopPage self-learning toggle", () => { expect(button).not.toBeNull(); button?.click(); - await vi.waitFor(() => + await waitForSkillWorkshop(() => expect(patch).toHaveBeenCalledWith({ raw: { skills: { workshop: { autonomous: { enabled: true } } } }, note: "Enable Skill Workshop self-learning", }), ); - await vi.waitFor(() => expect(runtimeConfig.refresh).toHaveBeenCalledTimes(1)); + await waitForSkillWorkshop(() => expect(runtimeConfig.refresh).toHaveBeenCalledTimes(1)); }); it("refreshes a stale config snapshot and retries the self-learning toggle", async () => { @@ -640,8 +660,8 @@ describe("SkillWorkshopPage self-learning toggle", () => { page.querySelector(".sw-empty-state__selflearn button")?.click(); - await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledTimes(2)); - await vi.waitFor(() => expect(runtimeConfig.refresh).toHaveBeenCalledTimes(2)); + await waitForSkillWorkshop(() => expect(runtimeConfig.patch).toHaveBeenCalledTimes(2)); + await waitForSkillWorkshop(() => expect(runtimeConfig.refresh).toHaveBeenCalledTimes(2)); await page.updateComplete; expect(page.querySelector(".sw-error")).toBeNull(); expect( @@ -658,7 +678,7 @@ describe("SkillWorkshopPage self-learning toggle", () => { await page.updateComplete; page.querySelector(".sw-empty-state__selflearn button")?.click(); - await vi.waitFor(() => + await waitForSkillWorkshop(() => expect(page.querySelector(".sw-error")?.textContent).toContain( "Could not update the self-learning setting.", ),