test(e2e): harden section reorder drag against bounding-box races

boundingBox() is a one-shot snapshot that returns null when the element
has no layout box — which happens after every CDK drop, since the section
task-list re-renders (@for track reorders nodes) and freshly-dropped tasks
run the expandInOnly enter animation at height:0.

Add a stableBoundingBox() helper that waits for visibility then polls for a
real, non-null box before driving the pointer gesture, and exclude
.ng-animating nodes when picking the reorder source/target (matching the
existing 'before' capture). Test-only change; reorder behavior itself is
covered by section.reducer unit tests.
This commit is contained in:
Johannes Millan 2026-05-29 17:18:07 +02:00
parent 2baf5fc512
commit bab8af264e

View file

@ -69,6 +69,37 @@ test.describe('Sections', () => {
): ReturnType<import('@playwright/test').Page['locator']> =>
page.locator('.section-container').filter({ hasText: title });
/**
* Read a locator's bounding box defensively. `boundingBox()` takes a
* one-shot snapshot and returns `null` whenever the element has no
* layout box at that instant which happens constantly here because
* the section task-list re-renders after every CDK drop (Angular's
* `@for track` reorders nodes) and freshly-dropped tasks run an
* `expandInOnly` enter animation that holds them at `height: 0`. Wait
* for the element to be visible, then poll until it reports a real box
* so the read can't race the re-render. This is the failure the two
* `test.fixme`s below hit; keeping the guard here lets the active tests
* stay reliable instead of throwing "no bounding box".
*/
const stableBoundingBox = async (
locator: import('@playwright/test').Locator,
): Promise<{ x: number; y: number; width: number; height: number }> => {
await locator.waitFor({ state: 'visible', timeout: 10000 });
await expect
.poll(
async () => {
const b = await locator.boundingBox();
return !!b && b.width > 0 && b.height > 0;
},
{ timeout: 10000 },
)
.toBe(true);
// The poll guarantees a real box exists; re-read it for the gesture.
const box = await locator.boundingBox();
if (!box) throw new Error('drag source/target has no bounding box');
return box;
};
/**
* CDK drag-drop is event-driven via Angular CDK's own pointer-event
* handling. Playwright's `dragTo` uses HTML5 drag-and-drop events,
@ -80,9 +111,8 @@ test.describe('Sections', () => {
source: import('@playwright/test').Locator,
target: import('@playwright/test').Locator,
): Promise<void> => {
const sBox = await source.boundingBox();
const tBox = await target.boundingBox();
if (!sBox || !tBox) throw new Error('drag source/target has no bounding box');
const sBox = await stableBoundingBox(source);
const tBox = await stableBoundingBox(target);
/* eslint-disable no-mixed-operators */
const sx = sBox.x + sBox.width / 2;
const sy = sBox.y + sBox.height / 2;
@ -293,9 +323,11 @@ test.describe('Sections', () => {
expect(before.length).toBe(3);
// Drag the first task onto the last task. After the drop, the first
// task should no longer be at index 0.
const firstTask = section.locator('task').nth(0);
const lastTask = section.locator('task').nth(2);
// task should no longer be at index 0. Exclude `.ng-animating` nodes
// (matching the `before` capture) so we never target a task that's
// still running its enter animation at `height: 0`.
const firstTask = section.locator('task:not(.ng-animating)').nth(0);
const lastTask = section.locator('task:not(.ng-animating)').nth(2);
await cdkDragTo(page, firstTask.locator('done-toggle').first(), lastTask);
await expect