improve(tests): tighten async polling across suites (#109919)

* test: tighten signal delivery polling

* test: tighten async polling across suites

* test: use virtual time for signal ingress drain
This commit is contained in:
Peter Steinberger
2026-07-17 12:20:58 +01:00
committed by GitHub
parent ecfcaa07e6
commit 9b5eb04221
9 changed files with 285 additions and 208 deletions
@@ -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" },
@@ -30,6 +30,10 @@ const {
const SIGNAL_BASE_URL = "http://127.0.0.1:8080";
type MonitorSignalProviderOptions = NonNullable<Parameters<typeof monitorSignalProvider>[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);
});
});
@@ -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<typeof vi.fn>;
setMyCommands: ReturnType<typeof vi.fn>;
@@ -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(
@@ -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<typeof attachHarness>): Promise<void> {
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();
@@ -12,6 +12,10 @@ type OnboardingMemoryImportElement = HTMLElement & {
updateComplete: Promise<boolean>;
};
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<HTMLButtonElement>(
"[data-test-id='onboarding-memory-import-import']",
@@ -258,7 +266,7 @@ describe("OnboardingMemoryImport", () => {
.querySelector<HTMLButtonElement>("[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<HTMLButtonElement>(
"[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<HTMLButtonElement>(
"[data-test-id='onboarding-memory-import-import']",
@@ -323,7 +331,7 @@ describe("OnboardingMemoryImport", () => {
element
.querySelector<HTMLButtonElement>("[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<HTMLButtonElement>(
"[data-test-id='onboarding-memory-import-import']",
@@ -356,14 +364,14 @@ describe("OnboardingMemoryImport", () => {
.querySelector<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>(
"[data-test-id='onboarding-memory-import-import']",
@@ -401,7 +409,7 @@ describe("OnboardingMemoryImport", () => {
.querySelector<HTMLButtonElement>("[data-test-id='onboarding-memory-import-import']")
?.click();
await vi.waitFor(() =>
await waitForOnboardingMemoryImport(() =>
expect(
element.querySelector<HTMLButtonElement>(
"[data-test-id='onboarding-memory-import-continue']",
+18 -14
View File
@@ -6,6 +6,10 @@ type WidgetBridgeDeps = Parameters<typeof createWidgetBridge>[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();
});
+21 -17
View File
@@ -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<ApplicationGatewaySnapshot>) => void;
emitRetiredEvent: (event: Parameters<GatewayEventListener>[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();
@@ -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<HTMLButtonElement>("[data-test-id='memory-import-provider-button']"),
).not.toBeNull(),
@@ -175,14 +179,14 @@ describe("MemoryImportPage", () => {
page
.querySelector<HTMLButtonElement>("[data-test-id='memory-import-provider-button']")
?.click();
await vi.waitFor(() =>
await waitForMemoryImport(() =>
expect(
page.querySelector<HTMLButtonElement>("[data-test-id='memory-import-confirm']"),
).not.toBeNull(),
);
page.querySelector<HTMLButtonElement>("[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<HTMLButtonElement>("[data-test-id='memory-import-provider-button']"),
).not.toBeNull(),
@@ -227,15 +231,15 @@ describe("MemoryImportPage", () => {
page
.querySelector<HTMLButtonElement>("[data-test-id='memory-import-provider-button']")
?.click();
await vi.waitFor(() =>
await waitForMemoryImport(() =>
expect(
page.querySelector<HTMLButtonElement>("[data-test-id='memory-import-confirm']"),
).not.toBeNull(),
);
page.querySelector<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[data-test-id='memory-import-provider-button']"),
).not.toBeNull(),
@@ -281,16 +285,16 @@ describe("MemoryImportPage", () => {
page
.querySelector<HTMLButtonElement>("[data-test-id='memory-import-provider-button']")
?.click();
await vi.waitFor(() =>
await waitForMemoryImport(() =>
expect(
page.querySelector<HTMLButtonElement>("[data-test-id='memory-import-confirm']"),
).not.toBeNull(),
);
page.querySelector<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[data-test-id='memory-import-provider-button']"),
).not.toBeNull(),
@@ -315,7 +319,7 @@ describe("MemoryImportPage", () => {
page
.querySelector<HTMLButtonElement>("[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<HTMLButtonElement>("[data-test-id='memory-import-provider-button']"),
).not.toBeNull(),
@@ -377,11 +381,11 @@ describe("MemoryImportPage", () => {
page
.querySelector<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[data-test-id='memory-import-provider-button']"),
).not.toBeNull(),
@@ -438,7 +442,7 @@ describe("MemoryImportPage", () => {
page
.querySelector<HTMLButtonElement>("[data-test-id='memory-import-provider-button']")
?.click();
await vi.waitFor(() =>
await waitForMemoryImport(() =>
expect(
page.querySelector<HTMLButtonElement>("[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();
@@ -20,6 +20,10 @@ type SkillWorkshopPageTestElement = HTMLElement & {
requestUpdate: () => void;
};
function waitForSkillWorkshop(assertion: () => void) {
return vi.waitFor(assertion, { interval: 1 });
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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.",
),