test(e2e): record the geometry when a board-card click fails (BUG-3553 probe) (#2049)

* test(e2e): record the geometry when a board-card click fails (BUG-3553 probe)

pane-controller :583 and pane-content-link-anchors :290 fail on CI with
another card intercepting the hit point, and then "not stable" until the
timeout. It has not reproduced locally (a CI-shaped desktop shard 1/2 at
2 workers passed clean), so the mechanism has to be read from a failing
CI run. clickBoardCard clicks as before; on failure it records the target's
box over ten frames, its lane index, the column's scroll, neighbours'
content-visibility state, transforms, and the element under the hit
point, attaches it, and rethrows. No wait is added.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* test(e2e): BUG-2871 spec opens its own parent on a retry; its click is probed (BUG-3553)

On #2049's first run the spec's first attempt clicked the parent card and
no pane opened (the BUG-3553 family), and its retry then opened the FIRST
attempt's parent: the locator matched the title prefix and took .first().
It now selects the card by the seeded slug. With --repeat-each=3 on one
server the old locator fails exactly as CI did; the new one passes 3/3.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
This commit is contained in:
xarmian
2026-10-11 02:21:30 -04:00
committed by GitHub
parent d6a45bf339
commit d8a44bb5ee
4 changed files with 102 additions and 26 deletions
@@ -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();
+74
View File
@@ -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<void> {
const testInfo = test.info();
try {
await card.click({ timeout });
} catch (err) {
const probe = await card
.evaluate(async (el) => {
const frame = () => new Promise<void>((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<typeof rect>[] = [];
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;
}
}
+2 -1
View File
@@ -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();
+21 -20
View File
@@ -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"]');