test: check upload conflicts, double-click, the no-preview pane, Undo and the share message in Files on both Stalwarts

This commit is contained in:
Linus Rath
2026-11-04 11:08:01 +01:00
parent e5dc1dd247
commit ac696225ba
+230
View File
@@ -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<string, unknown> | null };
async function nodes(c: JmapClient): Promise<Node[]> {
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<Node[]> {
return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX));
}
async function cleanUp(c: JmapClient): Promise<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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();
});
});