diff --git a/tests/runner-e2e/product-journeys/task-search-filter.ts b/tests/runner-e2e/product-journeys/task-search-filter.ts index 34d58499940..245e031f1ee 100644 --- a/tests/runner-e2e/product-journeys/task-search-filter.ts +++ b/tests/runner-e2e/product-journeys/task-search-filter.ts @@ -24,7 +24,7 @@ export async function taskSearchResultIds(page: Page, tasks: readonly SearchTask }).sort(); } -async function expectResults(page: Page, tasks: readonly SearchTask[], expectedIds: readonly string[]) { +export async function expectResults(page: Page, tasks: readonly SearchTask[], expectedIds: readonly string[]) { let observed: string[] = []; await expect.poll(async () => { observed = await taskSearchResultIds(page, tasks); return observed; }, { timeout: 30_000 }) .toEqual([...expectedIds].sort()); diff --git a/tests/runner-e2e/task-search-filter.spec.ts b/tests/runner-e2e/task-search-filter.spec.ts index fbe98888693..ce0df6860a9 100644 --- a/tests/runner-e2e/task-search-filter.spec.ts +++ b/tests/runner-e2e/task-search-filter.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Locator, type Page } from "@playwright/test"; -import { taskSearchBacklogCheckbox, taskSearchResultIds } from "./product-journeys/task-search-filter.js"; +import { expectResults, taskSearchBacklogCheckbox, taskSearchResultIds } from "./product-journeys/task-search-filter.js"; import type { SearchTask } from "./product-journeys/task-search-evidence.js"; // Reproduce the observed Radix checkbox + StatusIcon + visible text label. @@ -43,12 +43,15 @@ test("task search readback survives a search rerender that removes an observed r page.setDefaultTimeout(150); // Bound only the reproduced stale-index wait. await page.setContent(`
${searchTasks.map(taskLink).join("")}
`); let transitions = 0; + let batches = 0; + let detachedReads = 0; + let invalidReads = 0; const transition = async () => { if (transitions++) return; await page.locator("main#main-content").evaluate((main, html) => { main.innerHTML = html; }, searchTasks.slice(0, 2).map(taskLink).join("")); }; - // Keep all browser operations real. Place the render between the old - // visibility/attribute reads, or immediately before the batch read. + // Keep browser operations and the journey's poll real. Hold actual DOM nodes + // across the first render so the reader must recover from a detached batch. const withTransition = (source: Locator): Locator => new Proxy(source, { get(object, property, receiver) { if (property === "all") return async () => (await object.all()).map((row, index) => index !== 2 ? row : new Proxy(row, { @@ -64,8 +67,27 @@ test("task search readback survives a search rerender that removes an observed r })); if (property === "filter") return (...args: Parameters) => withTransition(object.filter(...args)); if (property === "evaluateAll") return async (...args: Parameters) => { - await transition(); - return object.evaluateAll(...args); + batches++; + if (batches === 1) { + const held = await page.evaluateHandle(() => Array.from(document.querySelectorAll("main#main-content a[data-inbox-issue-link]"))); + try { + await transition(); + const detached = await held.evaluate(...args); + expect(detached).toBeNull(); + detachedReads++; + // A later connected snapshot can still be wrong. The same poll + // must reject its extra control before accepting the final pair. + await page.locator("main#main-content").evaluate((main, html) => { main.innerHTML = html; }, searchTasks.map(taskLink).join("")); + return detached; + } finally { await held.dispose(); } + } + const result = await object.evaluateAll(...args); + if (batches === 2) { + expect(result).toHaveLength(3); + invalidReads++; + await page.locator("main#main-content").evaluate((main, html) => { main.innerHTML = html; }, searchTasks.slice(0, 2).map(taskLink).join("")); + } + return result; }; const value = Reflect.get(object, property, receiver); return typeof value === "function" ? value.bind(object) : value; @@ -78,8 +100,12 @@ test("task search readback survives a search rerender that removes an observed r return typeof value === "function" ? value.bind(object) : value; }, }); - await expect(taskSearchResultIds(observedPage, searchTasks)).resolves.toEqual(["status-control", "target"]); + await expect(expectResults(observedPage, searchTasks, ["target", "status-control"])) + .resolves.toEqual(["status-control", "target"]); expect(transitions).toBe(1); + expect(detachedReads).toBe(1); + expect(invalidReads).toBe(1); + expect(batches).toBe(3); await expect(page.locator("a[data-inbox-issue-link]")).toHaveCount(2); });