test(app): check the Updating messages toast the same way on every run

The previous repair skipped the no-toast assertion whenever the daemon
took 300ms or more to answer, so a slow run proved nothing. The toast is
correct once a check has gone unanswered for the quiet window, so the
spec now always asserts that the toast did not appear sooner than that
after the question was sent, timed in the page. The delayed-answer path
gets its own test that holds the daemon's answer.
This commit is contained in:
Mohamed Boudra
2026-11-09 16:55:16 +01:00
parent 33c8164015
commit 6d6d78aa82
3 changed files with 103 additions and 86 deletions
@@ -11,6 +11,7 @@ import {
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import {
answerFreshnessCheck,
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectResumeAnsweredWithoutFetch,
@@ -21,6 +22,7 @@ import {
restoreUnchangedViewedTimeline,
restoreViewedTimelineHoldingCatchUp,
restoreViewedTimelineWithHeldResponse,
returnWithFreshnessCheckUnanswered,
waitForResumeAnswer,
reopenAppWatchingForUpdatingToast,
watchForUpdatingToast,
@@ -63,7 +65,7 @@ test.describe("Agent timeline resume", () => {
await test.step("back from the background", async () => {
const requests = rememberTimelineRequestCounts(gate);
const expectNoUpdatingToast = await watchForUpdatingToast(page, gate, requests);
const expectNoUpdatingToast = await watchForUpdatingToast(page);
await hideTabAndReturn(page);
await waitForResumeAnswer(gate, requests);
@@ -75,7 +77,7 @@ test.describe("Agent timeline resume", () => {
await test.step("back from a dropped connection", async () => {
const requests = rememberTimelineRequestCounts(gate);
const expectNoUpdatingToast = await watchForUpdatingToast(page, gate, requests);
const expectNoUpdatingToast = await watchForUpdatingToast(page);
await disconnectViewedTimeline(page, gate);
await restoreUnchangedViewedTimeline(page, gate);
@@ -88,7 +90,7 @@ test.describe("Agent timeline resume", () => {
await test.step("back from reopening the app", async () => {
const requests = rememberTimelineRequestCounts(gate);
const expectNoUpdatingToast = await reopenAppWatchingForUpdatingToast(page, gate, requests);
const expectNoUpdatingToast = await reopenAppWatchingForUpdatingToast(page);
await waitForResumeAnswer(gate, requests);
await expectTimelinePromptVisible(page, agent.newestPrompt);
@@ -101,6 +103,25 @@ test.describe("Agent timeline resume", () => {
}
});
test("a check the daemon has not answered reports the update until it does", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requests = rememberTimelineRequestCounts(gate);
await returnWithFreshnessCheckUnanswered(page, gate);
await answerFreshnessCheck(page, gate);
expectResumeAnsweredWithoutFetch(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
}
});
test("returning to a changed chat reports the update and shows the new messages", async ({
page,
}) => {
@@ -328,10 +328,8 @@ export async function installDaemonWebSocketGate(page: Page, options: { port?: n
};
const clientRequestCounts = new Map<string, number>();
const clientRequests = new Map<string, ClientRequest[]>();
const clientRequestTimes = new Map<string, number[]>();
const timelineRequestCounts = new Map<string, number>();
const serverMessageCounts = new Map<string, number>();
const serverMessageTimes = new Map<string, number[]>();
const agentStreamEventCounts = new Map<string, number>();
const agentStreamItemCounts = new Map<string, number>();
const serverMessageWaiters = new Set<() => void>();
@@ -365,13 +363,8 @@ export async function installDaemonWebSocketGate(page: Page, options: { port?: n
const messageType = typeof message?.type === "string" ? message.type : null;
const itemType = readAgentStreamItemType(message);
const eventType = readAgentStreamEventType(message);
if (messageType) {
if (messageType)
serverMessageCounts.set(messageType, (serverMessageCounts.get(messageType) ?? 0) + 1);
serverMessageTimes.set(messageType, [
...(serverMessageTimes.get(messageType) ?? []),
Date.now(),
]);
}
if (itemType)
agentStreamItemCounts.set(itemType, (agentStreamItemCounts.get(itemType) ?? 0) + 1);
if (eventType)
@@ -488,10 +481,6 @@ export async function installDaemonWebSocketGate(page: Page, options: { port?: n
const requests = clientRequests.get(request.type) ?? [];
requests.push(request);
clientRequests.set(request.type, requests);
clientRequestTimes.set(request.type, [
...(clientRequestTimes.get(request.type) ?? []),
Date.now(),
]);
}
if (request?.type === heldClientRequestType) {
heldClientRequest = { server, message };
@@ -862,17 +851,6 @@ export async function installDaemonWebSocketGate(page: Page, options: { port?: n
getClientRequestCount(type: string): number {
return clientRequestCounts.get(type) ?? 0;
},
/** The longest the daemon took to answer a request of this type since `fromIndex`, in ms. */
getSlowestAnswerMs(requestType: string, responseType: string, fromIndex = 0): number {
const answers = serverMessageTimes.get(responseType) ?? [];
const latencies = (clientRequestTimes.get(requestType) ?? [])
.slice(fromIndex)
.flatMap((askedAt) => {
const answeredAt = answers.find((at) => at >= askedAt);
return answeredAt === undefined ? [] : [answeredAt - askedAt];
});
return Math.max(0, ...latencies);
},
getClientRequests(type: string): ReadonlyArray<ClientRequest> {
return [...(clientRequests.get(type) ?? [])];
},
@@ -14,11 +14,11 @@ const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response";
const TIMELINE_REQUEST = "fetch_agent_timeline_request";
const TIMELINE_RESPONSE = "fetch_agent_timeline_response";
/**
* A check unanswered after `FRESHNESS_CHECK_QUIET_MS` (500) reports Updating messages by design, and
* a loaded runner can take that long. The gate sees the answer before the app does, so this stops
* short of 500.
* `FRESHNESS_CHECK_QUIET_MS` is 500: a check unanswered that long reports Updating messages by
* design, however long the daemon takes. The app starts that wait just before it sends the question
* this helper times, so the bound stops short of 500.
*/
const SLOW_ANSWER_MS = 300;
const QUIET_WINDOW_FLOOR_MS = 300;
export interface TimelineRequestCounts {
agentId?: string;
@@ -83,70 +83,88 @@ export async function waitForResumeAnswer(
]);
}
/** Records every appearance of the toast, including one that lasts a single commit. */
export async function watchForUpdatingToast(
page: Page,
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): Promise<() => Promise<void>> {
const watch = await page.evaluateHandle(() => {
let shown = false;
const check = () => {
shown ||= document.querySelector('[data-testid="agent-updating-toast"]') !== null;
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
check();
return {
stop() {
observer.disconnect();
return shown;
},
};
});
return async () => {
await waitForNextPaint(page);
const shown = await watch.evaluate((recorder) => recorder.stop());
await watch.dispose();
expectNoToastForPromptAnswer(shown, gate, before);
interface UpdatingToastTiming {
askedAt: number | null;
shownAt: number | null;
}
declare global {
interface Window {
__updatingToastTiming?: UpdatingToastTiming;
}
}
/** Runs in the page: times the freshness question and the toast's first commit on its own clock. */
function recordUpdatingToastTiming(): void {
const noteToast = (timing: UpdatingToastTiming) => {
if (document.querySelector('[data-testid="agent-updating-toast"]') === null) return;
timing.shownAt ??= performance.now();
};
const existing = window.__updatingToastTiming;
if (existing) {
existing.askedAt = null;
existing.shownAt = null;
noteToast(existing);
return;
}
const timing: UpdatingToastTiming = { askedAt: null, shownAt: null };
window.__updatingToastTiming = timing;
const send = WebSocket.prototype.send;
WebSocket.prototype.send = function sendRecordingQuestion(data) {
if (
typeof data === "string" &&
data.includes("agent.timeline.set_subscription.request") &&
data.includes('"agentIds":["')
) {
timing.askedAt ??= performance.now();
}
return send.call(this, data);
};
new MutationObserver(() => noteToast(timing)).observe(document, {
childList: true,
subtree: true,
});
noteToast(timing);
}
/** A toast may only follow an unanswered question; nothing about the daemon's speed changes that. */
async function expectToastWaitedForTheDaemon(page: Page): Promise<void> {
await waitForNextPaint(page);
const showedTooSoon = await page.evaluate((floorMs) => {
const { askedAt, shownAt } = window.__updatingToastTiming as UpdatingToastTiming;
if (shownAt === null) return false;
return askedAt === null || shownAt - askedAt < floorMs;
}, QUIET_WINDOW_FLOOR_MS);
expect(showedTooSoon, "the Updating messages toast did not wait for the daemon").toBe(false);
}
/** Records every appearance of the toast, including one that lasts a single commit. */
export async function watchForUpdatingToast(page: Page): Promise<() => Promise<void>> {
await page.evaluate(recordUpdatingToastTiming);
return () => expectToastWaitedForTheDaemon(page);
}
/** Reopens the app on this chat, recording the toast from the first paint of the new page. */
export async function reopenAppWatchingForUpdatingToast(
page: Page,
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): Promise<() => Promise<void>> {
await page.addInitScript(() => {
const recorder = window as Window & { updatingToastShown?: boolean };
new MutationObserver(() => {
recorder.updatingToastShown ||=
document.querySelector('[data-testid="agent-updating-toast"]') !== null;
}).observe(document, { childList: true, subtree: true });
});
export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise<void>> {
await page.addInitScript(recordUpdatingToastTiming);
await page.reload();
return async () => {
await waitForNextPaint(page);
const shown = await page.evaluate(
() => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true,
);
expectNoToastForPromptAnswer(shown, gate, before);
};
return () => expectToastWaitedForTheDaemon(page);
}
function expectNoToastForPromptAnswer(
shown: boolean,
/** Returns to the chat with the daemon's answer held, past the point the toast reports it. */
export async function returnWithFreshnessCheckUnanswered(
page: Page,
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
const slowest = gate.getSlowestAnswerMs(
SUBSCRIPTION_REQUEST,
SUBSCRIPTION_RESPONSE,
before.subscriptions,
);
if (slowest >= SLOW_ANSWER_MS) return;
expect(shown, "the Updating messages toast appeared").toBe(false);
): Promise<void> {
gate.holdNextClientRequest(SUBSCRIPTION_REQUEST);
await hideTabAndReturn(page);
await gate.waitForHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
}
export async function answerFreshnessCheck(page: Page, gate: DaemonWebSocketGate): Promise<void> {
gate.releaseHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
async function waitForNextPaint(page: Page): Promise<void> {