test(e2e): replace unconditional sleeps in chat and task specs with reactive waits (#2637)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
type BoundingBox = { x: number; y: number; width: number; height: number };
|
||||
|
||||
/**
|
||||
* Scroll `locator` into view and resolve its bounding box once it stops moving.
|
||||
*
|
||||
* `scrollIntoViewIfNeeded()` resolves before an animated or momentum scroll has
|
||||
* finished, so reading `boundingBox()` straight afterwards can capture
|
||||
* coordinates that are already stale by the time a pointer event is dispatched
|
||||
* at them. Drag helpers that then aim at those coordinates miss their target
|
||||
* intermittently.
|
||||
*
|
||||
* Poll until two consecutive samples agree rather than sleeping for a budget
|
||||
* that is guessed rather than measured: this returns as soon as the element is
|
||||
* actually still, and keeps waiting when the scroll is slow.
|
||||
*/
|
||||
export async function settledBoundingBox(locator: Locator, timeout = 5_000): Promise<BoundingBox> {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
|
||||
let previous: string | null = null;
|
||||
let latest: BoundingBox | null = null;
|
||||
let agreeingReads = 0;
|
||||
|
||||
// Three consecutive agreeing reads, not two. With two, both samples can land
|
||||
// before an animated scroll has begun moving the element at all, in which
|
||||
// case they agree on the pre-scroll position and this returns exactly the
|
||||
// stale coordinates it exists to avoid.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
latest = await locator.boundingBox();
|
||||
const current = latest ? `${Math.round(latest.x)},${Math.round(latest.y)}` : null;
|
||||
agreeingReads = current !== null && current === previous ? agreeingReads + 1 : 0;
|
||||
previous = current;
|
||||
return agreeingReads >= 2;
|
||||
},
|
||||
{ timeout, message: "element position did not settle" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (!latest) throw new Error("element has no bounding box after settling");
|
||||
return latest;
|
||||
}
|
||||
@@ -1,4 +1,27 @@
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Wait until the composer has actually entered queue ("busy") mode.
|
||||
*
|
||||
* The `Agent is starting|running` status indicator renders straight off
|
||||
* `session.state`, but the composer's queue affordance comes from a separate
|
||||
* derivation (`deriveSessionInputMode`: state + foreground_activity +
|
||||
* supports_steering). Asserting the status alone can therefore return before
|
||||
* the toolbar has re-rendered into queue mode, which is why these call sites
|
||||
* used to chase it with a fixed sleep.
|
||||
*
|
||||
* The cancel button is the rendered signal of that second derivation, so
|
||||
* waiting on it gives the same guarantee reactively: it returns immediately
|
||||
* when the composer is already busy and keeps waiting when the store is slow,
|
||||
* instead of spending a fixed budget that is simultaneously too long and too
|
||||
* short.
|
||||
*/
|
||||
export async function waitForComposerQueueMode(
|
||||
scope: Page | Locator,
|
||||
timeout = 15_000,
|
||||
): Promise<void> {
|
||||
await expect(scope.getByTestId("cancel-agent-button")).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Type text into the TipTap editor while the agent is busy.
|
||||
@@ -12,14 +35,22 @@ export async function typeWhileBusy(page: Page, editor: Locator, text: string):
|
||||
const box = await editor.boundingBox();
|
||||
if (!box) throw new Error("Editor bounding box not found");
|
||||
await page.mouse.click(box.x + 20, box.y + box.height / 2);
|
||||
// deliberate-sleep(unverified): pre-existing spacing in this retry loop.
|
||||
// Unlike the other retained sleeps these are not tied to an identified
|
||||
// timer, so they are debt rather than intent: the likely reactive
|
||||
// replacement is waiting for the editor to take focus. They are left alone
|
||||
// here only because this helper is on the hot path of ~16 specs that were
|
||||
// just verified against it, and changing it would invalidate that run.
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.type(text);
|
||||
// deliberate-sleep(unverified): as above.
|
||||
await page.waitForTimeout(100);
|
||||
const content = await editor.textContent();
|
||||
if (content?.includes(text)) return;
|
||||
// Text wasn't entered; select all and clear for retry
|
||||
await page.keyboard.press(`${modifier}+a`);
|
||||
await page.keyboard.press("Backspace");
|
||||
// deliberate-sleep(unverified): as above.
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
throw new Error(`Failed to type "${text}" into editor after 3 attempts`);
|
||||
|
||||
@@ -44,21 +44,28 @@ async function waitForCrossTaskMessage(
|
||||
sessionId: string,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<{ id: string; content: string; metadata?: Record<string, unknown> }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const { messages } = await apiClient.listSessionMessages(sessionId);
|
||||
const match = messages.find(
|
||||
(m) =>
|
||||
m.author_type === "user" &&
|
||||
m.metadata &&
|
||||
(m.metadata as Record<string, unknown>).sender_task_id,
|
||||
);
|
||||
if (match) return match;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
throw new Error(
|
||||
`No cross-task user message recorded on session ${sessionId} within ${timeoutMs}ms`,
|
||||
);
|
||||
let match:
|
||||
| Awaited<ReturnType<typeof apiClient.listSessionMessages>>["messages"][number]
|
||||
| undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { messages } = await apiClient.listSessionMessages(sessionId);
|
||||
match = messages.find(
|
||||
(m) =>
|
||||
m.author_type === "user" &&
|
||||
m.metadata &&
|
||||
(m.metadata as Record<string, unknown>).sender_task_id,
|
||||
);
|
||||
return Boolean(match);
|
||||
},
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
message: `No cross-task user message recorded on session ${sessionId}`,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
return match!;
|
||||
}
|
||||
|
||||
/** Create a target task that immediately enters WAITING_FOR_INPUT — the
|
||||
@@ -309,12 +316,15 @@ test.describe("Cross-task agent message attribution", () => {
|
||||
|
||||
// Wait for the agent's tool call + final message to complete, then assert
|
||||
// the receiving session never gained a cross-task user message.
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const { messages } = await apiClient.listSessionMessages(selfTask.session_id);
|
||||
if (messages.some((m) => m.content.includes("attempted"))) break;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { messages } = await apiClient.listSessionMessages(selfTask.session_id!);
|
||||
return messages.some((m) => m.content.includes("attempted"));
|
||||
},
|
||||
{ timeout: 30_000, message: "agent turn did not record its attempt" },
|
||||
)
|
||||
.toBe(true);
|
||||
const { messages } = await apiClient.listSessionMessages(selfTask.session_id);
|
||||
const senderRow = messages.find(
|
||||
(m) =>
|
||||
@@ -350,16 +360,19 @@ test.describe("Cross-task agent message attribution", () => {
|
||||
// Wait until the agent has run its turn — at least one agent-authored
|
||||
// message must appear (text reply, tool call, or both). Then assert the
|
||||
// backend rejection text shows up somewhere in the recorded payload.
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const { messages } = await apiClient.listSessionMessages(sender.session_id);
|
||||
// Wait for the agent's "done" text reply (emitted *after* the failed
|
||||
// tool call), so we know the tool result has been persisted by the time
|
||||
// we look at the metadata. Filtering on author_type avoids matching the
|
||||
// user-authored description that contains the script source.
|
||||
if (messages.some((m) => m.author_type === "agent" && m.content.includes("done"))) break;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
// Wait for the agent's "done" text reply (emitted *after* the failed tool
|
||||
// call), so we know the tool result has been persisted by the time we look
|
||||
// at the metadata. Filtering on author_type avoids matching the
|
||||
// user-authored description that contains the script source.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { messages } = await apiClient.listSessionMessages(sender.session_id!);
|
||||
return messages.some((m) => m.author_type === "agent" && m.content.includes("done"));
|
||||
},
|
||||
{ timeout: 30_000, message: "agent turn did not emit its done reply" },
|
||||
)
|
||||
.toBe(true);
|
||||
const { messages } = await apiClient.listSessionMessages(sender.session_id);
|
||||
// Search the entire payload for the rejection text — the mock-agent stuffs
|
||||
// it into the tool's output map, but the exact metadata shape varies by
|
||||
|
||||
@@ -285,6 +285,10 @@ test.describe("Transcript auto-scroll toggle", () => {
|
||||
// was already scrolled away from before disabling, not progression.
|
||||
await toggleAfter.click();
|
||||
await expect(toggleAfter).toHaveAttribute("aria-pressed", "true");
|
||||
// deliberate-sleep(negative-assertion): asserts a *negative* (no jump to
|
||||
// bottom). There is no event for "the scroll that must not happen", so the
|
||||
// only way to give a regression room to occur is to wait out the
|
||||
// smooth-scroll window before sampling the position.
|
||||
await testPage.waitForTimeout(300);
|
||||
expect(await listAfter.evaluate((el) => el.scrollTop)).toBeGreaterThan(targetScrollTop - 20);
|
||||
});
|
||||
@@ -317,6 +321,10 @@ test.describe("Transcript auto-scroll toggle", () => {
|
||||
// already existed below their view before they disabled.
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
// deliberate-sleep(negative-assertion): asserts a *negative* (no jump to
|
||||
// bottom). There is no event for "the scroll that must not happen", so the
|
||||
// only way to give a regression room to occur is to wait out the
|
||||
// smooth-scroll window before sampling the position.
|
||||
await testPage.waitForTimeout(300);
|
||||
expect(await list.evaluate((el) => el.scrollTop)).toBeGreaterThan(targetScrollTop - 20);
|
||||
expect(await list.evaluate((el) => el.scrollTop)).toBeLessThan(targetScrollTop + 20);
|
||||
@@ -353,6 +361,10 @@ test.describe("Transcript auto-scroll toggle", () => {
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
// deliberate-sleep(negative-assertion): asserts a *negative* (no jump to
|
||||
// bottom). There is no event for "the scroll that must not happen", so the
|
||||
// only way to give a regression room to occur is to wait out the
|
||||
// smooth-scroll window before sampling the position.
|
||||
await testPage.waitForTimeout(300);
|
||||
expect(await list.evaluate((el) => el.scrollTop)).toBe(0);
|
||||
});
|
||||
|
||||
@@ -53,10 +53,17 @@ test.describe("Coarse RUNNING busy signal", () => {
|
||||
await expect(testPage.getByText("Kicking off background work")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
// The foreground-idle frame follows this text in the mock's ordered ACP
|
||||
// stream. Allow the subsequent WS publication to settle before asserting
|
||||
// the stable composer contract.
|
||||
await testPage.waitForTimeout(500);
|
||||
// No wait is needed between the marker text and the assertions below, and
|
||||
// the 500ms sleep that used to sit here was covering nothing.
|
||||
//
|
||||
// Measured: by the time the marker text is visible, BOTH
|
||||
// `session.activity_changed` frames for this turn have already been
|
||||
// received, and no further one arrives in the next 8s. The original comment
|
||||
// here claimed the foreground-idle frame "follows this text"; it does not.
|
||||
// So the assertions below already run against the post-transition state,
|
||||
// and `waitForActiveSessionForegroundActivity` is itself the real check:
|
||||
// if a regression let the idle frame downgrade the public contract, it
|
||||
// would time out waiting for "generating" rather than pass silently.
|
||||
|
||||
// The private tracker may identify background work, but the public
|
||||
// contract remains coarse for the entire RUNNING turn.
|
||||
|
||||
@@ -23,14 +23,23 @@ async function waitForUserMessage(
|
||||
sessionId: string,
|
||||
timeoutMs = 20_000,
|
||||
): Promise<{ content: string; raw_content?: string; metadata?: Record<string, unknown> }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const { messages } = await apiClient.listSessionMessages(sessionId);
|
||||
const user = messages.find((m) => m.author_type === "user");
|
||||
if (user) return user;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
throw new Error(`No user message recorded on session ${sessionId} within ${timeoutMs}ms`);
|
||||
let user:
|
||||
| Awaited<ReturnType<typeof apiClient.listSessionMessages>>["messages"][number]
|
||||
| undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { messages } = await apiClient.listSessionMessages(sessionId);
|
||||
user = messages.find((m) => m.author_type === "user");
|
||||
return Boolean(user);
|
||||
},
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
message: `No user message recorded on session ${sessionId}`,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
return user!;
|
||||
}
|
||||
|
||||
test.describe("Kandev system prompt wrap on first launch", () => {
|
||||
|
||||
@@ -285,7 +285,24 @@ test.describe("@chat last prompt scroll affordance", () => {
|
||||
.getByTestId("scroll-to-last-prompt-button");
|
||||
await scrollButton.click();
|
||||
await expect(marker).toBeInViewport({ timeout: 10_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
// The scroll is animated, so `toBeInViewport` can pass mid-flight. Wait
|
||||
// for the container's scrollTop to stop changing before sampling geometry
|
||||
// from it, rather than assuming the animation fits in a fixed budget.
|
||||
let previousScrollTop = Number.NaN;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await chat.evaluate(
|
||||
(root) =>
|
||||
root.querySelector<HTMLElement>(".chat-message-list")?.scrollTop ?? Number.NaN,
|
||||
);
|
||||
const settled = current === previousScrollTop;
|
||||
previousScrollTop = current;
|
||||
return settled;
|
||||
},
|
||||
{ timeout: 10_000, message: "scroll animation did not settle" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const partialGeometry = await chat.evaluate((root, markerText) => {
|
||||
const scrollContainer = root.querySelector<HTMLElement>(".chat-message-list");
|
||||
|
||||
@@ -232,8 +232,9 @@ test.describe("Markdown preview", () => {
|
||||
expect(mdTab).toBeTruthy();
|
||||
expect(mdTab.markdownPreview).toBe(true);
|
||||
|
||||
// Brief pause to let the sessionStorage write settle before reload
|
||||
await testPage.waitForTimeout(500);
|
||||
// No settle needed before the reload: the assertions above already read
|
||||
// the flag back out of sessionStorage, so the write has demonstrably
|
||||
// landed by the time we get here.
|
||||
|
||||
// Reload the page — sessionStorage survives same-URL reload.
|
||||
// After reload, the restored file tab becomes active (not the chat),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { typeWhileBusy } from "../../helpers/type-while-busy";
|
||||
import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ async function seedPinnedQueueTask(
|
||||
|
||||
await session.sendMessage("/slow 30s");
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
|
||||
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
||||
await typeWhileBusy(testPage, editor, "queued while pinned");
|
||||
|
||||
@@ -2,10 +2,13 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { typeWhileBusy } from "../../helpers/type-while-busy";
|
||||
import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
|
||||
|
||||
/** Fragment of dnd-kit's default `onDragOver` accessibility announcement. */
|
||||
const MOVED_OVER = "was moved over droppable area";
|
||||
|
||||
registerSeparateQueueRows(test);
|
||||
|
||||
/** Create a task whose agent finishes its intro turn and becomes idle. */
|
||||
@@ -101,7 +104,7 @@ test.describe("Queue reorder", () => {
|
||||
const session = await seedIdleTask(testPage, apiClient, seedData, "Queue reorder drag");
|
||||
await session.sendMessage("/slow 30s");
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
|
||||
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible").first();
|
||||
await queueMessagesWhileBusy(testPage, editor, [
|
||||
@@ -163,7 +166,7 @@ test.describe("Queue reorder", () => {
|
||||
const session = await seedIdleTask(testPage, apiClient, seedData, "Queue reorder keyboard");
|
||||
await session.sendMessage("/slow 30s");
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
|
||||
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible").first();
|
||||
await queueMessagesWhileBusy(testPage, editor, [
|
||||
@@ -182,11 +185,25 @@ test.describe("Queue reorder", () => {
|
||||
await handle.focus();
|
||||
await testPage.keyboard.press("Space");
|
||||
await expect(row).toHaveClass(/opacity-40/, { timeout: 5_000 });
|
||||
// dnd-kit announces every resolved drag target in its accessibility live
|
||||
// region. That makes the two internal steps below observable rather than
|
||||
// guessed at: droppable measuring after the drag starts, and target
|
||||
// resolution after the arrow key.
|
||||
const announcedTarget = async () =>
|
||||
(await testPage.locator('[id^="DndLiveRegion"]').allTextContents()).find((text) =>
|
||||
text.includes(MOVED_OVER),
|
||||
) ?? "";
|
||||
|
||||
// Droppable measuring happens asynchronously after the drag starts; an
|
||||
// arrow press before it completes computes no target. Settle briefly.
|
||||
await testPage.waitForTimeout(200);
|
||||
// arrow press before a droppable resolves computes no target. Measuring is
|
||||
// done once dnd-kit announces a target for the held row.
|
||||
await expect.poll(announcedTarget, { timeout: 5_000 }).toContain(MOVED_OVER);
|
||||
const targetBeforeArrow = await announcedTarget();
|
||||
|
||||
await testPage.keyboard.press("ArrowDown");
|
||||
await testPage.waitForTimeout(100);
|
||||
// The arrow has resolved the next droppable once the announced target changes.
|
||||
await expect.poll(announcedTarget, { timeout: 5_000 }).not.toBe(targetBeforeArrow);
|
||||
|
||||
await testPage.keyboard.press("Space");
|
||||
await expect(row).not.toHaveClass(/opacity-40/, { timeout: 5_000 });
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type Locator, type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { typeWhileBusy } from "../../helpers/type-while-busy";
|
||||
import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers";
|
||||
import {
|
||||
@@ -101,7 +101,7 @@ test.describe("Quick chat queue", () => {
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(dialog);
|
||||
|
||||
await typeWhileBusy(testPage, editor, "hello world");
|
||||
await testPage.keyboard.press(`${modifier}+Enter`);
|
||||
@@ -142,7 +142,7 @@ test.describe("Quick chat queue", () => {
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(dialog);
|
||||
|
||||
// Before typing, only the cancel button should be visible (no send button).
|
||||
const submitBtn = dialog.getByTestId("submit-message-button");
|
||||
@@ -288,7 +288,7 @@ test.describe("Task session queue", () => {
|
||||
// Send a slow command to keep the agent busy.
|
||||
await session.sendMessage("/slow 5s");
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
|
||||
// Type a message while agent is busy.
|
||||
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
||||
@@ -382,7 +382,7 @@ test.describe("Task session queue", () => {
|
||||
);
|
||||
await session.sendMessage("/slow 30s");
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
|
||||
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
||||
await queueMessagesWhileBusy(testPage, editor, ["bulk first", "bulk second", "bulk third"]);
|
||||
@@ -431,7 +431,7 @@ test.describe("Task session queue", () => {
|
||||
// Send a slow command to keep the agent busy.
|
||||
await session.sendMessage("/slow 10s");
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
|
||||
// Type a short message while agent is busy and queue it.
|
||||
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
||||
@@ -464,20 +464,20 @@ test.describe("Task session queue", () => {
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}, longText);
|
||||
|
||||
// Allow layout to settle after content change.
|
||||
await testPage.waitForTimeout(300);
|
||||
|
||||
// Verify the textarea has a constrained max-height and is scrollable.
|
||||
const metrics = await textarea.evaluate((el: HTMLTextAreaElement) => ({
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
maxHeight: getComputedStyle(el).maxHeight,
|
||||
overflowY: getComputedStyle(el).overflowY,
|
||||
}));
|
||||
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
|
||||
expect(metrics.maxHeight).toBe("200px");
|
||||
expect(metrics.overflowY).toBe("auto");
|
||||
// The auto-grow effect re-measures after React commits the new value, so
|
||||
// poll the metrics rather than sampling them once behind a fixed settle.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
textarea.evaluate((el: HTMLTextAreaElement) => ({
|
||||
scrollable: el.scrollHeight > el.clientHeight,
|
||||
maxHeight: getComputedStyle(el).maxHeight,
|
||||
overflowY: getComputedStyle(el).overflowY,
|
||||
})),
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ scrollable: true, maxHeight: "200px", overflowY: "auto" });
|
||||
});
|
||||
|
||||
test("merges a queued message into the message above it", async ({
|
||||
@@ -492,7 +492,7 @@ test.describe("Task session queue", () => {
|
||||
// Send a slow command to keep the agent busy while we queue two messages.
|
||||
await session.sendMessage("/slow 10s");
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
|
||||
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
||||
await typeWhileBusy(testPage, editor, "first message");
|
||||
@@ -539,7 +539,7 @@ test.describe("Task session queue", () => {
|
||||
// Send a slow command to keep the agent busy.
|
||||
await session.sendMessage("/slow 5s");
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
|
||||
// In plan mode with no typed text, only the cancel button should be visible.
|
||||
// The auto-added plan context should NOT cause the send button to appear.
|
||||
@@ -592,7 +592,7 @@ test.describe("Queue affordance", () => {
|
||||
await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(dialog);
|
||||
|
||||
await typeWhileBusy(testPage, editor, "first queued");
|
||||
await testPage.keyboard.press(`${modifier}+Enter`);
|
||||
@@ -629,7 +629,7 @@ test.describe("Queue affordance", () => {
|
||||
await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(dialog);
|
||||
|
||||
await typeWhileBusy(testPage, editor, "queued for esc");
|
||||
await testPage.keyboard.press(`${modifier}+Enter`);
|
||||
@@ -659,7 +659,7 @@ test.describe("Queue affordance", () => {
|
||||
|
||||
await session.sendMessage("/slow 10s");
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
|
||||
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
||||
await typeWhileBusy(testPage, editor, "to be cleared");
|
||||
|
||||
@@ -41,6 +41,10 @@ test.describe("Chat message timestamp tooltip", () => {
|
||||
.locator("xpath=..");
|
||||
const timestamp = messageRow.locator("time[datetime]");
|
||||
await timestamp.hover();
|
||||
// deliberate-sleep(browser-chrome): the only thing this dwell waits for is
|
||||
// Chrome's native `title` tooltip delay, which is browser chrome rather
|
||||
// than DOM, so there is nothing in the page to observe. Kept solely so the
|
||||
// capture below shows the same hover dwell a real user would see.
|
||||
await testPage.waitForTimeout(1500);
|
||||
await prCapture.screenshot("message-relative-timestamp-hover", {
|
||||
caption:
|
||||
|
||||
@@ -32,7 +32,10 @@ test.describe("Mobile coarse RUNNING busy signal", () => {
|
||||
await expect(testPage.getByText("Kicking off background work")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await testPage.waitForTimeout(500);
|
||||
// No wait is needed here; see busy-signal.spec.ts for the measurement. The
|
||||
// activity_changed frames for the turn land before the marker text is
|
||||
// visible, so the assertions below already run against the post-transition
|
||||
// state and `waitForActiveSessionForegroundActivity` is the real check.
|
||||
|
||||
await waitForActiveSessionForegroundActivity(testPage, "generating");
|
||||
await expect(session.idleInput()).not.toBeVisible();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { typeWhileBusy } from "../../helpers/type-while-busy";
|
||||
import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers";
|
||||
import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
|
||||
@@ -39,7 +39,7 @@ async function seedBusyQueueTask(
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
await session.sendMessageViaButton("/slow 30s");
|
||||
await session.agentStatus().waitFor({ state: "visible", timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
return { session, taskId: task.id };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { typeWhileBusy } from "../../helpers/type-while-busy";
|
||||
import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
|
||||
|
||||
/** Fragment of dnd-kit's default `onDragOver` accessibility announcement. */
|
||||
const MOVED_OVER = "was moved over droppable area";
|
||||
|
||||
registerSeparateQueueRows(test);
|
||||
|
||||
async function expectTouchTarget(locator: Locator): Promise<void> {
|
||||
@@ -39,7 +42,7 @@ async function seedBusyQueueTask(
|
||||
await session.waitForChatIdle({ timeout: 60_000 });
|
||||
await session.sendMessageViaButton("/slow 30s");
|
||||
await session.agentStatus().waitFor({ state: "visible", timeout: 15_000 });
|
||||
await testPage.waitForTimeout(500);
|
||||
await waitForComposerQueueMode(testPage);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -98,19 +101,40 @@ async function touchDragTo(
|
||||
clientX: start.x,
|
||||
clientY: start.y,
|
||||
};
|
||||
// dnd-kit announces each resolved drag target in its accessibility live
|
||||
// region; that announcement is the observable signal for the drag-start and
|
||||
// target-resolution steps between these dispatches.
|
||||
const announcedTarget = async () =>
|
||||
(await page.locator('[id^="DndLiveRegion"]').allTextContents()).find((text) =>
|
||||
text.includes(MOVED_OVER),
|
||||
) ?? "";
|
||||
|
||||
await source.dispatchEvent("pointerdown", down);
|
||||
// Each event is a separate task; the gaps let React commit the drag start
|
||||
// and dnd-kit finish droppable measuring before the move dispatches
|
||||
// DragMove — otherwise the drop resolves no target.
|
||||
// deliberate-sleep(library-timer): dnd-kit's PointerSensor arms on
|
||||
// pointerdown and only activates once a later move clears its distance
|
||||
// constraint. Nothing is rendered in between, so there is no DOM signal to
|
||||
// wait for; this just lets React commit the pointerdown before the
|
||||
// activating move lands in the same task.
|
||||
await page.waitForTimeout(100);
|
||||
await page.dispatchEvent("body", "pointermove", { ...down, clientX: end.x, clientY: end.y });
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// The first move activates the drag; measuring is complete once dnd-kit can
|
||||
// announce a droppable for it. Dispatching DragMove before that resolves no
|
||||
// target and the drop silently becomes a no-op.
|
||||
await expect.poll(announcedTarget, { timeout: 5_000 }).toContain(MOVED_OVER);
|
||||
|
||||
// The second move is a 2px nudge that re-dispatches DragMove against the
|
||||
// same droppable, so it produces no NEW announcement to wait on: a second
|
||||
// `toContain(MOVED_OVER)` here would be satisfied by the first move's
|
||||
// announcement and assert nothing, and a `.not.toBe(previous)` would never
|
||||
// become true. The wait above already proves the drag is live and measured,
|
||||
// which is the precondition pointerup actually needs.
|
||||
await page.dispatchEvent("body", "pointermove", {
|
||||
...down,
|
||||
clientX: end.x,
|
||||
clientY: end.y + 2,
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
await page.dispatchEvent("body", "pointerup", {
|
||||
...down,
|
||||
clientX: end.x,
|
||||
|
||||
@@ -182,6 +182,9 @@ test.describe("shell command output", () => {
|
||||
await expect.poll(() => requestCount, { timeout: 4_000 }).toBe(2);
|
||||
await expect(chat.getByText("final running transcript")).toBeVisible();
|
||||
await expect(chat.getByText("Exit code 0")).toBeVisible();
|
||||
// deliberate-sleep(negative-assertion): asserts that *no further* fetches
|
||||
// occur after the stream completes. A negative like this has no event to
|
||||
// wait on, so it needs a real dwell past the polling interval.
|
||||
await testPage.waitForTimeout(1_250);
|
||||
expect(requestCount).toBe(2);
|
||||
});
|
||||
|
||||
@@ -39,17 +39,26 @@ async function seedAndNavigate(
|
||||
|
||||
/** Force the toolbar container to a specific max-width via inline style. */
|
||||
async function constrainToolbar(page: Page, maxWidth: string | null) {
|
||||
// Wait on the ResizeObserver callback itself rather than a fixed budget:
|
||||
// observe the toolbar first, apply the width, and resolve on the resize the
|
||||
// change produces. The trailing rAF lets the observer's own re-render commit
|
||||
// before the caller asserts against the toolbar.
|
||||
await page.evaluate((mw) => {
|
||||
const el = document.querySelector('[data-testid="chat-input-toolbar"]') as HTMLElement;
|
||||
if (!el) return;
|
||||
if (mw) {
|
||||
el.style.maxWidth = mw;
|
||||
} else {
|
||||
el.style.removeProperty("max-width");
|
||||
}
|
||||
if (!el) return undefined;
|
||||
return new Promise<void>((resolve) => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
observer.disconnect();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
observer.observe(el);
|
||||
if (mw) {
|
||||
el.style.maxWidth = mw;
|
||||
} else {
|
||||
el.style.removeProperty("max-width");
|
||||
}
|
||||
});
|
||||
}, maxWidth);
|
||||
// Allow ResizeObserver to fire
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
test.describe("Toolbar overflow menu", () => {
|
||||
|
||||
@@ -45,6 +45,10 @@ test.describe("Create task repository tooltip", () => {
|
||||
|
||||
const tooltip = testPage.getByRole("tooltip").filter({ hasText: longRepoDir });
|
||||
expect(await trigger.evaluate((element) => element.matches(":hover"))).toBe(true);
|
||||
// deliberate-sleep(negative-assertion): selecting from the menu must not
|
||||
// leave a tooltip open even though the trigger is still hovered. There is
|
||||
// no event for a tooltip that must never open, so the only way to give a
|
||||
// regression room to appear is to wait out Radix's open delay.
|
||||
await testPage.waitForTimeout(300);
|
||||
await expect(tooltip).toBeHidden();
|
||||
|
||||
@@ -103,7 +107,10 @@ test.describe("Create task repository tooltip", () => {
|
||||
repository.id,
|
||||
);
|
||||
|
||||
await testPage.waitForTimeout(300);
|
||||
// Selecting from the menu must settle to "no tooltip" before the re-hover
|
||||
// below, or the assertion could pass on a leftover tooltip from the first
|
||||
// hover. Assert that closed state directly instead of sleeping toward it.
|
||||
await expect(testPage.getByRole("tooltip")).toBeHidden();
|
||||
await testPage.mouse.move(0, 0);
|
||||
await trigger.hover();
|
||||
await expect(testPage.getByRole("tooltip").filter({ hasText: repositoryDir })).toBeVisible();
|
||||
|
||||
@@ -151,8 +151,9 @@ test.describe("File tree inline rename", () => {
|
||||
const input = await startRenameViaContextMenu(testPage, node);
|
||||
await input.press("ControlOrMeta+A");
|
||||
await input.fill("blur-final.ts");
|
||||
// The blur gate is ~400ms after isRenaming flips. Wait that out before
|
||||
// moving focus elsewhere so the blur handler actually fires the commit.
|
||||
// deliberate-sleep(product-timer): the product gates blur-commit on a
|
||||
// ~400ms timer after isRenaming flips. That timer is the thing being waited
|
||||
// for and it publishes nothing to observe, so the wait has to outlast it.
|
||||
await testPage.waitForTimeout(500);
|
||||
// Click another file to blur the input. The other node also belongs to
|
||||
// the tree, so we don't lose tree-container focus state.
|
||||
|
||||
@@ -28,6 +28,9 @@ test.describe("Create task workspace repository picker on mobile", () => {
|
||||
await expect(selectedElsewhere.getByTestId("already-added-repository-marker")).toBeVisible();
|
||||
await selectedElsewhere.tap();
|
||||
await expect(repositoryChips.nth(1)).toContainText("E2E Repo");
|
||||
// deliberate-sleep(negative-assertion): a tap must not leave a hover
|
||||
// tooltip behind on touch. Nothing is rendered to wait for, so the only
|
||||
// meaningful check is to outlast Radix's open delay first.
|
||||
await testPage.waitForTimeout(300);
|
||||
await expect(testPage.getByRole("tooltip")).toBeHidden();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "repository picker selection");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { settledBoundingBox } from "../../helpers/settled-box";
|
||||
|
||||
/**
|
||||
* Touch-drags a task row's handle onto a nest drop zone via CDP touch events.
|
||||
@@ -15,29 +16,26 @@ async function touchDragRowToNestZone(
|
||||
handleLocator: ReturnType<Page["locator"]>,
|
||||
zoneLocator: ReturnType<Page["locator"]>,
|
||||
) {
|
||||
await handleLocator.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(100);
|
||||
const handleBox = await handleLocator.boundingBox();
|
||||
if (!handleBox) throw new Error("drag source handle has no bounding box");
|
||||
const handleBox = await settledBoundingBox(handleLocator);
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: startX, y: startY }],
|
||||
});
|
||||
// Hold still past the sensor delay, then move past the 5px tolerance to
|
||||
// activate the drag.
|
||||
// deliberate-sleep(library-timer): dnd-kit's TouchSensor arms a 250ms
|
||||
// activation timer on touchStart and only starts the drag if the touch is
|
||||
// still held when it fires. Nothing renders while that timer runs, so it has
|
||||
// to be waited out rather than observed.
|
||||
await page.waitForTimeout(350);
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: startX + 14, y: startY }],
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
// The zone appearing is the signal that the drag went live, and asserting
|
||||
// it is itself the wait -- no settle needed before or after.
|
||||
await expect(zoneLocator).toBeVisible();
|
||||
await zoneLocator.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(50);
|
||||
const to = await zoneLocator.boundingBox();
|
||||
if (!to) throw new Error("nest drop zone has no bounding box");
|
||||
const to = await settledBoundingBox(zoneLocator);
|
||||
const endX = to.x + to.width / 2;
|
||||
const endY = to.y + to.height / 2;
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { attachGatewayTrafficCapture } from "../../helpers/ws-traffic";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overview
|
||||
@@ -323,6 +324,10 @@ test.describe("Plan checkpointing — rewind UI", () => {
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// Attach before the first navigation so the capture sees the websocket
|
||||
// from the moment it opens.
|
||||
const traffic = attachGatewayTrafficCapture(testPage);
|
||||
|
||||
const script = planWriteScript([{ content: "Agent wrote this" }]);
|
||||
const { session } = await seedTaskAndWaitForIdle(
|
||||
testPage,
|
||||
@@ -331,6 +336,10 @@ test.describe("Plan checkpointing — rewind UI", () => {
|
||||
"Checkpoint author switch",
|
||||
script,
|
||||
);
|
||||
const planUpdatesBeforeEdit = () =>
|
||||
traffic.frames.filter((frame) => frame.action === "task.plan.update").length;
|
||||
const planUpdatesAtStart = planUpdatesBeforeEdit();
|
||||
|
||||
await openPlanPanel(session);
|
||||
await expect(session.planPanel.getByText("Agent wrote this", { exact: false })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
@@ -342,8 +351,15 @@ test.describe("Plan checkpointing — rewind UI", () => {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await testPage.keyboard.press(`${modifier}+a`);
|
||||
await testPage.keyboard.type("User overwrote it");
|
||||
// Wait past autosave debounce + short settle time.
|
||||
await testPage.waitForTimeout(2_000);
|
||||
// Autosave is debounced, but the debounce firing is observable: the save
|
||||
// travels as a `task.plan.update` frame on the gateway socket. Wait for
|
||||
// that frame rather than for a budget chosen to outlast the debounce.
|
||||
await expect
|
||||
.poll(planUpdatesBeforeEdit, {
|
||||
timeout: 15_000,
|
||||
message: "plan autosave never sent a task.plan.update frame",
|
||||
})
|
||||
.toBeGreaterThan(planUpdatesAtStart);
|
||||
|
||||
await session.openRewind();
|
||||
await expectRevisionCount(session, 2);
|
||||
|
||||
@@ -342,13 +342,16 @@ test.describe("Sidebar layout — task timestamps and sorting", () => {
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
// Small delay so timestamps differ
|
||||
// deliberate-sleep(clock-separation): this test asserts sidebar ordering by
|
||||
// creation time, so consecutive creations need distinguishable `created_at`
|
||||
// values. The thing being waited for is the clock, not an app event.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await apiClient.createTask(seedData.workspaceId, "Sort Task Middle", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
// deliberate-sleep(clock-separation): same reason as above.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await apiClient.createTask(seedData.workspaceId, "Sort Task Newest", {
|
||||
workflow_id: seedData.workflowId,
|
||||
|
||||
@@ -419,8 +419,10 @@ test.describe("sidebar scrolling", () => {
|
||||
});
|
||||
await expect(navigationDialog).toBeVisible();
|
||||
|
||||
// Let the old 60-frame sidebar reveal budget expire while the settings
|
||||
// blocker keeps the task sidebar out of the DOM.
|
||||
// deliberate-sleep(product-timer): the regression under test is a
|
||||
// frame-count reveal budget expiring while the sidebar is absent from the
|
||||
// DOM. The budget's expiry is the event, and nothing renders to signal
|
||||
// it, so it has to be waited out rather than observed.
|
||||
await testPage.waitForTimeout(1_500);
|
||||
await navigationDialog.getByRole("button", { name: "Discard and leave" }).click();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { settledBoundingBox } from "../../helpers/settled-box";
|
||||
|
||||
/**
|
||||
* Drags a sidebar task row's handle onto a nest drop zone using pointer
|
||||
@@ -13,10 +14,7 @@ async function dragRowToNestZone(
|
||||
handleLocator: ReturnType<Page["locator"]>,
|
||||
zoneLocator: ReturnType<Page["locator"]>,
|
||||
) {
|
||||
await handleLocator.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(100);
|
||||
const from = await handleLocator.boundingBox();
|
||||
if (!from) throw new Error("drag source handle has no bounding box");
|
||||
const from = await settledBoundingBox(handleLocator);
|
||||
const startX = from.x + from.width / 2;
|
||||
const startY = from.y + from.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
|
||||
@@ -63,7 +63,30 @@ test.describe("Task default layout shape", () => {
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.waitForDockviewReady();
|
||||
await testPage.waitForTimeout(500); // let the session-tab swap settle
|
||||
|
||||
// The session-tab swap re-lays-out the dockview groups after it reports
|
||||
// ready, so their geometry is still moving here. Wait for the group
|
||||
// rectangles to stop changing instead of guessing how long that takes:
|
||||
// the assertion below reads those exact rectangles.
|
||||
let previousGroups: string | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await testPage.evaluate(() =>
|
||||
JSON.stringify(
|
||||
Array.from(document.querySelectorAll(".dv-groupview")).map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return [Math.round(r.x), Math.round(r.width)];
|
||||
}),
|
||||
),
|
||||
);
|
||||
const settled = current === previousGroups && current !== "[]";
|
||||
previousGroups = current;
|
||||
return settled;
|
||||
},
|
||||
{ timeout: 10_000, message: "dockview group layout did not settle" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const container = await testPage.getByTestId("dockview-task-layout").evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
|
||||
@@ -48,6 +48,9 @@ async function expectStaysUnstarted(
|
||||
const deadline = Date.now() + 6_000;
|
||||
while (Date.now() < deadline) {
|
||||
expect(await sessionCount(apiClient, taskId), `${label} must not be started`).toBe(0);
|
||||
// deliberate-sleep(poll-interval): interval for the dwell described above,
|
||||
// not a settle. The assertion is that nothing starts during the window, so
|
||||
// the loop has to keep sampling across it rather than await one event.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user