import { test, expect, type Locator, type Page } from "@playwright/test"; import { openApp } from "./helpers"; // A video dragged onto a list in the rail, with a real pointer: the rail and the page are separate // React trees, and jsdom lays out nothing to drag across. The browser projects share one account, so // each test holds lists of its own, named for it, and takes them away after. interface Listed { id: number; items: { id: string; title: string }[]; } const made: number[] = []; async function newList(page: Page, name: string, videos: string[] = []): Promise { const res = await page.request.post("/api/playlists", { data: { name } }); const { id } = (await res.json()) as { id: number }; made.push(id); for (const video_id of videos) { const added = await page.request.post(`/api/playlists/${String(id)}/items`, { data: { video_id }, }); expect(added.ok()).toBe(true); } return id; } const contents = async (page: Page, id: number) => ((await (await page.request.get(`/api/playlists/${String(id)}`)).json()) as Listed).items.map( (v) => v.id, ); async function setUp(page: Page, name: string, project: string) { await pinOnTouch(page, project); const feed = (await (await page.request.get("/api/feed?limit=3")).json()) as { items: { id: string; title: string }[]; }; expect(feed.items.length).toBe(3); const videos = feed.items; const from = await newList( page, `${name} from`, videos.map((v) => v.id), ); const to = await newList(page, `${name} to`); await openApp(page, `/?page=playlists&pl=${String(from)}`); await expect(page.getByRole("heading", { name: `${name} from` })).toBeVisible(); await expect(page.locator("tbody tr")).toHaveCount(3); return { videos, from, to, target: page.locator(`[data-playlist-drop="${String(to)}"]`) }; } const centre = async (el: Locator) => { const box = await el.boundingBox(); if (!box) throw new Error("nothing to point at"); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; }; /** Pick the row up by its grip and carry it to a point, the button still held. */ async function carry(page: Page, grip: Locator, to: { x: number; y: number }) { const from = await centre(grip); await page.mouse.move(from.x, from.y); await page.mouse.down(); await page.mouse.move(from.x + 8, from.y + 8, { steps: 3 }); await page.mouse.move(to.x, to.y, { steps: 12 }); } /** The same onto the rail. An unpinned one is a tab until the drag's own hover peeks it open. */ async function carryToRail(page: Page, grip: Locator, target: Locator) { if (await target.isVisible()) { await carry(page, grip, await centre(target)); return; } const tab = page.getByRole("button", { name: "Pin Playlists open" }); await carry(page, grip, await centre(tab)); await expect(target).toBeVisible(); await page.mouse.move(...(Object.values(await centre(target)) as [number, number]), { steps: 6 }); } /** What dnd-kit tells a screen reader: said once the drag has taken the step in, so a drop after it * is the one a person makes, not one that beats the row to its new place. */ const said = (page: Page, text: RegExp) => expect(page.locator('[id^="DndLiveRegion"]').filter({ hasText: text })).toBeAttached(); /** A coarse pointer's rail never peeks, a drawer instead: on the tablet it is pinned, for these. */ async function pinOnTouch(page: Page, project: string) { if (project !== "ipad") return; await page.route("**/api/me", async (route) => { const res = await route.fetch(); const me = (await res.json()) as { preferences?: Record }; await route.fulfill({ response: res, json: { ...me, preferences: { ...me.preferences, playlistsCollapsed: false } }, }); }); } test.describe("acting on the selected rows", { tag: "@playlists" }, () => { test.afterEach(async ({ page }) => { for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`); }); const box = (page: Page, title: string) => page.getByRole("checkbox", { name: `Select “${title}”` }); test("a Shift range, the band's Remove, and its Undo back to the places", async ({ page, }, info) => { const { videos, from } = await setUp(page, `band ${info.project.name}`, info.project.name); await box(page, videos[0]!.title).click(); await box(page, videos[1]!.title).click({ modifiers: ["Shift"] }); await expect(box(page, videos[1]!.title)).toBeChecked(); await expect(box(page, videos[2]!.title)).not.toBeChecked(); await page.getByRole("button", { name: "Remove", exact: true }).click(); await expect(page.locator("tbody tr")).toHaveCount(1); await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]); await page.getByRole("button", { name: "Undo", exact: true }).last().click(); await expect.poll(() => contents(page, from)).toEqual(videos.map((v) => v.id)); await expect(page.locator("tbody tr")).toHaveCount(3); }); test("x ticks the walked row, Esc clears, and Delete takes the selection and goes on", async ({ page, }, info) => { const { videos, from } = await setUp(page, `keys ${info.project.name}`, info.project.name); const firstRow = page.locator("tbody tr").first(); await page.keyboard.press("j"); await expect(firstRow).toBeFocused(); await page.keyboard.press("x"); await expect(box(page, videos[0]!.title)).toBeChecked(); await page.keyboard.press("Escape"); await expect(box(page, videos[0]!.title)).not.toBeChecked(); await page.keyboard.press("x"); await page.keyboard.press("j"); await page.keyboard.press("x"); await page.keyboard.press("Delete"); await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]); await expect(page.locator("tbody tr")).toHaveCount(1); await expect(firstRow).toBeFocused(); }); }); test.describe("dragging onto a list", { tag: "@playlists" }, () => { test.afterEach(async ({ page }) => { await page.mouse.up(); for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`); }); test("a row leaves for the list it is dropped on, lit up on the way, and Undo brings it back", async ({ page, }, info) => { const { videos, from, to, target } = await setUp( page, `drag one ${info.project.name}`, info.project.name, ); const grip = page.getByRole("button", { name: `Reorder “${videos[1]!.title}”`, exact: false }); await carryToRail(page, grip, target); await expect(target).toHaveClass(/ring-2/); await page.mouse.up(); await expect(target).not.toHaveClass(/ring-2/); await expect(page.locator("tbody tr")).toHaveCount(2); // The grip that held the focus has left: the keyboard keeps its place in the list. await expect .poll(() => page.evaluate(() => document.activeElement?.closest("tbody") != null)) .toBe(true); await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]); expect(await contents(page, from)).toEqual([videos[0]!.id, videos[2]!.id]); await page.getByRole("button", { name: "Undo", exact: true }).last().click(); await expect.poll(() => contents(page, from)).toEqual(videos.map((v) => v.id)); expect(await contents(page, to)).toEqual([]); }); test("a selected row carries the selected ones, named on the drag image", async ({ page, }, info) => { const { videos, from, to, target } = await setUp( page, `drag many ${info.project.name}`, info.project.name, ); await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click(); await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click(); const grip = page.getByRole("button", { name: `Reorder “${videos[2]!.title}”`, exact: false }); await carryToRail(page, grip, target); const image = page.getByText("2 videos", { exact: true }); await expect(image).toBeVisible(); // Above every layer, by the tooltip's token: the overlay wrapping it takes the level. expect(await image.evaluate((el) => getComputedStyle(el.parentElement!).zIndex)).toBe("100"); // Below the pointer, so it leaves the list's name to read. const { y } = await centre(target); expect((await image.boundingBox())!.y).toBeGreaterThan(y); await page.mouse.up(); await expect.poll(() => contents(page, to)).toEqual([videos[0]!.id, videos[2]!.id]); expect(await contents(page, from)).toEqual([videos[1]!.id]); await expect(page.getByRole("checkbox", { name: "Select all" })).not.toBeChecked(); }); test("many rows dropped inside the list stay as they were", async ({ page }, info) => { const { videos, from } = await setUp( page, `drag inside ${info.project.name}`, info.project.name, ); await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click(); await page.getByRole("checkbox", { name: videos[1]!.title, exact: false }).click(); const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }); await carry(page, grip, await centre(page.locator("tbody tr").nth(2))); await page.mouse.up(); await expect(page.locator("tbody tr")).toHaveCount(3); expect(await contents(page, from)).toEqual(videos.map((v) => v.id)); }); test("one row dropped inside the list takes the new place", async ({ page }, info) => { const { videos, from } = await setUp( page, `drag order ${info.project.name}`, info.project.name, ); const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }); await carry(page, grip, await centre(page.locator("tbody tr").nth(2))); await said(page, /position 3/); await page.mouse.up(); await expect .poll(() => contents(page, from)) .toEqual([videos[1]!.id, videos[2]!.id, videos[0]!.id]); }); test("Escape puts the drag down, with nothing moved and nothing lit", async ({ page }, info) => { const { videos, from, to, target } = await setUp( page, `drag cancel ${info.project.name}`, info.project.name, ); // A tick on another row: the Esc is the drag's, and leaves the selection alone. const ticked = page.getByRole("checkbox", { name: `Select “${videos[2]!.title}”` }); await ticked.click(); const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }); await carryToRail(page, grip, target); await expect(target).toHaveClass(/ring-2/); await page.keyboard.press("Escape"); await expect(target).not.toHaveClass(/ring-2/); await page.mouse.up(); expect(await contents(page, from)).toEqual(videos.map((v) => v.id)); expect(await contents(page, to)).toEqual([]); await expect(ticked).toBeChecked(); }); test("a key still reorders a row, alone", async ({ page }, info) => { const { videos, from } = await setUp(page, `drag key ${info.project.name}`, info.project.name); await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click(); await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click(); await page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }).focus(); await page.keyboard.press("Space"); await said(page, /picked up/i); // A person's pause: dnd-kit drops an arrow pressed before it has measured the lifted row, which // nothing on the page announces (11 of 31 runs without it, none of 31 with it). await page.waitForTimeout(300); await page.keyboard.press("ArrowDown"); await said(page, /position 2/); await page.keyboard.press("Space"); await expect .poll(() => contents(page, from)) .toEqual([videos[1]!.id, videos[0]!.id, videos[2]!.id]); }); test("under a filter a row still goes to another list, and none is reordered", async ({ page, }, info) => { const { videos, from, to, target } = await setUp( page, `drag filter ${info.project.name}`, info.project.name, ); await page.getByRole("columnheader", { name: "Title" }).getByRole("button").last().click(); await page.getByPlaceholder("Title").fill(videos[1]!.title); await page.keyboard.press("Escape"); await expect(page.locator("tbody tr")).toHaveCount(1); const grip = page.getByRole("button", { name: `Drag “${videos[1]!.title}” onto a list`, exact: false, }); await carryToRail(page, grip, target); await page.mouse.up(); await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]); expect(await contents(page, from)).toEqual([videos[0]!.id, videos[2]!.id]); }); test("the rail scrolls while the drag holds at its edge", async ({ page }, info) => { const name = `drag scroll ${info.project.name}`; for (let i = 0; i < 12; i++) await newList(page, `${name} ${String(i)}`); await page.setViewportSize({ width: 1280, height: 520 }); const { videos } = await setUp(page, name, info.project.name); const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }); const area = page.locator("[data-playlist-drop-area]"); await carryToRail(page, grip, area.locator("[data-playlist-drop]").first()); const scroller = () => area.evaluate((el) => { for (let p = el.parentElement; p; p = p.parentElement) { const { overflowY } = getComputedStyle(p); if ((overflowY === "auto" || overflowY === "scroll") && p.scrollHeight > p.clientHeight) { const { left, right, bottom } = p.getBoundingClientRect(); return { top: p.scrollTop, x: (left + right) / 2, bottom }; } } return { top: -1, x: 0, bottom: 0 }; }); const before = await scroller(); expect(before.top).toBeGreaterThanOrEqual(0); await page.mouse.move(before.x, before.bottom - 8, { steps: 4 }); await expect.poll(async () => (await scroller()).top).toBeGreaterThan(before.top); await page.keyboard.press("Escape"); }); });