diff --git a/web/workspace/src/lib/workspace/drive-ui/DriveIcon.svelte b/web/workspace/src/lib/workspace/drive-ui/DriveIcon.svelte new file mode 100644 index 000000000..c5e20ccec --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-ui/DriveIcon.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts b/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts index c2cd43f1f..bb8a2790b 100644 --- a/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts +++ b/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts @@ -74,6 +74,9 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe message: "Revision conflict", }, { status: 409 }); } + if (url.pathname.endsWith("/list")) { + return Response.json({ entries: [], next_after: null }); + } if (url.pathname.endsWith("/read-text")) { return Response.json({ entry: entry("2", "alpha", revision), @@ -126,3 +129,88 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe "my-name.txt", ); }); + +test("Folder picker blocks failed reads, filters files and self, and follows paged destinations", async () => { + let failList = true; + const mutations: DriveMutationRequest[] = []; + const folder = (id: string) => ({ + ...entry(id, "alpha", "7", "folder"), + name: `Folder ${id}`, + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const id = url.searchParams.get("id") ?? "1"; + if (url.pathname.endsWith("/mutate")) { + mutations.push(JSON.parse(String(init?.body))); + return Response.json({ + code: "conflict", + classification: "not_committed", + message: "Revision conflict", + }, { status: 409 }); + } + if (url.pathname.endsWith("/list")) { + if (id !== "1") return Response.json({ entries: [], next_after: null }); + if (failList) { + return Response.json( + { code: "unavailable", message: "Unavailable" }, + { status: 503 }, + ); + } + return Response.json( + url.searchParams.has("after") + ? { entries: [folder("5")], next_after: null } + : { + entries: [folder("2"), entry("3"), folder("4")], + next_after: "4", + }, + ); + } + return Response.json(folder(id)); + }), + ); + const view = render(DrivePage, { workspaceId: "alpha", nodeId: "2" }); + await waitFor(() => + expect(view.getByRole("button", { name: "Rename / move" })).toBeDefined() + ); + await fireEvent.click(view.getByRole("button", { name: "Rename / move" })); + await waitFor(() => + expect(view.getByRole("button", { name: "Retry folders" })).toBeDefined() + ); + expect( + view.getByRole("button", { name: "Apply rename / move" }).hasAttribute( + "disabled", + ), + ).toBe(true); + expect(mutations).toHaveLength(0); + failList = false; + await fireEvent.click(view.getByRole("button", { name: "Retry folders" })); + await waitFor(() => + expect(view.getByRole("button", { name: "Folder 4" })).toBeDefined() + ); + expect(view.queryByRole("button", { name: "Folder 2" })).toBeNull(); + expect(view.queryByRole("button", { name: "note.txt" })).toBeNull(); + await fireEvent.click(view.getByRole("button", { name: "More folders" })); + await waitFor(() => + expect(view.getByRole("button", { name: "Folder 5" })).toBeDefined() + ); + await fireEvent.click(view.getByRole("button", { name: "Folder 5" })); + await waitFor(() => + expect( + view.getByRole("button", { name: "Apply rename / move" }).hasAttribute( + "disabled", + ), + ).toBe(false) + ); + await fireEvent.click( + view.getByRole("button", { name: "Apply rename / move" }), + ); + await waitFor(() => expect(mutations).toHaveLength(1)); + expect(mutations[0].mutation).toMatchObject({ + operation: "relocate", + expected_revision: "7", + id: { workspace_id: "alpha", node_id: "2" }, + parent: { workspace_id: "alpha", node_id: "5" }, + }); +}); diff --git a/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte b/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte index df5ad5d74..a5455c109 100644 --- a/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte +++ b/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte @@ -6,6 +6,7 @@ import { driveHref } from '#lib/workspace/resource-links.ts'; import { workspaceRoute, formatDate } from '#lib/workspace/api/http.ts'; import DriveDocument from './DriveDocument.svelte'; + import DriveIcon from './DriveIcon.svelte'; import { previewKind, formatBytes, DRIVE_TEXT_MAX_BYTES } from './preview-policy.ts'; let { workspaceId, nodeId }: { workspaceId: string; nodeId?: string } = $props(); @@ -26,6 +27,17 @@ let searchText = $state(''); let includeText = $state(false); let copyState = $state(''); + let createMenu = $state(false); + let actionsMenu = $state(false); + let formDialog: HTMLDialogElement; + let returnFocus: HTMLElement | null = null; + let destination = $state(null); + let destinationFolders = $state([]); + let destinationNext = $state(null); + let destinationBusy = $state(false); + let destinationError = $state(null); + let destinationEpoch = 0; + let destinationAbort: AbortController | null = null; let lifetime = 0; let breadcrumbEpoch = 0; let entry = $derived($controller.entry); @@ -42,7 +54,8 @@ const ws = workspaceId; const id = nodeId; ++lifetime; - form = null; formEntry = null; editing = false; localError = null; file = null; searchText = ''; copyState = ''; + ++destinationEpoch; destinationAbort?.abort(); + form = null; formEntry = null; createMenu = false; actionsMenu = false; editing = false; localError = null; file = null; searchText = ''; copyState = ''; try { void controller.select(ws, id ? { workspace_id: ws, node_id: parseDriveDecimal(id) } : undefined); } catch { void controller.select(null); localError = 'Invalid Drive node ID.'; } @@ -82,7 +95,14 @@ imageUrl = url; return () => { URL.revokeObjectURL(url); }; }); - onDestroy(() => { ++lifetime; ++breadcrumbEpoch; controller.dispose(); }); + $effect(() => { + if (!formDialog) return; + if (form && !formDialog.open) { + formDialog.showModal(); + formDialog.querySelector('input')?.focus(); + } else if (!form && formDialog.open) formDialog.close(); + }); + onDestroy(() => { ++lifetime; ++breadcrumbEpoch; ++destinationEpoch; destinationAbort?.abort(); controller.dispose(); }); beforeNavigate(({ to, cancel }) => { // Within the optional Drive route the controller keeps per-identity drafts. // Leaving it destroys this session store; make that loss an explicit decision. @@ -91,9 +111,46 @@ }); function openForm(next: typeof form): void { if (!entry || !canWrite) return; + const active = document.activeElement; + returnFocus = active instanceof HTMLElement ? active.closest('details')?.querySelector('summary') ?? active : null; + createMenu = false; actionsMenu = false; form = next; formEntry = entry; localError = null; file = null; newText = ''; name = next === 'relocate' ? entry.name : next === 'text' ? 'document.md' : ''; parentId = entry.parent?.node_id ?? entry.entry.node_id; + destination = null; destinationFolders = []; destinationNext = null; destinationError = null; + if (next === 'relocate') void loadDestination(entry.parent ?? undefined); + } + function closeForm(): void { + if (pending) return; + ++destinationEpoch; destinationAbort?.abort(); + form = null; + } + async function loadDestination(ref?: DriveEntry['entry'], append = false): Promise { + const epoch = ++destinationEpoch; + const ws = workspaceId; + const snapshot = formEntry; + destinationAbort?.abort(); + destinationAbort = new AbortController(); + const signal = destinationAbort.signal; + destinationBusy = true; destinationError = null; + try { + const folder = ref ? await client.metadata(ws, ref, signal) : await client.root(ws, signal); + if (folder.kind !== 'folder') throw new Error('Choose a folder.'); + const result = await client.list(ws, folder.entry, { limit: 100, after: append ? destinationNext ?? undefined : undefined }, signal); + if (epoch !== destinationEpoch || ws !== workspaceId || form !== 'relocate' || snapshot !== formEntry) return; + destination = folder; parentId = folder.entry.node_id; + const folders = result.entries.filter((item) => item.kind === 'folder' && item.entry.node_id !== snapshot?.entry.node_id); + destinationFolders = append ? [...destinationFolders, ...folders] : folders; + destinationNext = result.next_after; + } catch { + if (epoch === destinationEpoch && !signal.aborted) destinationError = 'Folders could not be loaded. Retry before moving this item.'; + } finally { + if (epoch === destinationEpoch) destinationBusy = false; + } + } + function typeLabel(item: DriveEntry): string { + const type = previewKind(item); + return type === 'folder' ? 'Folder' : type === 'markdown' ? 'Markdown' : type === 'text' ? 'Text document' : type === 'image' ? 'Image' : 'File'; } async function action(run: () => Promise): Promise { const epoch = lifetime; @@ -118,6 +175,7 @@ operation: 'create', parent: current.entry, name: file.name, content_type: file.type || 'application/octet-stream', }); } else if (selectedForm === 'relocate') { + if (!destination || destinationBusy || destinationError) throw new Error('Choose an available destination folder.'); requestId = await controller.mutate({ operation: 'relocate', id: current.entry, expected_revision: current.revision, parent: { workspace_id: workspaceId, node_id: parseDriveDecimal(parentId) }, name }); } else { requestId = await controller.mutate(selectedForm === 'folder' ? { operation: 'create_folder', parent: current.entry, name } : { operation: 'create_text', parent: current.entry, name, text: newText, content_type: 'text/markdown' }); @@ -162,132 +220,34 @@ } -
-
-
-

{entry?.parent ? entry.name : 'Drive'}

-

Shared files · latest only. Saving replaces the current content; there is no version history.

-
- -
- - {#if breadcrumbError}

{breadcrumbError}

{/if} + { + if (!(event.target instanceof Element) || !event.target.closest('.drive-menu')) { + createMenu = false; actionsMenu = false; + } +}} onkeydown={(event) => { + if (event.key === 'Escape' && !form) { + const menu = document.activeElement?.closest('details.drive-menu'); + if (menu?.open) { + menu.open = false; + menu.querySelector('summary')?.focus(); + event.preventDefault(); + } + createMenu = false; actionsMenu = false; + } +}} /> + +{#snippet notices()} {#if localError}{/if} - {#if $controller.error}{/if} - {#if denied}

Read only / access denied by Backend. Editing and upload are unavailable.

{/if} - {#if $controller.loading}

Loading Drive…

{/if} - {#if entry} - - {#if entry.parent} -
- {#if entry.kind === 'file'}Download{/if} - - {#if canEdit}{/if} - {#if canWrite} - - {#if entry.kind === 'file'}{/if} - - {/if} -
-

{driveHref(entry.entry)} {copyState}

- {/if} - {#if kind === 'folder'} -
- {#if canWrite} - - - - {/if} -
- - {#if $controller.search}

Search results across this Drive: {$controller.search.query}

{/if} - {#if !$controller.loading && !$controller.error && !$controller.entries.length}

{$controller.search ? 'No matching files.' : 'This folder is empty.'}

{/if} - {#if $controller.entries.length} - -
- - - - {#each $controller.entries as item (item.entry.node_id)} - - {/each} - -
NameKind / sizeUpdated
{item.name}{item.kind} · {formatBytes(item.size)}{formatDate(item.updated_at)}
{item.updated_by}
-
- {/if} - {#if $controller.nextAfter}{/if} - {:else if kind === 'markdown' || kind === 'text'} - {#if $controller.truncated}

Preview truncated at 64 KiB. Download the full file; partial content cannot be saved.

{/if} - {#if draft?.conflict} - - {/if} - {#if editing && draft} -
- -
- {#if canWrite}{/if} - - {dirty ? 'Unsaved draft' : 'Saved content'} · {new TextEncoder().encode(draft.text).length} / 65536 bytes -
-
- {/if} - {#if $controller.text !== null} -
- {#if $controller.text === ''}

Empty file.

{:else if kind === 'markdown'}{:else}
{$controller.text}
{/if} -
- {/if} - {:else if kind === 'image'} - {#if (entry.size ?? Infinity) > DRIVE_RESPONSE_MAX_BYTES}

Image exceeds the 256 KiB preview limit. Download to view.

- {:else if imageFailed} - {:else if imageUrl} - {#key imageUrl}{entry.name} { if (event.currentTarget.getAttribute('src') === imageUrl) imageFailed = true; }} />{/key} - {/if} - {:else if kind === 'download'}

This file type is download-only. HTML, SVG and other active content are never rendered here.

{/if} - {/if} - {#if form && entry} -
{ event.preventDefault(); void submitForm(); }}> -

{form === 'relocate' ? 'Rename / move' : form === 'replace' ? 'Replace file' : form === 'upload' ? 'Upload file' : form === 'folder' ? 'New folder' : 'New Markdown'}

- {#if form === 'replace' || form === 'relocate'}

Expected revision {formEntry?.revision}. Refresh does not retarget this form to a newer revision.

{/if} - {#if form === 'upload' || form === 'replace'} - - {#if file}

{file.name} · {formatBytes(file.size)}

{/if} - {:else} - - {#if form === 'relocate'} - - -

Use the folder's stable URL ID. Rename / move updates DB metadata, not a copy of the file.

- {/if} - {#if form === 'text'}{/if} - {/if} -
- - {#if pending}{/if} - -
-
- {/if} + {#if $controller.error}{/if} + {#if denied}

Read only / access denied by Backend. Editing and upload are unavailable.

{/if} +{/snippet} + +{#snippet receipts()} {#if $controller.receipts.length}
{#each $controller.receipts as receipt (receipt.requestId)} -
- {receipt.operation}: {receipt.state === 'committed' ? 'Published — DB commit confirmed' : receipt.state === 'pending' ? 'Transferring / awaiting DB publication' : receipt.state === 'unknown' ? 'Outcome unknown — do not reupload' : 'Not committed'} +
+ {receipt.state === 'committed' ? 'Published — DB commit confirmed' : receipt.state === 'pending' ? 'Transferring / awaiting DB publication' : receipt.state === 'unknown' ? 'Outcome unknown — do not reupload' : 'Not committed'} {#if receipt.error}

{receipt.error}

{/if} {#if receipt.state === 'unknown'}{/if}
Request ID{receipt.requestId}
@@ -295,43 +255,300 @@ {/each}
{/if} +{/snippet} + +
+
+
+ +

{entry?.parent ? entry.name : 'Drive'}

+

{kind === 'folder' || !entry ? 'Files shared with your workspace.' : `${typeLabel(entry)} · ${formatBytes(entry.size)}`}

+
+
+ + {#if entry && kind === 'folder' && canWrite} +
+ New +
+ + +
+
+ + {/if} + {#if entry?.parent} + {#if entry.kind === 'file'}Download{/if} + {#if canEdit}{/if} +
+ +
+ + {#if canWrite} + + {#if entry.kind === 'file'}{/if} + + {/if} +
+
+ {/if} +
+
+ {#if breadcrumbError}

{breadcrumbError}

{/if} + {#if !form}{@render notices()}{/if} + {#if copyState}

{copyState} {#if entry}Latest file link{/if}

{/if} + {#if $controller.loading}

Loading Drive…

{/if} + {#if entry} + {#if kind === 'folder'} +
+ +
+ {$controller.search ? `Results for “${$controller.search.query}”` : 'Files and folders'} + {$controller.entries.length}{$controller.nextAfter ? '+' : ''} items +
+ {#if !$controller.loading && !$controller.error && !$controller.entries.length} +
+ +

{$controller.search ? 'No matching files.' : 'This folder is empty.'}

+

{$controller.search ? 'Try a different name, or include file contents in your search.' : 'Upload a file or create a document to start sharing.'}

+
+ {/if} + {#if $controller.entries.length} + +
+ + + + {#each $controller.entries as item (item.entry.node_id)} + + + + + + + {/each} + +
NameSizeModifiedOpen
+ + {item.name}{typeLabel(item)} + {item.kind === 'folder' ? '—' : formatBytes(item.size)}{formatDate(item.updated_at)}
+
+ {/if} + {#if $controller.nextAfter}{/if} +
+ {:else if kind === 'markdown' || kind === 'text'} + {#if $controller.truncated}

Preview truncated at 64 KiB. Download the full file; partial content cannot be saved.

{/if} + {#if draft?.conflict} + + {/if} +
+ {#if editing && draft} +
+ +
+ {#if canWrite}{/if} + + {dirty ? 'Unsaved draft' : 'Saved content'} · {formatBytes(new TextEncoder().encode(draft.text).length)} / 64 KiB +
+
+ {/if} + {#if $controller.text !== null} +
+ {#if editing}

Saved version

{/if} + {#if $controller.text === ''}

Empty file.

{:else if kind === 'markdown'}{:else}
{$controller.text}
{/if} +
+ {/if} +
+ {:else if kind === 'image'} +
+ {#if (entry.size ?? Infinity) > DRIVE_RESPONSE_MAX_BYTES}

Image exceeds the 256 KiB preview limit. Download to view.

+ {:else if imageFailed} + {:else if imageUrl} + {#key imageUrl}{entry.name} { if (event.currentTarget.getAttribute('src') === imageUrl) imageFailed = true; }} />{/key} + {/if} +
+ {:else if kind === 'download'} +

No preview available

This file type is download-only. HTML, SVG and other active content are never rendered here.

Download file
+ {/if} + {#if entry.parent} +
Details +
Updated
{formatDate(entry.updated_at)}
By
{entry.updated_by}
Revision
{entry.revision}
+

Saving replaces the current content. There is no version history.

+
+ {/if} + {/if} + {#if !form}{@render receipts()}{/if}
+ { event.preventDefault(); closeForm(); }} onclose={() => { form = null; if (returnFocus?.isConnected) returnFocus.focus(); }}> + {#if form && entry} +
{ event.preventDefault(); void submitForm(); }}> +
+

{form === 'relocate' ? 'Rename / move' : form === 'replace' ? 'Replace file' : form === 'upload' ? 'Upload file' : form === 'folder' ? 'New folder' : 'New Markdown'}

{form === 'replace' || form === 'relocate' ? formEntry?.name : `In ${entry.parent ? entry.name : 'Drive'}`}

+ +
+ {@render notices()} + {#if form === 'upload' || form === 'replace'} + + {#if file && file.size > DRIVE_FILE_MAX_BYTES}{/if} + {:else} + + {#if form === 'relocate'} +
+ Destination folder +
{destination ? destination.parent ? destination.name : 'Drive' : 'Loading…'} + + {#if destination?.parent}{/if} +
+ {#if destinationError}{/if} + {#if destinationBusy}

Loading folders…

{/if} +
+ {#each destinationFolders as folder (folder.entry.node_id)}{/each} + {#if !destinationBusy && !destinationError && !destinationFolders.length}

No subfolders. This folder will be used.

{/if} + {#if destinationNext}{/if} +
+
+ {/if} + {#if form === 'text'}{/if} + {/if} + {#if form === 'replace' || form === 'relocate'}

Expected revision {formEntry?.revision}. Changes by another writer will not be overwritten.

{/if} + {#if form === 'replace'}

This replaces the current file. There is no version history.

{/if} +
+ + {#if pending}{/if} + +
+ {@render receipts()} +
+ {/if} +
+ diff --git a/web/workspace/test/drive-browser/README.md b/web/workspace/test/drive-browser/README.md index 9e808a2db..d02e9d898 100644 --- a/web/workspace/test/drive-browser/README.md +++ b/web/workspace/test/drive-browser/README.md @@ -47,4 +47,6 @@ Home/sidebar and Workers are captured under identical owner/member/error/empty/l The production shell scrolls `main` internally, so `fullPage` does not expand that scroll panel. Capture points use ordinary keyboard focus to show the actual form or the top denial state; no CSS/DOM screenshot override is applied. The responsive audit separately records document/main/table-region geometry, visible keyboard focus, and page errors across the full theme/viewport matrix. Raster checks wait for actual decoding, assert natural dimensions 1200×800, preserve the 3:2 aspect ratio, and constrain the rendered box by both the available width and 60vh height at all four widths/both themes. +The Drive browser suite also exercises the file-browser redesign at 1440/768/390/320px in light/dark themes: menu placement, native-dialog focus and Escape restoration, folder creation, search, and destination-folder navigation. Screenshots are written to `.svelte-kit/drive-redesign/` for the list, New menu, creation dialog, Markdown preview, and move dialog. Component tests cover failed/paged destination reads, exclusion of files and the moving folder itself, and pinned mutation revisions. These checks use the same fixture boundary, not a live Backend. + Parent and reviewer must open images and inspect context before asserting visual acceptance. In particular, a successful capture command is not sufficient and HTTP 200 does not excuse visible UI errors. diff --git a/web/workspace/test/drive-browser/capture-visual.ts b/web/workspace/test/drive-browser/capture-visual.ts index ac15748f8..fd0d45e41 100644 --- a/web/workspace/test/drive-browser/capture-visual.ts +++ b/web/workspace/test/drive-browser/capture-visual.ts @@ -68,6 +68,7 @@ for (const scheme of ["light", "dark"]) { label: "Rename / move pinned revision", fullPage: true, interaction: [ + { action: "click", selector: '.drive-menu > summary[aria-label="More actions"]' }, { action: "click", selector: '.drive-toolbar button:has-text("Rename / move")' }, { action: "wait", @@ -82,6 +83,7 @@ for (const scheme of ["light", "dark"]) { fullPage: true, interaction: [ { action: "click", selector: '.drive-form button:has-text("Cancel form")' }, + { action: "click", selector: '.drive-menu > summary[aria-label="More actions"]' }, { action: "click", selector: '.drive-toolbar button:has-text("Replace file")' }, { action: "wait", @@ -244,6 +246,7 @@ for (const scheme of ["light", "dark"]) { label: "Member after typed write denial", fullPage: true, interaction: [ + { action: "click", selector: '.drive-create-menu > summary' }, { action: "click", selector: '.drive-page button:has-text("New folder")', @@ -261,12 +264,12 @@ for (const scheme of ["light", "dark"]) { action: "wait", ready: { kind: "selector", - selector: '.drive-page:has-text("Read only / access denied by Backend")', + selector: '.drive-dialog:has-text("Read only / access denied by Backend")', }, }, { action: "press", - selector: ".drive-heading button", + selector: '.drive-dialog button[aria-label="Close dialog"]', key: "Shift", }, ], diff --git a/web/workspace/test/drive-browser/drive.browser.test.ts b/web/workspace/test/drive-browser/drive.browser.test.ts index 6b5d09f5f..ed007aa2c 100644 --- a/web/workspace/test/drive-browser/drive.browser.test.ts +++ b/web/workspace/test/drive-browser/drive.browser.test.ts @@ -5,10 +5,14 @@ import { chromium, type Page, type Route } from "playwright"; import { Buffer } from "node:buffer"; import type { FixtureLog } from "../../../../tools/web-ux/drive-fixture/api.ts"; const root = join(dirname(fromFileUrl(import.meta.url)), "../../../.."); -const ready = (page: Page) => page.locator('[data-drive-ready="true"]').waitFor(); -const button = (page: Page, name: string) => page.getByRole("button", { name, exact: true }); -const draft = (page: Page) => page.getByRole("textbox", { name: /^Draft \(expected revision/ }); -const preview = (page: Page) => page.getByRole("article", { name: "Current saved content" }); +const ready = (page: Page) => + page.locator('[data-drive-ready="true"]').waitFor(); +const button = (page: Page, name: string) => + page.getByRole("button", { name, exact: true }); +const draft = (page: Page) => + page.getByRole("textbox", { name: /^Draft \(expected revision/ }); +const preview = (page: Page) => + page.getByRole("article", { name: "Current saved content" }); function gate() { let release!: () => void; const promise = new Promise((resolve) => release = resolve); @@ -180,7 +184,9 @@ Deno.test("production Drive two tabs retain losing CAS draft and never retry wit await draft(second).inputValue(), "Second actor unsaved draft", ); - const casWrites = (await logs(origin)).filter((x) => x.path === "/mutate"); + const casWrites = (await logs(origin)).filter((x) => + x.path === "/mutate" + ); assertEquals(casWrites.map((x) => [x.nodeId, x.expectedRevision]), [[ "3", "1", @@ -235,7 +241,7 @@ Deno.test("production Drive delayed old read cannot replace another file or work await ready(page); assert((await preview(page).textContent())!.includes("Second document")); assertEquals( - await page.locator("main .drive-url a").getAttribute("href"), + await page.locator(".drive-details dd a").getAttribute("href"), "/w/home-member/drive/4", ); } finally { @@ -321,7 +327,9 @@ Deno.test("production Drive delayed old save and status preserve destination dra await button(page, "Save").click(); await page.getByText("Published — DB commit confirmed", { exact: false }) .last().waitFor(); - const pinnedWrites = (await logs(origin)).filter((x) => x.path === "/mutate"); + const pinnedWrites = (await logs(origin)).filter((x) => + x.path === "/mutate" + ); assertEquals(pinnedWrites.map((x) => [x.nodeId, x.expectedRevision]), [ ["3", "1"], ["4", "1"], @@ -342,12 +350,15 @@ Deno.test("production Drive rename move delete recreate preserve stable identity try { await page.goto(`${origin}/w/home-owner/drive/3`); await ready(page); + await page.getByLabel("More actions", { exact: true }).click(); await button(page, "Rename / move").click(); await page.getByRole("textbox", { name: "Name", exact: true }).fill( "renamed.md", ); - await page.getByRole("textbox", { name: "Destination folder node ID" }) - .fill("2"); + await page.getByRole("dialog").getByRole("button", { + name: "資料", + exact: true, + }).click(); await button(page, "Apply rename / move").click(); await page.getByRole("heading", { name: "renamed.md", exact: true }) .waitFor(); @@ -361,6 +372,7 @@ Deno.test("production Drive rename move delete recreate preserve stable identity await ready(page); await page.getByRole("link", { name: "renamed.md", exact: true }).click(); await ready(page); + await page.getByLabel("More actions", { exact: true }).click(); await button(page, "Delete").click(); await page.getByRole("alert").filter({ hasText: /not[_ ]found/i }) .waitFor(); @@ -368,6 +380,8 @@ Deno.test("production Drive rename move delete recreate preserve stable identity await rootFolder(page); await page.getByRole("link", { name: "資料", exact: true }).click(); await ready(page); + await page.locator(".drive-menu > summary").filter({ hasText: /^New$/ }) + .click(); await button(page, "New Markdown").click(); await page.getByRole("textbox", { name: "Name", exact: true }).fill( "renamed.md", @@ -445,6 +459,7 @@ Deno.test("production Drive upload response loss and navigation cancellation que await transmitted.promise; await button(page, "Cancel transfer / check outcome").click(); await button(page, "Check request result").waitFor(); + await button(page, "Cancel form").click(); await page.getByRole("link", { name: "資料", exact: true }).click(); await page.getByRole("heading", { name: "資料", exact: true }).waitFor(); await ready(page); @@ -607,20 +622,26 @@ Deno.test("production Drive denial empty pages truncation unsafe content and ima await page.getByText("This file type is download-only.", { exact: false }) .waitFor(); assertEquals( - await page.locator("main svg, main iframe, main object").count(), + await page.locator( + "main svg:not([aria-hidden]), main iframe, main object", + ).count(), 0, ); await page.goto(`${origin}/w/home-owner/drive/5`); await ready(page); await page.waitForFunction(() => { const image = document.querySelector("main .drive-image"); - return image instanceof HTMLImageElement && image.complete && image.naturalWidth === 1200 && + return image instanceof HTMLImageElement && image.complete && + image.naturalWidth === 1200 && image.naturalHeight === 800; }); assertEquals( - await page.getByRole("img", { name: "landscape.png", exact: true }).evaluate((image) => - image instanceof HTMLImageElement ? [image.naturalWidth, image.naturalHeight] : [] - ), + await page.getByRole("img", { name: "landscape.png", exact: true }) + .evaluate((image) => + image instanceof HTMLImageElement + ? [image.naturalWidth, image.naturalHeight] + : [] + ), [1200, 800], ); await page.goto(`${origin}/w/home-owner/drive/7`); @@ -713,3 +734,154 @@ Deno.test("production Drive Markdown safe image placeholders preserve each image await page.close(); } })); + +Deno.test("production Drive menus dialogs folder picker and search remain usable at every viewport", () => + harness(async (origin, browser) => { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390, 320]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + const capture = async (surface: string) => { + const geometry = await page.evaluate(() => { + const main = document.querySelector("main")!; + const dialog = document.querySelector("dialog[open]"); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + main: main.clientWidth, + mainScroll: main.scrollWidth, + dialog: dialog?.clientWidth, + dialogScroll: dialog?.scrollWidth, + }; + }); + assert(geometry.document <= width, JSON.stringify(geometry)); + assert( + geometry.mainScroll <= geometry.main + 1, + JSON.stringify(geometry), + ); + if (geometry.dialog) { + assert( + geometry.dialogScroll! <= geometry.dialog + 1, + JSON.stringify(geometry), + ); + } + await page.screenshot({ + path: join( + root, + `web/workspace/.svelte-kit/drive-redesign/${surface}-${colorScheme}-${width}.png`, + ), + }); + }; + try { + const reset = await fetch(`${origin}/__fixture/reset`, { + method: "POST", + }); + await reset.body?.cancel(); + await page.goto(`${origin}/w/home-owner/drive`); + await ready(page); + const newMenu = page.locator(".drive-menu > summary").filter({ + hasText: /^New$/, + }); + assertEquals(await button(page, "New folder").isVisible(), false); + await capture("files"); + await newMenu.press("Enter"); + await button(page, "New folder").waitFor(); + const menuBox = await page.locator( + ".drive-create-menu .drive-menu-panel", + ).boundingBox(); + assert( + menuBox && menuBox.x >= 0 && menuBox.x + menuBox.width <= width, + JSON.stringify(menuBox), + ); + await capture("menu"); + await button(page, "New folder").click(); + const dialog = page.getByRole("dialog", { + name: "New folder", + exact: true, + }); + await dialog.waitFor(); + assert( + await dialog.getByLabel("Name", { exact: true }).evaluate((el) => + el === document.activeElement + ), + ); + await capture("create"); + for (let i = 0; i < 8; i++) { + await page.keyboard.press("Tab"); + // Native dialogs may cycle through browser chrome (reported as body), + // but background page controls must stay inert. + assert( + await dialog.evaluate((el) => + el.contains(document.activeElement) || + document.activeElement === document.body + ), + "Modal must not focus background controls", + ); + } + await page.keyboard.press("Escape"); + await dialog.waitFor({ state: "hidden" }); + await newMenu.locator(":scope:focus").waitFor(); + await newMenu.press("Enter"); + await button(page, "New folder").focus(); + await page.keyboard.press("Escape"); + assertEquals(await button(page, "New folder").isVisible(), false); + await newMenu.locator(":scope:focus").waitFor(); + await newMenu.press("Enter"); + await button(page, "New folder").click(); + const folderName = `Created ${colorScheme} ${width}`; + await dialog.getByLabel("Name", { exact: true }).fill(folderName); + await button(page, "Create").click(); + await dialog.waitFor({ state: "hidden" }); + await page.getByRole("link", { name: folderName, exact: true }) + .waitFor(); + await page.getByRole("searchbox", { name: "Search Drive" }).fill( + folderName, + ); + await button(page, "Search").click(); + await button(page, "Show folder").waitFor(); + await ready(page); + assertEquals(await page.locator(".drive-table tbody tr").count(), 1); + await button(page, "Show folder").click(); + await ready(page); + await page.getByRole("link", { name: "README.md", exact: true }) + .click(); + await page.waitForURL("**/w/home-owner/drive/3"); + await ready(page); + await preview(page).waitFor(); + await capture("preview"); + const more = page.getByLabel("More actions", { exact: true }); + await more.click(); + await button(page, "Rename / move").click(); + const move = page.getByRole("dialog", { + name: "Rename / move", + exact: true, + }); + await move.getByRole("button", { name: "資料", exact: true }).click(); + await page.locator(".drive-folder-location strong").filter({ + hasText: "資料", + }).waitFor(); + await capture("move"); + await button(page, "Parent folder").click(); + await move.getByRole("button", { name: "資料", exact: true }) + .waitFor(); + assertEquals( + await move.getByRole("button", { name: "README.md", exact: true }) + .count(), + 0, + ); + await page.keyboard.press("Escape"); + await move.waitFor({ state: "hidden" }); + await more.locator(":scope:focus").waitFor(); + assertEquals(errors, []); + } finally { + await context.close(); + } + } + } + }));