diff --git a/e2e/attachments.spec.ts b/e2e/attachments.spec.ts new file mode 100644 index 00000000..c9de2036 --- /dev/null +++ b/e2e/attachments.spec.ts @@ -0,0 +1,325 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Drops `count` text files named `-.txt` on the zone, the way a + * browser delivers a drag from the desktop: dragenter, dragover, then drop, + * all carrying one real DataTransfer. + */ +async function dropFiles(page: Page, prefix: string, count: number) { + await page.getByTestId("zone").evaluate( + (zone, { prefix, count }) => { + const data = new DataTransfer(); + for (let index = 0; index < count; index += 1) { + data.items.add( + new File([`${prefix} ${index}`], `${prefix}-${index}.txt`, { + type: "text/plain", + }), + ); + } + for (const type of ["dragenter", "dragover", "drop"]) { + zone.dispatchEvent( + new DragEvent(type, { + dataTransfer: data, + bubbles: true, + cancelable: true, + }), + ); + } + }, + { prefix, count }, + ); +} + +/** Left edges of the stack's cards, from layout (so the tilt doesn't count). */ +function cardOffsets(page: Page): Promise { + return page + .locator("[data-slot=attachment]") + .evaluateAll((cards) => + cards.map((card) => (card instanceof HTMLElement ? card.offsetLeft : -1)), + ); +} + +async function gapBetweenFirstCards(page: Page): Promise { + const [first, second] = await cardOffsets(page); + if (first === undefined || second === undefined) return Number.NaN; + return second - first; +} + +const count = (page: Page) => page.getByRole("status", { name: "Count" }); + +test("a dropped picture previews, and removing it revokes its URL", async ({ + page, +}) => { + await page.goto("/attachments"); + await page.getByTestId("zone").evaluate(async (zone) => { + const canvas = document.createElement("canvas"); + canvas.width = 8; + canvas.height = 8; + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png"), + ); + if (blob === null) throw new Error("no PNG"); + const data = new DataTransfer(); + data.items.add(new File([blob], "dot.png", { type: "image/png" })); + for (const type of ["dragenter", "dragover", "drop"]) { + zone.dispatchEvent( + new DragEvent(type, { + dataTransfer: data, + bubbles: true, + cancelable: true, + }), + ); + } + }); + + await expect(count(page)).toHaveText("1"); + const list = page.getByRole("list", { name: "1 attachment" }); + const picture = list.locator("img"); + await expect(picture).toHaveJSProperty("complete", true); + expect( + await picture.evaluate((image: HTMLImageElement) => image.naturalWidth), + ).toBe(8); + const url = await picture.getAttribute("src"); + expect(url).toMatch(/^blob:/); + const readable = (target: string) => + page.evaluate( + (target) => + fetch(target).then( + () => true, + () => false, + ), + target, + ); + expect(await readable(url ?? "")).toBe(true); + + await list.getByRole("button", { name: "Remove dot.png" }).click(); + await expect(count(page)).toHaveText("0"); + await expect.poll(() => readable(url ?? "")).toBe(false); + // The list is gone with its last attachment; focus went to the message box. + await expect(page.getByRole("textbox", { name: "Message" })).toBeFocused(); + expect(await page.evaluate(() => document.activeElement?.tagName)).not.toBe( + "BODY", + ); +}); + +test("25 files fold into a stack that fans out, expands and clears", async ({ + page, +}) => { + await page.goto("/attachments"); + await dropFiles(page, "single", 1); + await expect(count(page)).toHaveText("1"); + await dropFiles(page, "batch", 25); + await expect(count(page)).toHaveText("26"); + await expect(page.getByRole("status", { name: "Refused" })).toHaveText(""); + + const stack = page.getByRole("list", { name: "26 attachments" }); + await expect(stack.locator("[data-slot=attachment]")).toHaveCount(4); + const more = stack.getByRole("button", { name: "Show 22 more" }); + await expect(more).toHaveText("+22"); + + // At rest the cards overlap: each shows a 10px sliver of the one behind. + await page.getByTestId("outside").hover(); + await expect.poll(() => gapBetweenFirstCards(page)).toBe(10); + await expect(more).toHaveCSS("opacity", "0"); + + // Pointed at, the deck fans out: a whole 52px card and an 8px gap apart. + await stack.hover(); + await expect.poll(() => gapBetweenFirstCards(page)).toBe(60); + await expect(more).toHaveCSS("opacity", "1"); + + await more.click(); + await expect(stack.locator("[data-slot=attachment]")).toHaveCount(26); + // Focus lands on the first card "+22" was folding, not the page start. + await expect( + stack.getByRole("button", { name: "Remove batch-3.txt" }), + ).toBeFocused(); + await expect(stack.getByText("batch-24.txt")).toBeVisible(); + + await page.getByRole("button", { name: "Remove all" }).click(); + await expect(count(page)).toHaveText("0"); + await expect(page.locator("[data-slot=attachments]")).toHaveCount(0); + await expect(page.getByRole("textbox", { name: "Message" })).toBeFocused(); +}); + +test("the stack fans out for the keyboard, and focus follows removal", async ({ + page, +}) => { + await page.goto("/attachments"); + await dropFiles(page, "key", 3); + await expect(count(page)).toHaveText("3"); + await page.getByTestId("outside").hover(); + + const stack = page.getByRole("list", { name: "3 attachments" }); + await page.keyboard.press("Tab"); + const first = stack.getByRole("button", { name: "Remove key-0.txt" }); + await expect(first).toBeFocused(); + await expect.poll(() => gapBetweenFirstCards(page)).toBe(60); + await expect(first).toHaveCSS("opacity", "1"); + + await page.keyboard.press("Enter"); + await expect(count(page)).toHaveText("2"); + await expect( + page + .getByRole("list", { name: "2 attachments" }) + .getByRole("button", { name: "Remove key-1.txt" }), + ).toBeFocused(); + + // The keyboard clears the rest; the last removal hands focus on. + await page.keyboard.press("Enter"); + await page.keyboard.press("Enter"); + await expect(count(page)).toHaveText("0"); + await expect(page.locator("[data-slot=attachments]")).toHaveCount(0); + await expect(page.getByRole("textbox", { name: "Message" })).toBeFocused(); + expect(await page.evaluate(() => document.activeElement?.tagName)).not.toBe( + "BODY", + ); +}); + +test("past the limit, 100 of 120 files join and the rest are refused", async ({ + page, +}) => { + await page.goto("/attachments"); + await dropFiles(page, "many", 120); + await expect(count(page)).toHaveText("100"); + await expect(page.getByRole("status", { name: "Refused" })).toHaveText( + "too-many 20", + ); + await expect( + page.getByRole("list", { name: "100 attachments" }).getByRole("button", { + name: "Show 96 more", + }), + ).toHaveText("+96"); +}); + +// A browser only builds file-system entries for a real drag from the +// desktop, which Playwright cannot make. The drop's items are stubbed to +// hand back entries of the same shape, so this covers the zone's walk of +// them, not the browser's side. On the picker the zone has to read the drop +// first, or the picker takes the folder as one unreadable file. +for (const [where, target] of [ + ["the zone", "[data-testid=zone]"], + ["the picker inside it", "[data-testid=zone] label"], +] as const) { + test(`a folder dropped on ${where} is walked to the files inside it`, async ({ + page, + }) => { + await page.goto("/attachments"); + await page.locator(target).evaluate((element) => { + type Entry = { + name: string; + isFile: boolean; + isDirectory: boolean; + file?: (success: (file: File) => void) => void; + createReader?: () => { + readEntries: (success: (entries: Entry[]) => void) => void; + }; + }; + const file = (name: string): Entry => ({ + name, + isFile: true, + isDirectory: false, + file: (success) => + success(new File([name], name, { type: "text/plain" })), + }); + const folder = (name: string, entries: Entry[]): Entry => ({ + name, + isFile: false, + isDirectory: true, + createReader: () => { + let read = false; + return { + readEntries: (success) => { + success(read ? [] : entries); + read = true; + }, + }; + }, + }); + const project = folder("project", [ + file("README.md"), + file(".DS_Store"), + folder("src", [file("index.ts"), file("util.ts")]), + folder("test", [file("index.ts")]), + ]); + const items = [ + { + kind: "file", + getAsFile: () => null, + webkitGetAsEntry: () => project, + }, + ]; + // A browser lists the folder itself among the files. + const files = [new File([], "project")]; + const data = { types: ["Files"], items, files, dropEffect: "none" }; + for (const type of ["dragenter", "dragover", "drop"]) { + const event = new DragEvent(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: data }); + element.dispatchEvent(event); + } + }); + + await expect(count(page)).toHaveText("4"); + await expect(page.getByRole("status", { name: "Folders" })).toHaveText( + "project", + ); + const stack = page.getByRole("list", { name: "4 attachments" }); + for (const name of [ + "project/README.md", + "project/src/index.ts", + "project/src/util.ts", + "project/test/index.ts", + ]) { + await expect( + stack.getByRole("button", { name: `Remove ${name}` }), + ).toHaveCount(1); + } + }); +} + +test("a drop on the picker inside the zone is taken once", async ({ page }) => { + await page.goto("/attachments"); + await page.locator("[data-testid=zone] label").evaluate((picker) => { + const data = new DataTransfer(); + data.items.add(new File(["hi"], "notes.txt", { type: "text/plain" })); + for (const type of ["dragenter", "dragover", "drop"]) { + picker.dispatchEvent( + new DragEvent(type, { + dataTransfer: data, + bubbles: true, + cancelable: true, + }), + ); + } + }); + await expect(count(page)).toHaveText("1"); + await expect(page.getByRole("status", { name: "Refused" })).toHaveText(""); + await expect( + page.locator("[data-slot=attachment-drop-zone-overlay]"), + ).toHaveCount(0); +}); + +test("files dropped outside the zone do not open in place of the page", async ({ + page, +}) => { + await page.goto("/attachments"); + const handled = await page.getByTestId("outside").evaluate((outside) => { + const data = new DataTransfer(); + data.items.add(new File(["stray"], "stray.txt", { type: "text/plain" })); + return ["dragenter", "dragover", "drop"].map((type) => { + const event = new DragEvent(type, { + dataTransfer: data, + bubbles: true, + cancelable: true, + }); + outside.dispatchEvent(event); + return { type, prevented: event.defaultPrevented }; + }); + }); + expect(handled).toEqual([ + { type: "dragenter", prevented: false }, + { type: "dragover", prevented: true }, + { type: "drop", prevented: true }, + ]); + await expect(page).toHaveURL(/\/attachments$/); + await expect(count(page)).toHaveText("0"); +}); diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 6a0f1bfc..1d1cd56e 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useState, Profiler } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, Profiler } from "react"; import type { ReactNode } from "react"; import { @@ -66,6 +66,17 @@ import { useHistoryWindow, ConversationMap, type ConversationMapTurn, + Attachment, + AttachmentDropZone, + AttachmentInfo, + FileInput, + AttachmentMore, + AttachmentPreview, + AttachmentRemove, + Attachments, + type AttachmentItem, + type AttachmentRejection, + useAttachments, } from "@corbits/react-ui"; import { CommandPalette, @@ -1358,6 +1369,94 @@ function ConversationMapScenario({ ); } +/** How many of each reason, e.g. "too-many 20". */ +function refusals(rejected: readonly AttachmentRejection[]): string { + const counts = new Map(); + for (const { reason } of rejected) { + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + return [...counts].map(([reason, count]) => `${reason} ${count}`).join(", "); +} + +/** A message box taking dropped files into a folded stack, as a chat would. */ +function AttachmentsScenario() { + const [attachments, setAttachments] = useState([]); + const [expanded, setExpanded] = useState(false); + const [refused, setRefused] = useState(""); + const [folders, setFolders] = useState(""); + const message = useRef(null); + const { add } = useAttachments({ + attachments, + maxFiles: 100, + maxFileBytes: 1024 * 1024, + }); + const folded = !expanded && attachments.length > 5; + const shown = folded ? attachments.slice(0, 4) : attachments; + const take = (files: Iterable | ArrayLike) => { + const { accepted, rejected } = add(files); + setAttachments((previous) => [...previous, ...accepted]); + setRefused(refusals(rejected)); + }; + return ( +
+
+ Outside the drop zone +
+ { + take(files); + setFolders(details.folders.join(",")); + }} + style={{ padding: "96px 16px 16px", width: 640 }} + > + {attachments.length === 0 ? null : ( + + {shown.map((item) => ( + + + + + setAttachments((previous) => + previous.filter((held) => held.id !== item.id), + ) + } + /> + + ))} + {folded ? ( + + ) : null} + + )} + {expanded ? ( + + ) : null} +