Skip to content

Commit c2729d4

Browse files
Add e2e coverage for the main interaction paths (WS3 M2)
Cover the solid paths with real-browser Playwright tests: entrance and edge-docking, expand/collapse, panel switching, Escape, activate(); both drag-to-dismiss paths (docked group and individual open-row) with dismiss-before-remove event ordering and a no-leaked-frames check; keyboard roaming, Delete-with-focus-handoff, and Enter-to-expand; plus a reduced-motion run that confirms the preference applied. Adds dragToDismiss/focusedLabel helpers. (13 specs, all Chromium.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7d025fd commit c2729d4

5 files changed

Lines changed: 256 additions & 1 deletion

File tree

tests/e2e/dismiss.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { expect, test } from "@playwright/test";
2+
import { bubble, dragToDismiss, settled } from "./helpers";
3+
4+
// Drag-to-dismiss — the marquee gesture. Both paths: a docked stack drags as
5+
// a group (dismisses all), an open-row bubble dismisses individually.
6+
7+
test.beforeEach(async ({ page }) => {
8+
await page.goto("/");
9+
await page.waitForFunction(() => !!window.bubbles);
10+
});
11+
12+
const events = (page: import("@playwright/test").Page) =>
13+
page.evaluate(() => window.bubbles.events());
14+
15+
test("dragging a docked bubble onto the target dismisses it", async ({ page }) => {
16+
await page.evaluate(() => {
17+
window.bubbles.create();
18+
window.bubbles.add({ id: "a", label: "A", panelText: "A" });
19+
});
20+
await settled(page);
21+
22+
await dragToDismiss(page, "A");
23+
24+
// The bubble leaves, and a "user" remove follows the commit-time dismiss.
25+
await expect(bubble(page, "A")).toHaveCount(0);
26+
const log = await events(page);
27+
expect(log).toContainEqual({ event: "dismiss", detail: { id: "a" } });
28+
expect(log).toContainEqual({ event: "remove", detail: { id: "a", reason: "user" } });
29+
30+
// dismiss precedes remove.
31+
const names = log.map((e) => e.event);
32+
expect(names.indexOf("dismiss")).toBeLessThan(names.indexOf("remove"));
33+
34+
// Teardown leaves no animation loops running.
35+
await expect.poll(() => page.evaluate(() => window.bubbles.liveFrames())).toBe(0);
36+
});
37+
38+
test("dragging one open-row bubble dismisses only it", async ({ page }) => {
39+
await page.evaluate(() => {
40+
window.bubbles.create();
41+
window.bubbles.add({ id: "a", label: "A", panelText: "A" });
42+
window.bubbles.add({ id: "b", label: "B", panelText: "B" });
43+
window.bubbles.activate("b");
44+
});
45+
await expect.poll(() => page.evaluate(() => window.bubbles.state())).toBe("open");
46+
await settled(page);
47+
48+
await dragToDismiss(page, "A");
49+
50+
await expect(bubble(page, "A")).toHaveCount(0);
51+
await expect(bubble(page, "B")).toBeVisible();
52+
53+
const log = await events(page);
54+
expect(log).toContainEqual({ event: "dismiss", detail: { id: "a" } });
55+
expect(log.filter((e) => e.event === "dismiss")).toHaveLength(1);
56+
});

tests/e2e/helpers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@ import { type Page } from "@playwright/test";
44
export const bubble = (page: Page, label: string) =>
55
page.locator(`[role="button"][aria-label="${label}"]`);
66

7+
/** The accessible name of whatever's focused — the suite's single-tab-stop probe. */
8+
export const focusedLabel = (page: Page): Promise<string | null> =>
9+
page.evaluate(() => document.activeElement?.getAttribute("aria-label") ?? null);
10+
11+
/**
12+
* Drags a bubble onto the dismiss target with a real pointer. Moves to the
13+
* bottom-center where the target rests (well inside its capture radius on a
14+
* desktop viewport), in steps so the velocity tracker and the target's
15+
* capture both see the motion, then releases.
16+
*/
17+
export const dragToDismiss = async (page: Page, label: string): Promise<void> => {
18+
const box = await bubble(page, label).boundingBox();
19+
const vp = page.viewportSize();
20+
if (!box || !vp) throw new Error(`cannot drag "${label}": no box/viewport`);
21+
22+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
23+
await page.mouse.down();
24+
await page.mouse.move(vp.width / 2, vp.height - 60, { steps: 24 });
25+
await page.mouse.move(vp.width / 2, vp.height - 56, { steps: 4 });
26+
await page.mouse.up();
27+
};
28+
729
/**
830
* Resolves once every bubble has held its position for a few consecutive
931
* animation frames — the black-box settle the suite waits on instead of

tests/e2e/keyboard.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { expect, test } from "@playwright/test";
2+
import { bubble, focusedLabel, settled } from "./helpers";
3+
4+
// Keyboard model: the open row is one tab stop with arrow-key roaming,
5+
// Enter/Space activates, Delete dismisses the focused bubble.
6+
7+
test.beforeEach(async ({ page }) => {
8+
await page.goto("/");
9+
await page.waitForFunction(() => !!window.bubbles);
10+
});
11+
12+
test("arrow keys move focus across the open row", async ({ page }) => {
13+
await page.evaluate(() => {
14+
window.bubbles.create();
15+
window.bubbles.add({ id: "a", label: "A", panelText: "A" });
16+
window.bubbles.add({ id: "b", label: "B", panelText: "B" });
17+
window.bubbles.add({ id: "c", label: "C", panelText: "C" });
18+
window.bubbles.toggle();
19+
});
20+
await expect.poll(() => page.evaluate(() => window.bubbles.state())).toBe("open");
21+
await settled(page);
22+
23+
// Row order is newest-first (C B A); the active newest bubble holds focus.
24+
expect(await focusedLabel(page)).toBe("C");
25+
await page.keyboard.press("ArrowRight");
26+
expect(await focusedLabel(page)).toBe("B");
27+
await page.keyboard.press("ArrowRight");
28+
expect(await focusedLabel(page)).toBe("A");
29+
// Clamps at the end rather than wrapping.
30+
await page.keyboard.press("ArrowRight");
31+
expect(await focusedLabel(page)).toBe("A");
32+
await page.keyboard.press("ArrowLeft");
33+
expect(await focusedLabel(page)).toBe("B");
34+
});
35+
36+
test("Delete dismisses the focused row bubble and moves focus to a neighbor", async ({ page }) => {
37+
await page.evaluate(() => {
38+
window.bubbles.create();
39+
window.bubbles.add({ id: "a", label: "A", panelText: "A" });
40+
window.bubbles.add({ id: "b", label: "B", panelText: "B" });
41+
window.bubbles.toggle();
42+
});
43+
await expect.poll(() => page.evaluate(() => window.bubbles.state())).toBe("open");
44+
await settled(page);
45+
46+
// Row B A; focus is on B (newest, active).
47+
expect(await focusedLabel(page)).toBe("B");
48+
await page.keyboard.press("Delete");
49+
50+
await expect(bubble(page, "B")).toHaveCount(0);
51+
await expect.poll(() => focusedLabel(page)).toBe("A");
52+
});
53+
54+
test("Enter on the docked stack expands it", async ({ page }) => {
55+
await page.evaluate(() => {
56+
window.bubbles.create();
57+
window.bubbles.add({ id: "a", label: "A", panelText: "A" });
58+
});
59+
await settled(page);
60+
61+
await bubble(page, "A").focus();
62+
await page.keyboard.press("Enter");
63+
await expect.poll(() => page.evaluate(() => window.bubbles.state())).toBe("open");
64+
});

tests/e2e/lifecycle.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { expect, test } from "@playwright/test";
2+
import { bubble, focusedLabel, settled } from "./helpers";
3+
4+
// The open/close lifecycle: entering and docking, expanding into the row,
5+
// switching panels, collapsing, and programmatic activate().
6+
7+
test.beforeEach(async ({ page }) => {
8+
await page.goto("/");
9+
await page.waitForFunction(() => !!window.bubbles);
10+
});
11+
12+
const state = (page: import("@playwright/test").Page) =>
13+
page.evaluate(() => window.bubbles.state());
14+
const active = (page: import("@playwright/test").Page) =>
15+
page.evaluate(() => window.bubbles.active());
16+
17+
test("the first bubble flies in and docks against the edge", async ({ page }) => {
18+
await page.evaluate(() => {
19+
window.bubbles.create();
20+
window.bubbles.add({ id: "a", label: "A" });
21+
});
22+
23+
const a = bubble(page, "A");
24+
await expect(a).toBeVisible();
25+
await settled(page);
26+
27+
// Default side is "right": it rests in the right half, near the edge.
28+
const box = await a.boundingBox();
29+
const vp = page.viewportSize();
30+
expect(box && vp).toBeTruthy();
31+
expect(box!.x).toBeGreaterThan(vp!.width / 2);
32+
expect(box!.x + box!.width).toBeGreaterThan(vp!.width - 24);
33+
});
34+
35+
test("tap expands the stack and shows the panel; tapping the active bubble collapses", async ({
36+
page
37+
}) => {
38+
await page.evaluate(() => {
39+
window.bubbles.create();
40+
window.bubbles.add({ id: "a", label: "A", panelText: "Panel A" });
41+
});
42+
await settled(page);
43+
44+
await bubble(page, "A").click();
45+
await expect.poll(() => state(page)).toBe("open");
46+
await expect(page.locator('[data-panel-content="a"]')).toBeVisible();
47+
48+
await bubble(page, "A").click();
49+
await expect.poll(() => state(page)).toBe("docked");
50+
});
51+
52+
test("tapping another bubble switches the active panel", async ({ page }) => {
53+
await page.evaluate(() => {
54+
window.bubbles.create();
55+
window.bubbles.add({ id: "a", label: "A", panelText: "Panel A" });
56+
window.bubbles.add({ id: "b", label: "B", panelText: "Panel B" });
57+
window.bubbles.activate("a");
58+
});
59+
await expect.poll(() => active(page)).toBe("a");
60+
await expect(page.locator('[data-panel-content="a"]')).toBeVisible();
61+
62+
await bubble(page, "B").click();
63+
await expect.poll(() => active(page)).toBe("b");
64+
await expect(page.locator('[data-panel-content="b"]')).toBeVisible();
65+
});
66+
67+
test("Escape collapses the group and returns focus to the stack", async ({ page }) => {
68+
await page.evaluate(() => {
69+
window.bubbles.create();
70+
window.bubbles.add({ id: "a", label: "A", panelText: "Panel A" });
71+
window.bubbles.activate("a");
72+
});
73+
await expect.poll(() => state(page)).toBe("open");
74+
await settled(page);
75+
76+
await page.keyboard.press("Escape");
77+
await expect.poll(() => state(page)).toBe("docked");
78+
expect(await focusedLabel(page)).toBe("A");
79+
});
80+
81+
test("activate() expands a docked group onto the chosen bubble", async ({ page }) => {
82+
await page.evaluate(() => {
83+
window.bubbles.create();
84+
window.bubbles.add({ id: "a", label: "A", panelText: "A" });
85+
window.bubbles.add({ id: "b", label: "B", panelText: "B" });
86+
});
87+
await settled(page);
88+
expect(await state(page)).toBe("docked");
89+
90+
await page.evaluate(() => window.bubbles.activate("a"));
91+
await expect.poll(() => state(page)).toBe("open");
92+
await expect.poll(() => active(page)).toBe("a");
93+
await expect(page.locator('[data-panel-content="a"]')).toBeVisible();
94+
});
95+
96+
test("with reduced motion, expand and tap-away still reach their end states", async ({ page }) => {
97+
await page.emulateMedia({ reducedMotion: "reduce" });
98+
// The library reads the preference live per animation, so confirm it took
99+
// — otherwise this would pass without exercising the reduced-motion paths.
100+
expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(
101+
true
102+
);
103+
104+
await page.evaluate(() => {
105+
window.bubbles.create();
106+
window.bubbles.add({ id: "a", label: "A", panelText: "Panel A" });
107+
window.bubbles.activate("a");
108+
});
109+
await expect.poll(() => state(page)).toBe("open");
110+
await expect(page.locator('[data-panel-content="a"]')).toBeVisible();
111+
112+
await page.locator("#outside").click();
113+
await expect.poll(() => state(page)).toBe("docked");
114+
});

tests/e2e/tsconfig.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
// is editor-only and doesn't change the lint/CI gate.
66
"extends": "../../tsconfig.json",
77
"compilerOptions": {
8-
"types": ["@playwright/test", "vite/client", "node"],
98
"paths": {
109
"$src/*": ["../../src/*"]
1110
}

0 commit comments

Comments
 (0)