diff --git a/web/e2e/bug-2871-refresh-preserves-child-rows.spec.ts b/web/e2e/bug-2871-refresh-preserves-child-rows.spec.ts index aebf21539..9987f39d9 100644 --- a/web/e2e/bug-2871-refresh-preserves-child-rows.spec.ts +++ b/web/e2e/bug-2871-refresh-preserves-child-rows.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from './fixtures'; import { browserLogin, seedDoc } from './lib/collab-helpers'; +import { clickBoardCard } from './lib/boardClickProbe'; import type { APIRequestContext, Page } from '@playwright/test'; import type { SuiteFixture } from './fixtures'; @@ -28,10 +29,6 @@ function authHeaders(fixture: SuiteFixture) { return { Authorization: `Bearer ${fixture.apiToken}` }; } -function itemCard(page: Page, title: string) { - return page.locator('.item-card').filter({ has: page.locator('.card-title', { hasText: title }) }); -} - test('BUG-2871: a same-data refresh keeps the child row node', async ({ page, fixture, @@ -68,7 +65,10 @@ test('BUG-2871: a same-data refresh keeps the child row node', async ({ }); await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs`); - await itemCard(page, 'B2871 refresh parent').first().click(); + // By the seeded slug, not the title prefix: on a retry the previous + // attempt's parent is still on this shared board under the same prefix, + // and .first() opened it (BUG-3553, seen on #2049's run 38111320569). + await clickBoardCard(page.locator(`.item-card[data-item-slug="${parent.slug}"]`)); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await pane.getByRole('tab', { name: 'Relationships' }).click(); diff --git a/web/e2e/lib/boardClickProbe.ts b/web/e2e/lib/boardClickProbe.ts new file mode 100644 index 000000000..12f0c2108 --- /dev/null +++ b/web/e2e/lib/boardClickProbe.ts @@ -0,0 +1,74 @@ +import { test, type Locator } from '@playwright/test'; + +/** + * BUG-3553 diagnostic: click a board card, and if the click fails, record the + * geometry that explains why, then fail exactly as before. It adds NO wait and + * changes nothing about a click that succeeds. + * + * The failure it is for (pane-controller :583, pane-content-link-anchors + * :290): after scrolling, another card intercepts the hit point, and the + * target then reads "not stable" until the test times out. The trace shows a + * static screen and a column scrollTop far too small for the target's place + * in the lane, so the target's box at hit-test time is not where normal flow + * puts it. The fix depends on which mechanism moves it; this records the + * candidates (content-visibility state, transforms, scroll, the element under + * the hit point, the box across frames) from the failing run itself, because + * it has not reproduced locally. + * + * The click gets a bounded timeout, so the probe still has time to run inside + * the test's 30s budget. + */ +export async function clickBoardCard(card: Locator, timeout = 12_000): Promise { + const testInfo = test.info(); + try { + await card.click({ timeout }); + } catch (err) { + const probe = await card + .evaluate(async (el) => { + const frame = () => new Promise((r) => requestAnimationFrame(() => r())); + const rect = (e: Element) => { + const b = e.getBoundingClientRect(); + return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }; + }; + const boxes: ReturnType[] = []; + for (let i = 0; i < 10; i++) { + boxes.push(rect(el)); + await frame(); + } + const wrapper = el.closest('.card-wrapper'); + const column = el.closest('.column-cards') as HTMLElement | null; + const wrappers = column ? Array.from(column.querySelectorAll(':scope > .card-wrapper')) : []; + const at = wrapper ? wrappers.indexOf(wrapper) : -1; + const b = el.getBoundingClientRect(); + const hit = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2); + const transforms: string[] = []; + for (let n: Element | null = el; n && n !== document.body; n = n.parentElement) { + const t = getComputedStyle(n).transform; + if (t && t !== 'none') transforms.push(`${n.className}: ${t}`); + } + const cv = (e: Element | null) => + e && 'checkVisibility' in e ? (e as Element & { checkVisibility(o: object): boolean }).checkVisibility({ contentVisibilityAuto: true }) : null; + const neighbour = (i: number) => (wrappers[i] ? { i, rect: rect(wrappers[i]), rendered: cv(wrappers[i]) } : null); + return { + viewport: { w: innerWidth, h: innerHeight }, + boxesOverTenFrames: boxes, + wrapper: wrapper ? { rect: rect(wrapper), offsetTop: (wrapper as HTMLElement).offsetTop, rendered: cv(wrapper) } : null, + indexInLane: at, + laneLength: wrappers.length, + column: column + ? { scrollTop: column.scrollTop, scrollHeight: column.scrollHeight, clientHeight: column.clientHeight, rect: rect(column) } + : null, + neighbours: [neighbour(at - 2), neighbour(at - 1), neighbour(at + 1)], + hitAtCentre: hit ? { tag: hit.tagName, cls: String(hit.className), text: (hit.textContent ?? '').trim().slice(0, 60), insideTarget: el.contains(hit) } : null, + transforms, + activeAnimations: document.getAnimations().length, + url: location.pathname + location.search, + }; + }) + .catch((e) => ({ probeFailed: String(e) })); + const body = JSON.stringify(probe, null, 1); + console.log(`BUG-3553 board-click probe (${testInfo.title}):\n${body}`); + await testInfo.attach('bug-3553-board-click-probe', { body, contentType: 'application/json' }); + throw err; + } +} diff --git a/web/e2e/pane-content-link-anchors.spec.ts b/web/e2e/pane-content-link-anchors.spec.ts index 51e2aace6..0f5f29a22 100644 --- a/web/e2e/pane-content-link-anchors.spec.ts +++ b/web/e2e/pane-content-link-anchors.spec.ts @@ -5,6 +5,7 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'; /** The slice of CDP's `Target.TargetInfo` this spec reads. */ type TargetInfo = { targetId: string; type?: string; url?: string }; import type { SuiteFixture } from './fixtures'; +import { clickBoardCard } from './lib/boardClickProbe'; /** * Anchor-based content-link interception (PLAN-2154 Architecture B.1/B.2/B.4 @@ -287,7 +288,7 @@ test.describe('content-link anchor interception (PLAN-2154 / TASK-2159)', () => await seedRelatedLink(fixture, request, a.slug, b.id); await page.goto(docsUrl(fixture)); - await itemCard(page, 'Anchors rel alpha').first().click(); + await clickBoardCard(itemCard(page, 'Anchors rel alpha').first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); const paneUrlAtA = page.url(); diff --git a/web/e2e/pane-controller.spec.ts b/web/e2e/pane-controller.spec.ts index 048154762..b74bd2198 100644 --- a/web/e2e/pane-controller.spec.ts +++ b/web/e2e/pane-controller.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from './fixtures'; import { browserLogin, seedDoc } from './lib/collab-helpers'; import type { APIRequestContext, Page } from '@playwright/test'; import type { SuiteFixture } from './fixtures'; +import { clickBoardCard } from './lib/boardClickProbe'; /** * Pane-navigation controller — depth/ownership state machine @@ -222,7 +223,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- await page.goto(docsUrl(fixture)); const prePaneUrl = page.url(); - await page.locator('.item-card', { hasText: 'Ctrl dblclose alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl dblclose alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true }); @@ -259,7 +260,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const b = await seedDoc(fixture, request, 'Ctrl drill bravo'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl drill alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl drill alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); const refA = openItemParam(page); @@ -302,7 +303,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- await seedDoc(fixture, request, 'Ctrl detach charlie'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl detach alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl detach alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); @@ -370,7 +371,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- await page.goto(docsUrl(fixture)); const prePaneUrl = page.url(); - await page.locator('.item-card', { hasText: 'Ctrl reset alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl reset alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); @@ -381,7 +382,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- // Now a DIRECT list-row click on a THIRD item (charlie) while detached: // resets the stack (go(-depth) to base, then a latched re-target), // landing at depth 0 (the base ownership preserved → owned). - await page.locator('.item-card', { hasText: 'Ctrl reset charlie' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl reset charlie' }).first()); await expect(pane.locator('.title', { hasText: /Ctrl reset charlie/ })).toBeVisible(); await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true }); @@ -404,7 +405,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- await seedItemIn(fixture, request, coll.slug, 'Rename target item'); await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/${coll.slug}`); - await page.locator('.item-card', { hasText: 'Rename target item' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Rename target item' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); // Owned first-open at depth 0. @@ -483,7 +484,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const focusedRows = page.locator('.list-column .item-card.focused'); // Open A at depth 0 → the pane-snap effect highlights A's row. - await page.locator('.item-card', { hasText: 'Ctrl hl alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl hl alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true }); @@ -528,7 +529,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- await page.goto(docsUrl(fixture)); // Open A at depth 0. - await page.locator('.item-card', { hasText: 'Ctrl late alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl late alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true }); @@ -580,7 +581,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- await page.goto(docsUrl(fixture)); const prePaneUrl = page.url(); - await page.locator('.item-card', { hasText: 'Ctrl esc alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl esc alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true }); @@ -651,7 +652,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- await seedDoc(fixture, request, 'Ctrl esc0 bravo'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl esc0 alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl esc0 alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true }); @@ -690,7 +691,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const c = await seedDoc(fixture, request, 'Ctrl esc-repeat charlie'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl esc-repeat alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl esc-repeat alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await drillTo(page, b.slug); @@ -739,7 +740,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const b = await seedDoc(fixture, request, 'Ctrl esc-boundary bravo'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl esc-boundary alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl esc-boundary alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await drillTo(page, b.slug); @@ -792,7 +793,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const b = await seedDoc(fixture, request, 'Ctrl esc-sheet bravo'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl esc-sheet alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl esc-sheet alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await drillTo(page, b.slug); @@ -844,7 +845,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const b = await seedDoc(fixture, request, 'Ctrl back bravo'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl back alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl back alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true }); @@ -883,7 +884,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const d = await seedDoc(fixture, request, 'Ctrl back-multi delta'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl back-multi alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl back-multi alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); @@ -948,7 +949,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const d = await seedDoc(fixture, request, 'Ctrl back-race delta'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl back-race alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl back-race alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); @@ -1028,7 +1029,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const c = await seedDoc(fixture, request, 'Ctrl back-supersede charlie'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl back-supersede alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl back-supersede alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); // A real row click puts the item's REF (e.g. `DOC-16`), not its slug, @@ -1070,7 +1071,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- // settled at the history level), so this is a normal depth-0 // re-target (`openItemPane` replace) — a completely different, later // navigation than the stalled Back pop, landing on C. - await page.locator('.item-card', { hasText: 'Ctrl back-supersede charlie' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl back-supersede charlie' }).first()); await expect(pane.locator('.title', { hasText: /Ctrl back-supersede charlie/ })).toBeVisible(); await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true }); // A row click lands the item's ref (not necessarily its slug — same @@ -1111,7 +1112,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const b = await seedDoc(fixture, request, 'Ctrl back-slow bravo'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl back-slow alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl back-slow alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); // A real row click puts the item's REF (e.g. `DOC-16`), not its slug, @@ -1208,7 +1209,7 @@ test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK- const b = await seedDoc(fixture, request, 'Ctrl back-mobile bravo'); await page.goto(docsUrl(fixture)); - await page.locator('.item-card', { hasText: 'Ctrl back-mobile alpha' }).first().click(); + await clickBoardCard(page.locator('.item-card', { hasText: 'Ctrl back-mobile alpha' }).first()); const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); const backBtn = pane.locator('button[aria-label="Back"]');