From 8b412dde766775759e30701be24f031f88176929 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:58:34 +0100 Subject: [PATCH] test: check drops, saved mail, .csv import and the row menu in Files on the server --- integration/tests/26-ux-p2-pim-files.spec.ts | 193 +++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 integration/tests/26-ux-p2-pim-files.spec.ts diff --git a/integration/tests/26-ux-p2-pim-files.spec.ts b/integration/tests/26-ux-p2-pim-files.spec.ts new file mode 100644 index 000000000..d712c554d --- /dev/null +++ b/integration/tests/26-ux-p2-pim-files.spec.ts @@ -0,0 +1,193 @@ +import { test, expect, type Page } from '@playwright/test'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability pass 2 in Files (pim-5, pim-p2-6, pim-p2-7, pim-p2-18) against a VM 106 + * Stalwart, 1.0 and 0.16. Every step that writes is read back with FileNode/get. + * + * - pim-p2-6: files dropped on the list go into the folder in view, files dropped on a + * folder's row into that folder, with no overlay and no question. + * - pim-p2-7: a saved .eml opens like a mail with "Import to a mail folder…"; a .csv + * offers "Import into contacts…", which opens the contacts import. + * - pim-5: at 1280×720 the ⋮ of the last row opens its menu beside the button, not over it. + * + * Account dso-ux-pim, password IT_DSO_PASSWORD. Every node made here is named + * "UXP2-files …" and removed afterwards. Nothing is imported. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const FILENODE = 'urn:ietf:params:jmap:filenode'; +const RUN = Date.now().toString(36); +const PREFIX = `UXP2-files ${RUN}`; + +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 }; + +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 { + // Files first, then folders: a folder with children may be refused. + for (let round = 0; round < 3; round++) { + const all = await ours(c); + if (all.length === 0) return; + const parents = new Set(all.map((n) => n.parentId)); + const leaves = all.filter((n) => !parents.has(n.id)).map((n) => n.id); + await c.request([['FileNode/set', { accountId: c.filesAccountId, destroy: leaves }, '0']], [CORE, FILENODE]); + } +} + +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; mimeType?: string }[]): Promise { + await page.locator('input[type="file"][multiple]:not([webkitdirectory])').first().setInputFiles( + files.map((f) => ({ name: f.name, mimeType: f.mimeType ?? 'text/plain', buffer: Buffer.from(f.text) })), + ); +} + +const row = (page: Page, name: string) => page.locator(`[data-resource="${name}"]`).first(); + +/** A drag from outside, as the browser sends it, ending in a drop on `target`. */ +async function dropFiles(page: Page, selector: string, files: { name: string; text: string }[]): Promise { + const transfer = await page.evaluateHandle((list) => { + const dt = new DataTransfer(); + for (const f of list) dt.items.add(new File([f.text], f.name, { type: 'text/plain' })); + return dt; + }, files); + const target = page.locator(selector).first(); + await target.dispatchEvent('dragenter', { dataTransfer: transfer }); + await target.dispatchEvent('dragover', { dataTransfer: transfer }); + // No choices on the Files screen. + await expect(page.getByTestId('file-drop-overlay')).toHaveCount(0); + await target.dispatchEvent('drop', { dataTransfer: transfer }); +} + +const EML = [ + 'From: Ben ', + 'To: dso-ux-pim@example.org', + `Subject: ${PREFIX} saved mail`, + 'Date: Tue, 03 Nov 2026 10:00:00 +0100', + 'Message-ID: ', + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Hello from a saved message.', + '', +].join('\r\n'); + +const CSV = 'First Name,Last Name,E-mail Address\r\nAnna,Example,anna.uxp2@example.org\r\n'; + +test.describe('Files, usability pass 2', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + const a = await JmapClient.connect(A, PASSWORD); + await cleanUp(a); + }); + + test('a drop uploads where it lands, with no question (pim-p2-6)', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const folderName = `${PREFIX} folder`; + const folderId = await a.createFileDirectory(folderName); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + await expect(row(page, folderName)).toBeVisible({ timeout: 30000 }); + + // On the list: the folder in view (the top of Files). + await dropFiles(page, '[role="grid"][aria-label="Files and folders"]', [{ name: `${PREFIX} on list.txt`, text: 'list' }]); + await expect.poll(async () => (await ours(a)).find((n) => n.name === `${PREFIX} on list.txt`)?.parentId ?? 'missing', { timeout: 30000 }) + .toBeNull(); + + // On the folder's row: into that folder. + await dropFiles(page, `[data-resource="${folderName}"]`, [{ name: `${PREFIX} on row.txt`, text: 'row' }]); + await expect.poll(async () => (await ours(a)).find((n) => n.name === `${PREFIX} on row.txt`)?.parentId ?? 'missing', { timeout: 30000 }) + .toBe(folderId); + await context.close(); + }); + + test('a saved mail opens as a mail, a .csv offers the contacts import (pim-p2-7)', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const eml = `${PREFIX} saved.eml`; + const csv = `${PREFIX} contacts.csv`; + 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: eml, text: EML, mimeType: 'message/rfc822' }, { name: csv, text: CSV, mimeType: 'text/csv' }]); + await expect.poll(async () => (await ours(a)).map((n) => n.name).filter((n) => n === eml || n === csv).sort(), { timeout: 30000 }) + .toEqual([csv, eml].sort()); + + await row(page, eml).dblclick(); + const preview = page.getByRole('dialog', { name: eml }); + await expect(preview).toBeVisible({ timeout: 30000 }); + await expect(preview.getByText(`${PREFIX} saved mail`).first()).toBeVisible({ timeout: 30000 }); + await expect(preview.getByRole('button', { name: 'Import to a mail folder…' })).toBeVisible(); + await expect(page.getByText('There is no preview for this type of file.')).toHaveCount(0); + await preview.getByRole('button', { name: 'Import to a mail folder…' }).click(); + await expect(page.getByTestId('import-eml-dialog')).toBeVisible({ timeout: 30000 }); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('import-eml-dialog')).toBeHidden(); + await page.keyboard.press('Escape'); + + await row(page, csv).dblclick(); + const csvPreview = page.getByRole('dialog', { name: csv }); + await expect(csvPreview.getByRole('button', { name: 'Import into contacts…' })).toBeVisible({ timeout: 30000 }); + await csvPreview.getByRole('button', { name: 'Import into contacts…' }).click(); + await expect(page).toHaveURL(/\/contacts/, { timeout: 30000 }); + await expect(page.getByRole('dialog', { name: 'Import contacts' })).toBeVisible({ timeout: 30000 }); + await page.keyboard.press('Escape'); + await context.close(); + }); + + test('the ⋮ of the last row opens beside its button at 1280×720 (pim-5)', async ({ browser }) => { + test.setTimeout(240_000); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1280, height: 720 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + const rows = page.locator('[role="grid"][aria-label="Files and folders"] [data-resource]'); + await expect(rows.first()).toBeVisible({ timeout: 30000 }); + const last = rows.last(); + await last.scrollIntoViewIfNeeded(); + await last.hover(); + const kebab = last.locator('[data-item-action][title]').last(); + const button = (await kebab.boundingBox())!; + await kebab.click(); + const menu = page.getByRole('menu').first(); + await expect(menu).toBeVisible(); + const box = (await menu.boundingBox())!; + const overlaps = box.y < button.y + button.height && box.y + box.height > button.y + && box.x < button.x + button.width && box.x + box.width > button.x; + expect(overlaps).toBe(false); + await page.keyboard.press('Escape'); + await context.close(); + }); +});