mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
Reference in New Issue
Block a user