test(e2e): label or convert every remaining unconditional sleep (#2650)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-13 23:55:11 +01:00
committed by GitHub
parent 009a88278c
commit bfd632bafa
40 changed files with 506 additions and 163 deletions
+11 -2
View File
@@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path";
import { BackendFixtureEnvOverrides, createScopedEnvUse } from "./backend-env";
import { E2E_DOCKER_SCOPE } from "./docker-probe";
import { dwell } from "../helpers/causal-waits";
const BACKEND_DIR = path.resolve(__dirname, "../../../../apps/backend");
const WEB_DIR = path.resolve(__dirname, "../..");
@@ -123,7 +124,11 @@ export async function waitForHealth(
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
await dwell(
HEALTH_POLL_MS,
"poll-interval",
"sampling interval for the backend health probe; the process is still starting, so there is nothing to subscribe to and the only signal is the port answering",
);
}
throw new Error(`Service did not become healthy at ${url} within ${timeoutMs}ms`);
} finally {
@@ -150,7 +155,11 @@ async function waitForPortFree(port: number, timeoutMs = 10_000): Promise<void>
sock.once("error", () => resolve(true)); // ECONNREFUSED → port is free
});
if (free) return;
await new Promise((r) => setTimeout(r, 100));
await dwell(
100,
"poll-interval",
"sampling interval while waiting for the previous backend to release its port; the OS publishes nothing when a socket is finally freed",
);
}
// Timeout expired — proceed anyway; the new process will fail-fast if the
// port is still held and waitForHealth will surface the error.
+6 -1
View File
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { dwell } from "../helpers/causal-waits";
/**
* Image used by the Docker E2E project. Built once per machine and reused.
@@ -111,7 +112,11 @@ export async function waitForScopedKandevContainersRemoved(
while (Date.now() < deadline) {
removeScopedKandevContainers(scope);
if (scopedKandevContainerIDs(scope).length === 0) return;
await new Promise<void>((resolve) => setTimeout(resolve, 100));
await dwell(
100,
"poll-interval",
"sampling interval for the container-teardown loop above; docker rm is asynchronous and the CLI reports completion only by the container list emptying",
);
}
removeScopedKandevContainers(scope);
const remaining = scopedKandevContainerIDs(scope);
+11 -2
View File
@@ -7,6 +7,7 @@ import { ApiClient } from "../helpers/api-client";
import { PrAssetCapture } from "../helpers/pr-asset-capture";
import { makeGitEnv } from "../helpers/git-helper";
import type { WorkflowStep } from "../../lib/types/http";
import { dwell } from "../helpers/causal-waits";
const DEFAULT_SIDEBAR_VIEW = {
id: "view-all-tasks",
@@ -73,7 +74,11 @@ export const test = backendFixture.extend<
lastText = await probe.text();
break;
}
await new Promise((r) => setTimeout(r, 250));
await dwell(
250,
"poll-interval",
"sampling interval for the mock-harness health probe above; the backend is still booting and there is no page, let alone a socket, to receive a ready signal on",
);
}
if (lastStatus === 404) {
throw new Error(
@@ -139,7 +144,11 @@ export const test = backendFixture.extend<
lastAgentCount = agents.length;
agentProfileId = agents[0]?.profiles[0]?.id;
if (agentProfileId) break;
await new Promise((r) => setTimeout(r, 250));
await dwell(
250,
"poll-interval",
"sampling interval for the seeded-agent-profile poll above; seeding is asynchronous backend work read back over HTTP, with no page in this fixture",
);
}
if (!agentProfileId) {
throw new Error(
+6 -1
View File
@@ -39,6 +39,7 @@ import type {
SSHTestResult,
} from "../../lib/types/http-ssh";
import { loadInterimSettingsInterlockToken } from "./interim-settings-interlock";
import { dwell } from "./causal-waits";
// --- GitHub Mock Types ---
@@ -2398,7 +2399,11 @@ export class ApiClient {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await this.integrationReportsHealthy(integration, workspaceId)) return;
await new Promise<void>((resolve) => setTimeout(resolve, 100));
await dwell(
100,
"poll-interval",
"sampling interval for the auth-health loop above; this client has no Page, and the 90s health poller updates the record without publishing anything it can subscribe to",
);
}
throw new Error(`${integration} config never reported lastOk: true within ${timeoutMs}ms`);
}
@@ -0,0 +1,94 @@
// Waiting on the dockview layout having been *saved*.
//
// Dockview persists the layout on a ~300ms debounce, to `sessionStorage` under
// `kandev.dockview.env-layout-v3.<envId>`. The debounce publishes no event, so a
// spec that reloads to prove the layout survived has nothing to wait for and
// historically slept past the debounce instead — which fails the wrong way
// round: on a loaded shard the save is late, the reload takes the old JSON, and
// the test fails claiming the layout was not preserved.
//
// The debounce leaves an artifact even though it fires no event, so wait on the
// artifact. `snapshotPersistedLayouts` before the action that changes the
// layout, `waitForPersistedLayoutChange` after it, and the reload then reads a
// save that has demonstrably happened.
//
// Every key is read rather than one session's, so callers do not need an
// environment id: a spec drives one task at a time, and any write is the write
// being waited for.
import { expect, type Page } from "@playwright/test";
/**
* Copy of `DOCKVIEW_ENV_LAYOUT_PREFIX` in `apps/web/lib/local-storage.ts`, which
* is the authoritative definition — bump both together.
*
* Importing it instead would be better, and does not work: `local-storage.ts`
* pulls in `lib/api/domains/attachment-api.ts` -> `lib/config.ts`, which uses
* `import.meta`, and Playwright's CJS transform rejects that at load time
* ("Cannot use 'import.meta' outside a module") before any test is collected.
* Only type-only imports reach app source from here for that reason.
*
* A silent drift is what the copy risks, so it does not stay silent:
* {@link waitForPersistedLayoutChange} reports the live `sessionStorage` keys on
* timeout, which distinguishes a stale prefix here from the layout genuinely
* never being persisted.
*/
const LAYOUT_KEY_PREFIX = "kandev.dockview.env-layout-v3.";
/** Serialise every persisted dockview layout currently in `sessionStorage`. */
export async function snapshotPersistedLayouts(page: Page): Promise<string> {
return page.evaluate((prefix) => {
const entries: string[] = [];
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i);
if (!key?.startsWith(prefix)) continue;
entries.push(`${key}=${window.sessionStorage.getItem(key) ?? ""}`);
}
return entries.sort().join("\n");
}, LAYOUT_KEY_PREFIX);
}
/**
* Wait until the persisted layout differs from `previous`, i.e. the debounced
* save has actually run.
*
* Fails if no save lands, which is the point: a layout change that never
* reaches storage is exactly the bug the reload in these specs is checking for,
* and a sleep would have carried on into a misleading assertion instead.
*
* The two ways that failure can happen are indistinguishable from the snapshot
* alone — the app stopped persisting, or {@link LAYOUT_KEY_PREFIX} drifted from
* the production constant and this polled a key nothing writes — so the timeout
* names the keys that were actually present instead of leaving the reader to
* assume the first.
*/
export async function waitForPersistedLayoutChange(
page: Page,
previous: string,
timeout = 10_000,
): Promise<void> {
try {
await expect
.poll(() => snapshotPersistedLayouts(page), {
timeout,
message: "dockview never persisted the layout change",
})
.not.toBe(previous);
} catch (error) {
const keys = await page.evaluate(() => {
const found: string[] = [];
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i);
if (key) found.push(key);
}
return found.sort();
});
const matched = keys.filter((key) => key.startsWith(LAYOUT_KEY_PREFIX));
const diagnosis =
matched.length > 0
? `${matched.length} key(s) matched "${LAYOUT_KEY_PREFIX}", so the prefix is live and the layout genuinely did not change.`
: `no key matched "${LAYOUT_KEY_PREFIX}" — this helper's copy of the prefix has probably drifted from DOCKVIEW_ENV_LAYOUT_PREFIX in apps/web/lib/local-storage.ts.`;
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n\n${diagnosis}\nsessionStorage keys present: ${keys.join(", ") || "(none)"}`,
);
}
}
+12 -43
View File
@@ -3,10 +3,7 @@ import { computeRightMaxPx, computeSidebarMaxPx } from "../../lib/state/layout-m
import type { SeedData } from "../fixtures/test-base";
import type { ApiClient } from "../helpers/api-client";
import { SessionPage } from "../pages/session-page";
/** Bounding-box info Playwright returns. Re-declared to avoid pulling the
* full Locator type just for one shape. */
type Box = { x: number; y: number; width: number; height: number };
import { dwell } from "./causal-waits";
export const WIDE_VIEWPORT = { width: 1600, height: 900 };
@@ -65,43 +62,6 @@ export async function getDockviewGroupWidthById(page: Page, groupId: string): Pr
}, groupId);
}
async function sashBoxAt(page: Page, index: number): Promise<Box> {
const sashes = page.locator(".dv-sash");
const count = await sashes.count();
if (count === 0) throw new Error("no .dv-sash elements found");
if (index >= count) {
throw new Error(`sash index ${index} out of range (${count} sashes)`);
}
const box = await sashes.nth(index).boundingBox();
if (!box) throw new Error(`sash ${index} has no bounding box`);
return box;
}
/**
* Drag a horizontal-direction sash (between two columns) by deltaX pixels.
* sashIndex is the dockview sash order (0 = left-most). Reserved for tests
* that exercise real pointer motion (double-click smoke tests, etc.); most
* resize tests should prefer {@link resizeColumnViaSplitview} for stability
* in headless CI.
*/
export async function dragHorizontalSash(
page: Page,
sashIndex: number,
deltaX: number,
steps = 20,
): Promise<void> {
const box = await sashBoxAt(page, sashIndex);
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await page.mouse.move(cx, cy);
await page.mouse.down();
await page.mouse.move(cx + deltaX, cy, { steps });
await page.mouse.up();
// Give the debounced layout-save 350ms to fire so subsequent reload assertions
// see the new width.
await page.waitForTimeout(400);
}
/**
* Programmatically resize a column via dockview's internal splitview API.
*
@@ -210,8 +170,17 @@ export async function resizeColumnViaSplitview(
},
{ col: column, target: targetWidth },
);
// Allow the debounced layout persistence to fire.
await page.waitForTimeout(400);
// The evaluate above force-flushes via `__persistDockviewLayout__`, but the
// ordinary debounced write can still be in flight behind it. Not converted to
// an observation of the stored layout: a resize that clamps to the width it
// already had writes nothing, and the ~30 call sites include several that
// deliberately ask for a clamped width.
await dwell(
page,
400,
"product-timer",
"the dockview layout persists on a ~350ms debounce in our own code and publishes nothing when it lands, so reload assertions downstream have to be spaced past it",
);
return result;
}
+11 -2
View File
@@ -1,5 +1,6 @@
import type { ApiClient } from "./api-client";
import type { OfficeApiClient } from "./office-api-client";
import { dwell } from "./causal-waits";
const PROFILE_WAIT_MS = 20_000;
@@ -26,7 +27,11 @@ export async function balancedExecutionProfileRouting(
}),
);
if (profiles.some((profile) => profile === undefined)) {
await new Promise((resolve) => setTimeout(resolve, 250));
await dwell(
250,
"poll-interval",
"sampling interval for the profile-creation retry above; no Page exists in this helper, and profile availability is read back over HTTP rather than pushed",
);
continue;
}
const routing = await officeApi.getRouting(workspaceId);
@@ -52,7 +57,11 @@ export async function balancedExecutionProfileRouting(
),
};
}
await new Promise((resolve) => setTimeout(resolve, 250));
await dwell(
250,
"poll-interval",
"sampling interval for the routing-readiness loop above; no Page exists in this helper, and the routing config is read back over HTTP rather than pushed",
);
}
throw new Error(`execution profiles did not become available for ${providerOrder.join(", ")}`);
}
+13 -2
View File
@@ -1,5 +1,6 @@
import { type Locator, type Page, expect } from "@playwright/test";
import { FileTreePage } from "./file-tree-page";
import { dwell } from "../helpers/causal-waits";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -344,7 +345,12 @@ export class SessionPage {
if ((await this.readXtermBuffer("passthrough-terminal")).includes(text)) {
throw new Error(`Expected passthrough terminal NOT to contain "${text}", but it was found`);
}
await this.page.waitForTimeout(200);
await dwell(
this.page,
200,
"poll-interval",
"sampling interval for the stability window above; the assertion is that the text never appears, so the loop keeps re-reading the buffer across real elapsed time",
);
}
}
@@ -679,7 +685,12 @@ export class SessionPage {
} catch {
// Menu was likely detached by a re-render — dismiss and retry
await this.page.keyboard.press("Escape");
await this.page.waitForTimeout(500);
await dwell(
this.page,
500,
"unverified",
"spacing before the next attempt in this menu-retry loop; the menu was detached mid-render and nothing was identified that signals it is safe to re-open",
);
}
}
// Final attempt without catch
@@ -1,5 +1,6 @@
import { expect } from "@playwright/test";
import { backendFixture as test } from "../../fixtures/backend";
import { dwell } from "../../helpers/causal-waits";
import {
acceptInvite,
createInviteToken,
@@ -201,7 +202,12 @@ test.describe.serial("opt-in authentication", () => {
);
expect(renamed.ok()).toBeTruthy();
await memberPage.waitForTimeout(1500);
await dwell(
memberPage,
1500,
"negative-assertion",
"asserts the member's socket never receives the admin's workspace traffic; a frame that must not arrive publishes nothing, so the check needs real time on the wire before sampling",
);
const frames = await memberPage.evaluate(
() => (window as unknown as { __wsFrames: string[] }).__wsFrames,
);
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { backendFixture as test } from "../../fixtures/backend";
import { createInviteToken, login } from "../../helpers/auth";
import { dwell } from "../../helpers/causal-waits";
/**
* Not an assertion suite — captures screenshots of every auth surface into
@@ -46,7 +47,12 @@ test.describe.serial("auth screenshots", () => {
await page.getByTestId("setup-submit").click();
await page.waitForURL("**/");
await expect(page.getByTestId("setup-email")).toHaveCount(0, { timeout: 15_000 });
await page.waitForTimeout(1500);
await dwell(
page,
1500,
"unverified",
"settling the first post-setup render before capturing it; this suite exists to produce reviewable screenshots rather than assertions, and no specific timer was identified behind the number",
);
await shot(page, "02-app-after-setup");
await ctx.close();
});
@@ -187,18 +193,30 @@ test.describe.serial("auth screenshots", () => {
await login(ctx, backend.baseUrl, MEMBER);
const page = await ctx.newPage();
await page.goto("/");
await page.waitForTimeout(2000);
await dwell(
page,
2000,
"unverified",
"settling the member's first app render before capturing it; capture quality, not synchronisation, and no specific timer was identified behind the number",
);
await shot(page, "11-member-app");
// Dismiss the first-run onboarding wizard (it overlays the sidebar), then
// hover the current-user chip to verify the border (not accent-fill) hover.
const skip = page.getByRole("button", { name: /skip/i });
if (await skip.isVisible().catch(() => false)) {
await skip.click();
await page.waitForTimeout(400);
// The wizard overlays the sidebar, so the capture below is only right
// once it is gone. Assert that rather than budgeting for it.
await expect(skip).toBeHidden({ timeout: 5_000 });
}
const chip = page.getByTestId("current-user-chip");
await chip.hover();
await page.waitForTimeout(300);
await dwell(
page,
300,
"product-timer",
"the chip's hover treatment is a CSS transition in our own styles; it publishes nothing on completion, and capturing mid-transition is what this shot exists to avoid",
);
await chip.screenshot({ path: path.join(SHOT_DIR, "12-user-chip-hover.png") });
await ctx.close();
});
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
@@ -125,7 +126,12 @@ async function hoverUntilGutterSlotAppears(testPage: Page) {
};
});
if (geometry) return geometry;
await testPage.waitForTimeout(50);
await dwell(
testPage,
50,
"poll-interval",
"sampling interval for the hover-geometry loop above; the utility slot is rendered by a hover handler that publishes nothing, so the loop re-reads the DOM rather than awaiting an event",
);
}
}
+7 -2
View File
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
import {
fileDiffTab,
getDiffsContainer,
@@ -425,8 +426,12 @@ test.describe("Untracked file diff update", () => {
session.chat.getByText("untracked-file-modify complete", { exact: false }),
).toBeVisible({ timeout: 45_000 });
// Wait for git polling to detect the file change (polling interval is ~1-2s)
await testPage.waitForTimeout(3_000);
await dwell(
testPage,
3_000,
"poll-interval",
"git status is discovered by a ~1-2s backend poll rather than pushed, so the file change becomes visible on the poll's schedule and there is no event marking the sweep that will find it",
);
// Switch back to Changes tab and click on the diff file again
await openChangesTab(testPage);
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
import type { ApiClient } from "../../helpers/api-client";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
@@ -869,8 +870,12 @@ test.describe("Git Changes Panel", () => {
await session.clickTab("Changes");
await expect(session.changes).toBeVisible({ timeout: 10_000 });
// Wait for initial load
await testPage.waitForTimeout(2_000);
await dwell(
testPage,
2_000,
"unverified",
"pre-existing spacing before the external-git edits below; the panel is already asserted visible above and no timer was identified behind this, so it is labelled as debt rather than given a cause it does not have",
);
// Set up git helper
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
@@ -1273,10 +1278,15 @@ test.describe("Git Changes Panel", () => {
const session = await openTaskSession(testPage, "Git Cumulative Test");
// Wait for the session to fully initialize including base commit capture.
// The captureBaseCommit runs async after agent launch, we need it to complete
// before making commits so the cumulative diff works correctly.
await testPage.waitForTimeout(3_000);
// captureBaseCommit runs asynchronously after agent launch and has to finish
// before the commits below, or the cumulative diff is computed against the
// wrong base.
await dwell(
testPage,
3_000,
"product-timer",
"captureBaseCommit runs async after agent launch and publishes nothing when it completes, so the commits below have to be spaced past it",
);
// Set up git helper
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
@@ -1311,17 +1321,48 @@ test.describe("Git Changes Panel", () => {
await expect(session.changes.getByText("Add first line")).toBeVisible({ timeout: 10_000 });
await expect(session.changes.getByText("Add second line")).toBeVisible({ timeout: 10_000 });
// The poll below proves a "diff-viewer" panel exists after the click, which
// only implicates the click if none existed before it. Nothing in this test
// opens one earlier, so this reads as already-true today — it is here so
// that stops being an unstated assumption if the surrounding test ever
// reuses a session or restores a saved layout.
const diffViewerOpen = () =>
testPage.evaluate(() => {
type Api = { getPanel: (id: string) => unknown };
return Boolean(
(window as unknown as { __dockviewApi__?: Api }).__dockviewApi__?.getPanel("diff-viewer"),
);
});
expect(await diffViewerOpen(), "no cumulative diff panel before clicking Diff").toBe(false);
// Click the "Diff" button in the header to open the cumulative diff view
await session.changes.getByRole("button", { name: "Diff" }).click();
// Wait for diff viewer to load
await testPage.waitForTimeout(2_000);
// Assert what the click actually opens. Without this the checks below are
// vacuous: the two commit texts were already visible before the click and
// never go away, and "No changes" is absent from a page where the diff
// never opened at all, so all three passed whether or not the button did
// anything. The button calls `addDiffViewerPanel()`, which mounts a
// dockview panel with id "diff-viewer" -- it is not the Review dialog, as a
// first attempt at this assertion assumed and a run disproved.
await expect
.poll(diffViewerOpen, {
timeout: 15_000,
message: "the Diff button never opened the cumulative diff panel",
})
.toBe(true);
// Verify commits are visible (this proves git changes are tracked)
await expect(session.changes.getByText("Add first line")).toBeVisible({ timeout: 5_000 });
await expect(session.changes.getByText("Add second line")).toBeVisible({ timeout: 5_000 });
// The cumulative diff should NOT show "No changes"
// The cumulative diff should NOT show "No changes". A negative has no event
// to wait on, so it needs the render window to elapse before sampling.
await dwell(
testPage,
2_000,
"negative-assertion",
'asserts the cumulative diff never renders its empty state; "No changes" not appearing publishes nothing, so the check has to outlast the dialog\'s own load',
);
await expect(testPage.locator("text=No changes")).not.toBeVisible({ timeout: 5_000 });
});
@@ -5,6 +5,7 @@ import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-asserti
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import type { Locator } from "@playwright/test";
import { dwell } from "../../helpers/causal-waits";
const MR_IID = 211;
@@ -19,7 +20,12 @@ async function expectTouchTarget(locator: Locator, label: string) {
// A boundingBox() measured mid-animation reports the pre-settle scaled-down
// size, not the final CSS size — wait past the animation before measuring.
async function waitForDropdownSettled(page: import("@playwright/test").Page) {
await page.waitForTimeout(200);
await dwell(
page,
200,
"library-timer",
"@kandev/ui's DropdownMenuContent runs a 100ms zoom-in entrance animation that publishes nothing on completion, and a boundingBox read mid-animation reports the pre-settle scaled size",
);
}
async function seedTaskWithLinkedMR(apiClient: ApiClient, seedData: SeedData, title: string) {
@@ -1,5 +1,6 @@
import { test, expect } from "../../fixtures/test-base";
import { stubGitHubRateLimits } from "./github-rate-limit-fixture";
import { dwell } from "../../helpers/causal-waits";
type ReviewWatchesResponse = {
watches: Array<{ id: string; enabled: boolean }>;
@@ -210,7 +211,12 @@ test.describe("GitHub workspace settings", () => {
expect(executorBox).not.toBeNull();
expect(executorBox!.y - (managedBox!.y + managedBox!.height)).toBeLessThanOrEqual(9);
await dialog.getByRole("radio", { name: "Inherit executor Git credentials" }).click();
await testPage.waitForTimeout(300);
await dwell(
testPage,
300,
"unverified",
"settling the radio selection's render before the capture below; no timer was identified behind the number, and the capture itself is a no-op unless CAPTURE_PR_ASSETS is set",
);
await prCapture.screenshot("desktop-task-git-access-dialog", {
caption: "Task Git access is configured alongside the workspace connection",
});
@@ -12,6 +12,7 @@ import {
readFakeLspEvents,
releaseFakeLspInitialization,
} from "./lsp-e2e-helpers";
import { dwell } from "../../helpers/causal-waits";
test.describe("Mobile LSP boundaries", () => {
test.describe.configure({ timeout: 90_000 });
@@ -56,7 +57,12 @@ test.describe("Mobile LSP boundaries", () => {
await expect(viewer.locator(".cm-editor")).toBeVisible();
await expect(viewer.getByTestId("lsp-status-button")).toHaveCount(0);
await expect(testPage.getByTestId("app-status-lsp")).toHaveCount(0);
await testPage.waitForTimeout(1_000);
await dwell(
testPage,
1_000,
"negative-assertion",
"asserts that no LSP socket is ever opened on mobile; a connection that must not happen publishes nothing, so the check needs the window in which it would have opened to elapse",
);
expect(lspSockets).toEqual([]);
expect(readFakeLspEvents(backend)).toEqual([]);
} finally {
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/office-fixture";
import { injectLatency } from "../../helpers/causal-waits";
/**
* E2E coverage for the per-agent paginated runs list and the run
@@ -102,12 +103,10 @@ test.describe("Office agent run detail", () => {
await route.continue();
return;
}
// deliberate-sleep(latency-injection): NOT a wait. This delay is injected
// into the mocked paginated-runs response so the test can observe the
// pending/loading UI; the delay is the stimulus under test, and removing
// or shortening it destroys the scenario. Proposed as an eighth category
// because none of the existing seven describe "this is not a wait".
await new Promise((resolve) => setTimeout(resolve, 800));
await injectLatency(
800,
"slows the mocked paginated-runs response so the pending/loading UI stays observable; the delay is the stimulus under test, not a wait",
);
await route.continue();
});
@@ -1,6 +1,7 @@
import { type Page } from "@playwright/test";
import { test as base, expect } from "../../fixtures/test-base";
import { OfficeApiClient } from "../../helpers/office-api-client";
import { dwell } from "../../helpers/causal-waits";
/**
* Regression test: onboarding with a task title must launch the agent
@@ -90,11 +91,11 @@ test.describe("Onboarding task launch", () => {
return;
}
// deliberate-sleep(poll-interval): sampling interval for the loop above,
// which asserts an invariant on EVERY iteration (the session must never
// reach FAILED/CANCELLED before launch). Converting to expect.poll would
// drop that per-iteration guard, so the loop keeps sampling instead.
await new Promise((r) => setTimeout(r, 1_000));
await dwell(
1_000,
"poll-interval",
"sampling interval for the loop above, which asserts an invariant on every iteration (the session must never reach FAILED or CANCELLED before launch); expect.poll would drop that per-iteration guard",
);
}
expect(
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/office-fixture";
import { dwell } from "../../helpers/causal-waits";
test.describe("Real-time dashboard updates", () => {
test("dashboard metrics update after task creation", async ({
@@ -77,11 +78,12 @@ test.describe("Real-time dashboard updates", () => {
workflow_id: otherWf.id,
});
// deliberate-sleep(negative-assertion): the assertion below is that the
// cross-workspace event triggers NO dashboard refetch. There is no event
// for a fetch that must never happen, so the only way to give a regression
// room to occur is to wait past the window in which it would have fired.
await testPage.waitForTimeout(3000);
await dwell(
testPage,
3000,
"negative-assertion",
"the assertion below is that a cross-workspace event triggers no dashboard refetch; a fetch that must never happen has no event, so a regression needs the window in which it would have fired to elapse",
);
// No additional dashboard fetches should have occurred after the event.
const newFetches = fetchTimes.length - baselineCount;
@@ -16,6 +16,7 @@ import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture";
import { SessionPage } from "../../pages/session-page";
import { KanbanPage } from "../../pages/kanban-page";
import type { ApiClient } from "../../helpers/api-client";
import { dwell } from "../../helpers/causal-waits";
const PANEL_ID = "notes";
@@ -311,7 +312,12 @@ test.describe("Plugins — task panel / kanban Edit submenu / card indicator", (
// Now let the stale first read land — it must not revert the panel.
releaseFirst();
await testPage.waitForTimeout(500);
await dwell(
testPage,
500,
"negative-assertion",
"asserts the stale first read never reverts the panel; a value that must not be written renders nothing, so the check needs time for the late response to land and do damage",
);
await expect(notesEditor).toHaveValue("second note");
});
+7 -1
View File
@@ -49,6 +49,7 @@ import { expect, test } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import { holdPluginInstallResponse } from "../../helpers/plugin-install";
import { dwell } from "../../helpers/causal-waits";
const PLUGIN_ID = "kandev-plugin-e2e";
const NAV_ITEM_ID = "e2e-hello";
@@ -578,7 +579,12 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
// every .error, so a polling plugin would file an Error-level backend log
// entry per cycle. The report is scheduled in a microtask and sent async,
// so give it a real chance to fire before asserting it never did.
await testPage.waitForTimeout(500);
await dwell(
testPage,
500,
"negative-assertion",
"the report is scheduled in a microtask and sent async, and the assertion is that it never fires; a request that must not happen has no event, so it needs a real chance to arrive",
);
expect(reportRequests).toEqual([]);
// Attribution goes to the console instead, matching every other plugin
@@ -3,6 +3,7 @@ import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import type { Locator, Page } from "@playwright/test";
import { dwell } from "../../helpers/causal-waits";
const OWNER = "acme";
const REPO = "demo";
@@ -408,7 +409,12 @@ test.describe("PR top-bar CI popover", () => {
// Past the 150ms close delay the popover must still be open and its buttons
// clickable.
await testPage.waitForTimeout(600);
await dwell(
testPage,
600,
"product-timer",
"outlasts our own 150ms hover-intent close delay; the assertion is that the popover is still there afterwards, and a close that must not happen publishes nothing to wait on",
);
await expect(popover).toBeVisible();
await expect(openButton).toBeVisible();
await expect(session.prWorkflowAddContextButton("Lint")).toBeEnabled();
@@ -464,7 +470,12 @@ test.describe("PR top-bar CI popover", () => {
// Past the 150ms close delay the popover must still be open and interactive,
// not merely mounted — parity with the topbar test.
await testPage.waitForTimeout(600);
await dwell(
testPage,
600,
"product-timer",
"outlasts our own 150ms hover-intent close delay; the assertion is that the popover survives the gap crossing, and a close that must not happen publishes nothing to wait on",
);
await expect(popover).toBeVisible();
await expect(openButton).toBeVisible();
await expect(popover.getByTestId("pr-workflow-add-context").first()).toBeEnabled();
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
import { REVIEW_SIDEBAR_LIMITS } from "../../../hooks/use-review-sidebar-resize";
@@ -182,7 +183,12 @@ test.describe("Review file status", () => {
await expect
.poll(() => reviewScroll.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0);
await testPage.waitForTimeout(600);
await dwell(
testPage,
600,
"negative-assertion",
"asserts that jumping to a file never marks it reviewed; the count staying at zero is the absence of an event, so a regression needs the auto-review window to elapse to have room to fire",
);
await expect(reviewProgress).toHaveText(`0 of ${totalFiles} files reviewed`);
await prCapture.screenshot("review-ordered-safe-jump", {
caption: "Review keeps tree and diff order aligned without auto-reviewing a file jump",
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import type { ApiClient } from "../../helpers/api-client";
import { dwell } from "../../helpers/causal-waits";
import type { SeedData } from "../../fixtures/test-base";
import { makeGitEnv } from "../../helpers/git-helper";
@@ -64,7 +65,11 @@ async function waitForWorktreePath(
const worktreePath =
session?.worktree_path ?? session?.workspace_path ?? session?.worktrees?.[0]?.worktree_path;
if (worktreePath) return worktreePath;
await new Promise((resolve) => setTimeout(resolve, 250));
await dwell(
250,
"poll-interval",
"sampling interval for the loop above; no Page exists here, and the worktree appears in a session record the backend writes without publishing anything this helper can subscribe to",
);
}
throw new Error(`Timed out waiting for the nested-review worktree for ${sessionId}`);
}
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
@@ -419,7 +420,12 @@ test.describe("Code walkthrough", () => {
const reviewProgress = reviewDialog.getByText(/^0 of \d+ files reviewed$/);
await expect(reviewProgress).toBeVisible({ timeout: 15_000 });
const initialProgress = await reviewProgress.textContent();
await testPage.waitForTimeout(600);
await dwell(
testPage,
600,
"negative-assertion",
"asserts the walkthrough behind the dialog never scrolls the review or advances its progress; both checks are absences, so they need the window in which a stray scroll or auto-review would land to elapse first",
);
await expect(reviewDialog.getByTestId("review-diff-scroll")).toHaveJSProperty("scrollTop", 0);
await expect(reviewProgress).toHaveText(initialProgress ?? "");
await expectWalkthroughBehindDialog(testPage, reviewDialog, [
@@ -6,6 +6,7 @@ import { expect, test } from "../../fixtures/test-base";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
import { MODIFIER } from "./shared";
import { dwell } from "../../helpers/causal-waits";
const SEARCH_TERM = "TaskWorkspaceNeedle";
const EXTRA_REPOSITORY_NAME = "content-search-extra";
@@ -59,7 +60,12 @@ async function waitForRepositoryGroups({
if (Date.now() >= deadline) break;
await input.fill("");
await page.waitForTimeout(50);
await dwell(
page,
50,
"poll-interval",
"spacing between retype attempts in the retry loop above; the search input is debounced and the retry exists to re-trigger it, so there is no completion event to await here",
);
await input.fill(query);
}
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { dwell } from "../../helpers/causal-waits";
/**
* Regression: when workspace preparation takes longer than the file-tree
@@ -58,14 +59,15 @@ test.describe("Long prepare (slow git fetch)", () => {
await expect(fileTreeWaiting).toBeVisible({ timeout: 15_000 });
await expect(fileTreeManual).toHaveCount(0);
// deliberate-sleep(product-timer): waits past the pre-fix retry budget
// (1+2+5+10 = 18s). The regression is the file tree transitioning to the
// "manual" (Load Files) state when that budget expires, so the budget's
// expiry is precisely the thing under test and nothing renders to signal
// it. On faster CI runners the 22s fetch may already have completed by
// the time this assertion runs, so do not require the waiting state to
// still be visible here.
await testPage.waitForTimeout(19_000);
// On faster CI runners the 22s fetch may already have completed by the
// time the assertion below runs, so it does not require the waiting state
// to still be visible.
await dwell(
testPage,
19_000,
"product-timer",
"outlasts our own 1+2+5+10s file-tree retry ladder; the regression under test is the tree falling back to its manual state when that budget expires, and the expiry renders nothing to signal it",
);
await expect(fileTreeManual).toHaveCount(0);
// Fetch eventually returns, worktree creation proceeds, agentctl
@@ -6,6 +6,7 @@ import type { ApiClient } from "../../helpers/api-client";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import { waitForStableActiveSession } from "../../helpers/session-store";
import { dwell } from "../../helpers/causal-waits";
/**
* Regression: opening / switching to a second agent session in a task that
@@ -172,11 +173,12 @@ test.describe("Session flicker", () => {
});
await session.sessionTabBySessionId(session2Id).click();
// deliberate-sleep(negative-assertion): this is the observation window the
// test exists for. It asserts the ABSENCE of sustained oscillation after a
// deliberate switch, so it must sample real elapsed time rather than wait
// for any single event.
await testPage.waitForTimeout(2_500);
await dwell(
testPage,
2_500,
"negative-assertion",
"the observation window this test exists for: it asserts the absence of sustained oscillation after a deliberate switch, so it has to sample real elapsed time rather than await any single event",
);
const activeLog = await testPage.evaluate(
() => (window as unknown as FlickerWindow).__activeLog__ ?? [],
@@ -253,11 +255,12 @@ test.describe("Session flicker", () => {
await session.sessionTabBySessionId(session1Id).click();
await waitForStableActiveSession(testPage, session1Id);
await session.sessionTabBySessionId(session2Id).click();
// deliberate-sleep(negative-assertion): final observation window. The
// assertions below are that neither tab was stripped and no session panel
// was torn down, which only means something after giving the regression
// real time to occur.
await testPage.waitForTimeout(1_500);
await dwell(
testPage,
1_500,
"negative-assertion",
"final observation window; the assertions below are that neither tab was stripped and no session panel was torn down, which only mean something after the regression has had real time to occur",
);
// Both tabs must remain present throughout, and the active session's chat
// must still be visible (dockview keeps inactive panels mounted-but-hidden,
@@ -1,6 +1,7 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import { dwell } from "../../helpers/causal-waits";
// Regression: when the backend restarts and auto-resumes a task whose
// session was WAITING_FOR_INPUT, the task must NEVER transition into the
@@ -72,11 +73,12 @@ test.describe("Session resume — task state stability", () => {
0,
);
polls++;
// deliberate-sleep(poll-interval): sampling interval for the fixed probe
// window above, not a settle. The assertion is that the task never
// flickers into Running, so the loop must keep sampling across the whole
// window; the polls >= 50 check below guards against it degenerating.
await testPage.waitForTimeout(100);
await dwell(
testPage,
100,
"poll-interval",
"sampling interval for the fixed probe window above, not a settle; the assertion is that the task never flickers into Running, so the loop keeps sampling across the whole window and the polls >= 50 check guards against it degenerating",
);
}
// Ensure we actually polled meaningfully across the window.
expect(polls).toBeGreaterThanOrEqual(50);
@@ -6,6 +6,7 @@ import {
summarizeGatewayTraffic,
type GatewayTrafficFrame,
} from "../../helpers/ws-traffic";
import { dwell } from "../../helpers/causal-waits";
const TASK_COUNT = 27;
const SWITCH_COUNT = 5;
@@ -116,12 +117,12 @@ test.describe("Session stream budget", () => {
});
}
// deliberate-sleep(negative-assertion): a deliberate five-second
// observation window after the five task switches, measuring how much
// gateway traffic keeps arriving. This is a diagnostic total, not a
// compression-dependent correctness threshold, so the window must be real
// elapsed time rather than a wait for any particular frame.
await testPage.waitForTimeout(5_000);
await dwell(
testPage,
5_000,
"negative-assertion",
"a deliberate observation window measuring how much gateway traffic keeps arriving after five task switches; it is a diagnostic total rather than a wait for any particular frame, so it has to be real elapsed time",
);
const summary = summarizeGatewayTraffic(capture.frames);
await test.info().attach("gateway-traffic.json", {
@@ -6,6 +6,7 @@ import type { ApiClient } from "../../helpers/api-client";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import { attachGatewayTrafficCapture } from "../../helpers/ws-traffic";
import { dwell } from "../../helpers/causal-waits";
/** Wire action the kanban WS handler consumes when a task moves step. */
const TASK_UPDATED_ACTION = "task.updated";
@@ -191,10 +192,12 @@ test.describe("Session tab management — close behavior", () => {
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible({ timeout: 15_000 });
// …and stays gone — useAutoSessionTab must not recreate it.
// deliberate-sleep(negative-assertion): the regression is a tab being
// recreated after removal. There is no event for a recreation that must
// never happen, so the check needs real elapsed time to be meaningful.
await testPage.waitForTimeout(800);
await dwell(
testPage,
800,
"negative-assertion",
"the regression is a tab being recreated after removal; a recreation that must never happen has no event, so the check needs real elapsed time to mean anything",
);
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible();
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible();
@@ -297,10 +300,12 @@ test.describe("Session tab management — close behavior", () => {
// that the remaining session tab is present (and the deleted one didn't come
// back), so gate on the surviving session tab instead.
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible({ timeout: 15_000 });
// deliberate-sleep(negative-assertion): the deleted tab must not come back
// after a task round-trip. Nothing is rendered to wait for when the
// expected outcome is "no tab ever appears".
await testPage.waitForTimeout(800);
await dwell(
testPage,
800,
"negative-assertion",
"the deleted tab must not come back after a task round-trip; nothing is rendered to wait for when the expected outcome is that no tab ever appears",
);
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible();
});
@@ -354,9 +359,12 @@ test.describe("Session tab management — close behavior", () => {
await expect(session.sessionTabBySessionId(sessionB1Id)).toBeVisible({ timeout: 10_000 });
// …and neither of task A's session tabs should have followed us in.
// deliberate-sleep(negative-assertion): asserts tabs from another task
// never leak in, which has no arrival event to wait on.
await testPage.waitForTimeout(800);
await dwell(
testPage,
800,
"negative-assertion",
"asserts that tabs from another task never leak in, which has no arrival event to wait on",
);
await expect(session.sessionTabBySessionId(sessionA1Id)).not.toBeVisible();
await expect(session.sessionTabBySessionId(sessionA2Id)).not.toBeVisible();
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/ssh-test-base";
import { dwell } from "../../helpers/causal-waits";
/**
* HTTP contract for GET /api/v1/ssh/executors/:id/sessions. Backs the
* SSHSessionsCard table. Filtered to ssh-runtime ExecutorRunning rows for
@@ -144,7 +145,11 @@ test.describe("ssh sessions-endpoint contract", () => {
const beforeRow = before.find((s) => s.task_id === task.id);
expect(beforeRow).toBeDefined();
await new Promise((resolve) => setTimeout(resolve, 2_000));
await dwell(
2_000,
"clock-separation",
"the assertion below is that uptime_seconds grew, so real time has to pass between the two reads; the thing being waited for is the clock, not an app event",
);
const after = await apiClient.listSSHSessions(seedData.sshExecutorId);
const afterRow = after.find((s) => s.task_id === task.id);
+6 -1
View File
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
// Wait until the backend has a populated disk-usage cache (the initial walk
// is async; the first call returns `{data: null, computing: true}` and kicks
@@ -15,7 +16,11 @@ async function waitForDiskUsageCached(apiClient: {
const body = (await res.json()) as { data: unknown; computing: boolean };
if (body.data) return;
}
await new Promise((r) => setTimeout(r, 250));
await dwell(
250,
"poll-interval",
"sampling interval for the disk-usage cache poll above; the cache is computed in the background and only observable by re-reading the endpoint",
);
}
throw new Error("disk-usage cache never populated within 20s");
}
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
async function listBackups(apiClient: { rawRequest: (m: string, p: string) => Promise<Response> }) {
const res = await apiClient.rawRequest("GET", "/api/v1/system/backups");
@@ -48,7 +49,11 @@ test.describe("Restore snapshot dialog", () => {
found = true;
break;
}
await new Promise((r) => setTimeout(r, 250));
await dwell(
250,
"poll-interval",
"sampling interval for the backup-list poll above; snapshot creation is backend work read back over HTTP, with no page bound to this loop",
);
}
expect(found).toBeTruthy();
@@ -3,6 +3,7 @@
// shell-connect path, so the connect/readiness helpers live here to avoid
// drift between the two files.
import { type Page, expect } from "@playwright/test";
import { dwell } from "../../helpers/causal-waits";
export async function tapTerminalTab(testPage: Page): Promise<void> {
await testPage.getByRole("button", { name: "Terminal" }).tap();
@@ -41,7 +42,20 @@ async function remountTerminalPanel(testPage: Page): Promise<void> {
const chatTab = testPage.getByRole("button", { name: "Chat" });
if (await chatTab.isVisible()) {
await chatTab.tap();
await testPage.waitForTimeout(250);
// The point of the detour through Chat is to unmount the terminal panel, so
// wait for it to be gone. Budgeting for it instead would let a lost tap
// fall through to `switchToTerminalPanel`, which finds the panel already
// visible and returns without remounting anything -- turning the retry that
// exists to rescue a dead shell WS into a no-op.
//
// Counted rather than asserted visible/hidden: `terminal-panel` is not
// guaranteed to be a single node (dockview mounts one per terminal panel,
// and the mobile layout mounts its own), so a state assertion on the bare
// locator would be a strict-mode violation the moment a second one exists,
// and would pass on a stale hidden one while a live panel remained mounted.
// Zero of them is what "unmounted" means here, and `toHaveCount` says that
// without having to resolve which instance is the active one.
await expect(testPage.getByTestId("terminal-panel")).toHaveCount(0, { timeout: 5_000 });
}
await switchToTerminalPanel(testPage);
}
@@ -70,7 +84,12 @@ export async function waitForShellReady(testPage: Page, timeout = 45_000): Promi
remounts += 1;
nextRemountAt = Date.now() + 15_000;
}
await testPage.waitForTimeout(1_000);
await dwell(
testPage,
1_000,
"poll-interval",
"sampling interval for the loop above, which re-taps and remounts between reads; the shell WS connect publishes nothing this side of xterm's buffer filling, so there is no event to await instead",
);
}
expect(
(await readTerminalBuffer(testPage)).length,
@@ -4,6 +4,11 @@ import type { SeedData } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import type { Page } from "@playwright/test";
import {
snapshotPersistedLayouts,
waitForPersistedLayoutChange,
} from "../../helpers/dockview-persistence";
import { dwell } from "../../helpers/causal-waits";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
@@ -143,10 +148,16 @@ test.describe("Terminals — dockview UI", () => {
.getByTestId("terminal-tab-seq-2")
.locator("..")
.locator(".dv-default-tab-action");
const layoutBeforeClose = await snapshotPersistedLayouts(testPage);
await seq2Close.click();
await expect(testPage.getByTestId("terminal-tab-seq-2")).toHaveCount(0, { timeout: 5_000 });
await testPage.waitForTimeout(800);
// The tab is gone from the DOM, but the reload below reads sessionStorage,
// and that write is on a ~300ms debounce with no event. Wait for the write
// rather than past it: if the close never reaches storage, that is the bug
// this test exists to catch, and it should fail here rather than as a
// confusing assertion after the reload.
await waitForPersistedLayoutChange(testPage, layoutBeforeClose);
await testPage.reload();
await session.waitForLoad();
@@ -193,14 +204,16 @@ test.describe("Terminals — dockview UI", () => {
await session.clickTab("Terminal");
await session.expectTerminalConnected();
const layoutBeforeNewTerminal = await snapshotPersistedLayouts(testPage);
await clickNewTerminalInPlusMenu(testPage, session);
await expect(testPage.getByTestId("terminal-tab-seq-1")).toBeVisible({ timeout: 10_000 });
await expect(testPage.getByTestId("terminal-tab-seq-2")).toBeVisible({ timeout: 5_000 });
// Layout-save is 300ms-debounced. Wait past the debounce so the
// saved JSON includes the user-created terminal panel before we
// reload — otherwise a fast test races the save.
await testPage.waitForTimeout(800);
// Layout-save is 300ms-debounced and publishes nothing, so the saved JSON
// has to be observed rather than waited out: the reload below reads it, and
// a slow save on a loaded shard would make this fail claiming the panel was
// not preserved.
await waitForPersistedLayoutChange(testPage, layoutBeforeNewTerminal);
await testPage.reload();
// After the dockview "preserve restored active tab" change (commit
@@ -291,14 +304,22 @@ test.describe("Terminals — dockview UI", () => {
// Create a second terminal so we have a non-default row to click.
await clickNewTerminalInPlusMenu(testPage, session);
await expect(testPage.getByTestId("terminal-tab-seq-2")).toBeVisible({ timeout: 10_000 });
await testPage.waitForTimeout(800);
// Count terminal tab content elements before the focus click.
// Count terminal tab content elements before the focus click. Polling the
// count is the wait: it returns as soon as the second tab has rendered
// instead of after a fixed budget, and it fails if the tab never arrives
// rather than reporting a baseline of 1 that makes the comparison below
// meaningless.
const terminalContent = testPage.locator(".dv-default-tab-content").filter({
hasText: /^Terminal$/,
});
await expect
.poll(() => terminalContent.count(), {
timeout: 10_000,
message: "two terminal tabs before clicking reopen",
})
.toBeGreaterThanOrEqual(2);
const before = await terminalContent.count();
expect(before, "two terminal tabs before clicking reopen").toBeGreaterThanOrEqual(2);
// Open the menu and click the seq=2 row — that terminal is already
// open as a tab, so the click should focus rather than mint a new
@@ -310,9 +331,15 @@ test.describe("Terminals — dockview UI", () => {
await expect(seq2Row).toHaveCount(1, { timeout: 10_000 });
await seq2Row.click();
// Tab count must NOT grow. The menu closes on its own; wait briefly
// for any spurious panel to land.
await testPage.waitForTimeout(500);
// Tab count must NOT grow. There is no event for a panel that must never
// be created, so the only way to give a regression room to happen is to let
// the window in which it would have landed elapse.
await dwell(
testPage,
500,
"negative-assertion",
"asserts that clicking an already-open terminal focuses rather than minting a second panel; a panel that must not appear publishes nothing to wait on",
);
const after = await terminalContent.count();
expect(
after,
@@ -1,6 +1,7 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
import { dwell } from "../../helpers/causal-waits";
async function createProfiles(
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
@@ -962,10 +963,11 @@ test.describe("Workflow agent profile switching", () => {
expect(stableSessions.length, "no extra session should spawn within stability window").toBe(
2,
);
// deliberate-sleep(poll-interval): sampling interval for the stability
// window above. The assertion is that no extra session appears during
// it, so the loop must keep sampling across real elapsed time.
await new Promise((r) => setTimeout(r, 250));
await dwell(
250,
"poll-interval",
"sampling interval for the stability window above; the assertion is that no extra session appears during it, so the loop keeps sampling across real elapsed time",
);
}
// Critical assertion: no extra profileA session was spawned after the
@@ -1,6 +1,7 @@
import type { Locator } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
import { dwell } from "../../helpers/causal-waits";
async function maxRingSpread(locator: Locator): Promise<number> {
const boxShadow = await locator.evaluate((element) => getComputedStyle(element).boxShadow);
@@ -685,9 +686,12 @@ test.describe("Seed protection", () => {
// `workflow.created` WS event arrives at the open settings page.
await apiClient.e2eCreateHiddenWorkflow(seedData.workspaceId, hiddenName);
// Allow the WS event to propagate and the React effect in
// useWorkflowSettings a chance to (incorrectly) add a card.
await testPage.waitForTimeout(500);
await dwell(
testPage,
500,
"negative-assertion",
"gives the workflow.created frame and the useWorkflowSettings effect a chance to incorrectly add a card; the assertion is that no card appears, which publishes nothing to wait on",
);
// No new card appeared and the hidden entry is not in the list.
const allCards = testPage.locator('[data-testid^="workflow-card-"]');
@@ -2,6 +2,7 @@ import { test, expect } from "../../fixtures/test-base";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import { dwell } from "../../helpers/causal-waits";
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
useRegularMode();
@@ -216,8 +217,12 @@ test.describe("Workflow start step placement", () => {
});
// --- Toggle off plan mode ---
// Wait for the editor to be fully interactive before sending the shortcut
await testPage.waitForTimeout(1_000);
await dwell(
testPage,
1_000,
"unverified",
"pre-existing spacing before the plan-mode shortcut; the editor's readiness was not tied to an identified timer or event, so this is labelled as debt rather than given a cause it does not have",
);
await session.togglePlanMode();
await expect(session.planPanel).not.toBeVisible({ timeout: 15_000 });
+2
View File
@@ -285,6 +285,8 @@ export function setFilesPanelScrollPosition(sessionId: string, position: number)
// Bumping the prefix invalidates legacy saves so every env opens at the
// preset defaults once, then resumes per-env persistence. Bumped to v3 to
// discard layouts captured with the now-removed dockview sidebar column.
// Mirrored (deliberately, see below) by LAYOUT_KEY_PREFIX in
// `apps/web/e2e/helpers/dockview-persistence.ts`; bump both together.
const DOCKVIEW_ENV_LAYOUT_PREFIX = "kandev.dockview.env-layout-v3.";
// A serialized Dockview layout is geometry, not evidence of an intentional