From ac696225ba71a200ac25585183dfcf2e04d4b8d2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:08:01 +0100 Subject: [PATCH] test: check upload conflicts, double-click, the no-preview pane, Undo and the share message in Files on both Stalwarts --- integration/tests/25-ux-pim-files.spec.ts | 230 ++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 integration/tests/25-ux-pim-files.spec.ts diff --git a/integration/tests/25-ux-pim-files.spec.ts b/integration/tests/25-ux-pim-files.spec.ts new file mode 100644 index 000000000..e3d4894bc --- /dev/null +++ b/integration/tests/25-ux-pim-files.spec.ts @@ -0,0 +1,230 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'node:path'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability fixes in Files (pim-3, pim-10, pim-16, pim-26) against a VM 106 + * Stalwart, 1.0 and 0.16. FileNode differs between them (1.0: draft 18 with + * nodeType and `@type`; 0.16: blobId == null marks a folder), so the server + * state is read back over JMAP after every step. + * + * - pim-10: uploading a name that exists asks Replace / Keep both / Skip. + * Replace keeps the node id and gives it a new blob, Keep both makes the + * "(1)" copy, Skip changes nothing; "Do this for all" answers the rest. + * - pim-3: a double-click opens the preview and it stays open. + * - pim-16: a file with no preview opens a pane with Download. + * - pim-26: delete has Undo, and sharing offers "Send a message" with a link. + * + * Account dso-ux-pim (and dso-ux-pim-b to share with), password + * IT_DSO_PASSWORD. Every node made here is named "UXFIX-files …" and removed. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const B = 'dso-ux-pim-b@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const FILENODE = 'urn:ietf:params:jmap:filenode'; +const RUN = Date.now().toString(36); +const PREFIX = `UXFIX-files ${RUN}`; +const SHOTS = process.env.IT_UXPIM_SHOTS; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Node = { id: string; name: string; parentId: string | null; blobId: string | null; shareWith?: Record | null }; + +async function nodes(c: JmapClient): Promise { + const r = await c.request([['FileNode/get', { accountId: c.filesAccountId, ids: null }, '0']], [CORE, FILENODE]); + return r.methodResponses[0][1].list as Node[]; +} + +async function ours(c: JmapClient): Promise { + return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX)); +} + +async function cleanUp(c: JmapClient): Promise { + // Our nodes wherever they are, the trash's batch folders included. + const all = await nodes(c); + const ids = all.filter((n) => n.name.startsWith(PREFIX)).map((n) => n.id); + if (ids.length) await c.request([['FileNode/set', { accountId: c.filesAccountId, destroy: ids }, '0']], [CORE, FILENODE]); + // The draft "Send a message" started. + const q = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: PREFIX } }, '0']]); + const drafts = q.methodResponses[0][1].ids as string[]; + if (drafts?.length) await c.request([['Email/set', { accountId: c.accountId, destroy: drafts }, '0']]); +} + +async function login(page: Page, email: string): Promise { + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + await page.fill('#username', email); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); +} + +async function openFilesRoot(page: Page): Promise { + await page.goto('/files'); + await expect(page.getByRole('grid', { name: 'Files and folders' }).first()).toBeVisible({ timeout: 60000 }); +} + +async function upload(page: Page, files: { name: string; text: string }[]): Promise { + await page.locator('input[type="file"][multiple]:not([webkitdirectory])').first().setInputFiles( + files.map((f) => ({ name: f.name, mimeType: 'text/plain', buffer: Buffer.from(f.text) })), + ); +} + +async function shot(page: Page, name: string): Promise { + if (SHOTS) await page.screenshot({ path: path.join(SHOTS, name) }); +} + +const row = (page: Page, name: string) => page.locator(`[data-resource="${name}"]`).first(); + +test.describe('Files (pim-3, pim-10, pim-16, pim-26)', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + const a = await JmapClient.connect(A, PASSWORD); + await cleanUp(a); + }); + + test('an upload over an existing name asks, and each answer shows on the server', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const name = `${PREFIX}.txt`; + const copy = `${PREFIX} (1).txt`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + + await upload(page, [{ name, text: 'one' }]); + await expect.poll(async () => (await ours(a)).map((n) => n.name), { timeout: 30000 }).toEqual([name]); + const [first] = await ours(a); + await expect(row(page, name)).toBeVisible({ timeout: 30000 }); + + // Replace: the same node, a new blob, no copy. + await upload(page, [{ name, text: 'two, longer' }]); + const dialog = page.getByRole('alertdialog', { name: `${name} already exists.` }); + await expect(dialog).toBeVisible({ timeout: 30000 }); + await expect(dialog.getByRole('checkbox')).toHaveCount(0); + await shot(page, 'pim-fix-10-after.png'); + await dialog.getByRole('button', { name: 'Replace', exact: true }).click(); + await expect.poll(async () => { + const list = await ours(a); + return list.map((n) => ({ id: n.id, name: n.name, newBlob: n.blobId !== first.blobId })); + }, { timeout: 30000 }).toEqual([{ id: first.id, name, newBlob: true }]); + const replaced = (await ours(a))[0]; + + // Keep both: the "(1)" copy, the original untouched. + await upload(page, [{ name, text: 'three' }]); + await dialog.getByRole('button', { name: 'Keep both' }).click(); + await expect.poll(async () => (await ours(a)).map((n) => n.name).sort(), { timeout: 30000 }).toEqual([copy, name].sort()); + expect((await ours(a)).find((n) => n.name === name)).toMatchObject({ id: first.id, blobId: replaced.blobId }); + const before = JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort()); + + // Skip: nothing changes. + await expect(row(page, copy)).toBeVisible({ timeout: 30000 }); + await upload(page, [{ name, text: 'four' }]); + await dialog.getByRole('button', { name: 'Skip' }).click(); + await expect(dialog).toBeHidden(); + // Two conflicts: "Do this for all 2 conflicts", and Skip answers both. + await upload(page, [{ name, text: 'five' }, { name: copy, text: 'six' }]); + await expect(dialog).toBeVisible(); + await dialog.getByRole('checkbox', { name: 'Do this for all 2 conflicts' }).check(); + await dialog.getByRole('button', { name: 'Skip' }).click(); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await page.waitForTimeout(3000); + expect(JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort())).toBe(before); + await context.close(); + }); + + test('double-click opens a preview that stays, a file with no preview gets a pane, delete has Undo', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const name = `${PREFIX}.txt`; + const zip = `${PREFIX}.bin`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + await upload(page, [{ name: zip, text: 'binary-ish' }]); + await expect(row(page, zip)).toBeVisible({ timeout: 30000 }); + + // pim-3: one click selects, a double-click opens, and it stays open. + await row(page, name).getByTitle(name).click(); + await expect(row(page, name)).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('dialog', { name })).toHaveCount(0); + await row(page, name).getByTitle(name).dblclick(); + await page.waitForTimeout(1000); + await expect(page.getByRole('dialog', { name })).toBeVisible(); + await shot(page, 'pim-fix-3-after.png'); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog', { name })).toHaveCount(0); + + // pim-16: no preview, no editor: the pane, not a download. + let downloaded = false; + page.on('download', () => { downloaded = true; }); + await row(page, zip).getByTitle(zip).dblclick(); + const pane = page.getByTestId('file-no-preview'); + await expect(pane).toBeVisible(); + await expect(pane).toContainText('There is no preview for this type of file.'); + await expect(pane.getByRole('button', { name: 'Download' })).toBeVisible(); + await shot(page, 'pim-fix-16-after.png'); + expect(downloaded).toBe(false); + await page.keyboard.press('Escape'); + + // pim-26: delete goes to the trash with Undo, and Undo puts it back. + await row(page, zip).click(); + await page.keyboard.press('Delete'); + const undo = page.getByRole('button', { name: 'Undo' }).last(); + await expect(undo).toBeVisible({ timeout: 30000 }); + await shot(page, 'pim-fix-26-undo-after.png'); + const zipId = (await ours(a)).find((n) => n.name === zip)!.id; + await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).not.toBeNull(); + await undo.click(); + await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).toBeNull(); + await expect(row(page, zip)).toBeVisible({ timeout: 30000 }); + await context.close(); + }); + + test('sharing with someone offers a message with a link to the file', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const b = await JmapClient.connect(B, PASSWORD); + const name = `${PREFIX}.txt`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + // Run on its own (-g), it makes the file the first test made. + if (!(await ours(a)).some((n) => n.name === name)) await upload(page, [{ name, text: 'one' }]); + await expect.poll(async () => (await ours(a)).some((n) => n.name === name), { timeout: 30000 }).toBe(true); + const node = (await ours(a)).find((n) => n.name === name)!; + await expect(row(page, name)).toBeVisible({ timeout: 30000 }); + + await row(page, name).getByTitle(name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: /^Share/ }).click(); + const shareDialog = page.getByRole('dialog').last(); + await shareDialog.getByRole('button', { name: 'Add person or group' }).click(); + await shareDialog.getByRole('textbox', { name: 'Search by name or email…' }).fill('dso-ux-pim-b'); + await shareDialog.getByRole('button', { name: /dso-ux-pim-b/ }).first().click(); + const send = page.getByRole('button', { name: 'Send a message' }); + await expect(send).toBeVisible({ timeout: 30000 }); + await expect.poll(async () => Object.keys((await nodes(a)).find((n) => n.id === node.id)?.shareWith ?? {}).length, { timeout: 30000 }).toBe(1); + await shot(page, 'pim-fix-26-share-after.png'); + await send.click(); + + // The composer, to b, with a link the person opens it by. + const link = `?shared=${encodeURIComponent(`${a.filesAccountId}:${node.id}`)}`; + await expect(page.getByText(B).first()).toBeVisible({ timeout: 60000 }); + await expect(page.locator('[contenteditable="true"], textarea').filter({ hasText: link }).first()).toBeVisible({ timeout: 30000 }); + await shot(page, 'pim-fix-26-compose-after.png'); + + // b sees the node under that id. + const shared = await b.request([['FileNode/get', { accountId: a.filesAccountId, ids: [node.id], properties: ['id', 'name'] }, '0']], [CORE, FILENODE]); + expect(shared.methodResponses[0][1].list?.[0]?.name).toBe(name); + await context.close(); + }); +});