From f5c97c19974e6ed0b450261e486ab017cb18bc90 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:25:28 +0100 Subject: [PATCH] test: write a draft offline with a file, and read a kept file offline, on both servers --- integration/tests/22-ent-offline.spec.ts | 159 ++++++++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index ff4dcd3cf..a546a3cef 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -2,6 +2,7 @@ import { test, expect, type Browser, type BrowserContext, type Page } from '@pla import { execSync } from 'node:child_process'; import { ENT, + ENT_PASSWORD, adminContext, deliver, getPolicy, @@ -12,7 +13,7 @@ import { shot, userPage, } from './helpers/ent'; -import { accountSwitcher } from './helpers/app'; +import { accountSwitcher, openFolder } from './helpers/app'; import { JMAP_URL } from './helpers/config'; /** @@ -27,6 +28,9 @@ import { JMAP_URL } from './helpers/config'; * - a change the server can no longer take shows as changed on another device; * - the Trash emptied offline is emptied on the server after; * - a send whose response got lost is looked up and goes out once; + * - a draft written offline with a file survives a reload without a network, + * is on the server once after reconnecting, with the file, and can be sent; + * - a kept message shows its inline image and gives its attachment offline; * - a sign-out the user starts asks first when unsent mail would be lost, * then wipes the device; the idle sign-out wipes it and the login page * names the lost message; @@ -432,6 +436,159 @@ test('a send whose response got lost goes out once', async ({ browser }) => { await context.close(); }); +interface ServerDraft { id: string; subject: string; blobId: string; attachments: Array<{ blobId: string; name: string; size: number }> } + +/** The account's Drafts folder as the server has it, for one subject. */ +async function serverDrafts(account: string, subject: string): Promise { + const client = await jmapAs(account); + const boxes = await client.request([['Mailbox/get', { accountId: client.accountId, properties: ['id', 'role'] }, '0']]); + const drafts = (boxes.methodResponses[0][1].list as Array<{ id: string; role: string | null }>).find((box) => box.role === 'drafts'); + const r = await client.request([ + ['Email/query', { accountId: client.accountId, filter: { inMailbox: drafts?.id }, limit: 200 }, '0'], + ['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject', 'blobId', 'attachments'] }, '1'], + ]); + return (r.methodResponses[1][1].list as ServerDraft[]).filter((email) => email.subject === subject); +} + +test('a draft written offline with a file survives a reload, is on the server once after reconnecting, and can be sent', async ({ browser }) => { + const subject = `Offline draft ${RUN}`; + const fileText = `Attached offline ${RUN}`; + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page); + const context = page.context(); + await context.setOffline(true); + + await compose(page, ENT.bob, subject, `First words ${RUN}`); + const composer = page.locator('[data-testid="email-composer"]'); + await composer.locator('input[type="file"]').first().setInputFiles({ name: 'offline-note.txt', mimeType: 'text/plain', buffer: Buffer.from(fileText) }); + await expect(composer).toContainText('offline-note.txt', { timeout: 15_000 }); + await expect(composer.getByText('Saved on this device').first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'f-offline-draft'); + // Saved again and again: still one draft in the end. + for (const word of [' second', ' third', ' last']) { + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.press('Control+End'); + await page.keyboard.type(word); + await expect(composer.getByTestId('composer-save-status').first()).toHaveAttribute('data-status', 'saved', { timeout: 30_000 }); + await page.waitForTimeout(2500); + } + await page.getByTestId('composer-close').first().click(); + await expect(composer).toBeHidden({ timeout: 30_000 }); + expect(await serverDrafts(ENT.alice, subject)).toHaveLength(0); + + // A start without a network: it's in Drafts, with its text and its file. + await page.reload(); + await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + await openFolder(page, { role: 'drafts' }); + const row = page.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: subject }).first(); + await expect(row).toBeVisible({ timeout: 60_000 }); + await row.click(); + await page.getByTestId('edit-draft').click(); + await expect(composer).toBeVisible({ timeout: 20_000 }); + await expect(composer.locator('[contenteditable="true"]').first()).toContainText('last', { timeout: 20_000 }); + await expect(composer).toContainText('offline-note.txt'); + await page.getByTestId('composer-close').first().click(); + await expect(composer).toBeHidden({ timeout: 30_000 }); + expect(await serverDrafts(ENT.alice, subject)).toHaveLength(0); + + // Back online: one draft, with the latest text and the file. + await context.setOffline(false); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject)).length, { timeout: 180_000, intervals: [2000] }).toBe(1); + await page.waitForTimeout(5000); + const [draft] = await serverDrafts(ENT.alice, subject); + expect(await serverDrafts(ENT.alice, subject)).toHaveLength(1); + expect(draft.attachments.map((part) => part.name)).toEqual(['offline-note.txt']); + expect(await rawMessage(ENT.alice, draft.attachments[0].blobId)).toContain(fileText); + expect(await rawMessage(ENT.alice, draft.blobId)).toContain('last'); + + // And it goes out from there, with the file. + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + await openFolder(page, { role: 'drafts' }); + const again = page.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: subject }).first(); + await expect(again).toBeVisible({ timeout: 60_000 }); + await again.click(); + await page.getByTestId('edit-draft').click(); + await expect(composer).toContainText('offline-note.txt', { timeout: 20_000 }); + await send(page); + await expect(composer).toBeHidden({ timeout: 30_000 }); + const got = await waitForMessage(ENT.bob, (m) => m.subject === subject); + expect(await rawMessage(ENT.bob, got.blobId)).toContain('offline-note.txt'); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject)).length, { timeout: 30_000 }).toBe(0); + await context.close(); +}); + +const ONE_PIXEL = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEAQH/7yMK/gAAAABJRU5ErkJggg==', 'base64'); + +async function uploadAs(account: string, data: Buffer, type: string): Promise { + const client = await jmapAs(account); + const res = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { + method: 'POST', + headers: { 'Content-Type': type, Authorization: 'Basic ' + Buffer.from(`${account}:${ENT_PASSWORD}`).toString('base64') }, + body: new Uint8Array(data), + }); + if (!res.ok) throw new Error(`upload: ${res.status}`); + return (await res.json() as { blobId: string }).blobId; +} + +test('a kept message shows its inline image and gives its attachment offline', async ({ browser }) => { + const subject = `Offline files ${RUN}`; + const fileText = `Kept attachment ${RUN}`; + const client = await jmapAs(ENT.alice); + const inbox = await client.mailboxByRole('inbox'); + const image = await uploadAs(ENT.alice, ONE_PIXEL, 'image/png'); + const file = await uploadAs(ENT.alice, Buffer.from(fileText), 'application/octet-stream'); + const made = await client.request([['Email/set', { + accountId: client.accountId, + create: { + m: { + mailboxIds: { [inbox!.id]: true }, + from: [{ email: ENT.bob }], + to: [{ email: ENT.alice }], + subject, + bodyValues: { t: { value: `Text ${RUN}` }, h: { value: `

Html ${RUN}

pixel` } }, + bodyStructure: { + type: 'multipart/mixed', + subParts: [ + { + type: 'multipart/alternative', + subParts: [ + { partId: 't', type: 'text/plain' }, + { type: 'multipart/related', subParts: [{ partId: 'h', type: 'text/html' }, { blobId: image, type: 'image/png', name: 'pixel.png', disposition: 'inline', cid: 'pixel@example.org' }] }, + ], + }, + { blobId: file, type: 'application/octet-stream', name: 'kept.bin', disposition: 'attachment' }, + ], + }, + }, + }, + }, '0']]); + expect(made.methodResponses[0][1].created?.m?.id, JSON.stringify(made.methodResponses[0][1].notCreated)).toBeTruthy(); + + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page); + // Files come last in a pass, and yield to anything newer. This message's come first of them. + await page.waitForTimeout(10_000); + await page.context().setOffline(true); + await openMessage(page, subject); + + const pixel = page.frameLocator('iframe').locator('img[alt="pixel"]').first(); + // A broken image is 0 wide until the app's placeholder comes in, so ask where it came from. + await expect.poll(() => pixel.evaluate((img: HTMLImageElement) => (img.src.startsWith('blob:') ? img.naturalWidth : 0)).catch(() => 0), { timeout: 30_000 }).toBe(1); + + const chip = page.locator('[data-testid="attachment"][data-attachment-name="kept.bin"]').first(); + await expect(chip).toBeVisible({ timeout: 20_000 }); + const download = page.waitForEvent('download', { timeout: 30_000 }); + await chip.locator('button[title="Download"]').click(); + const saved = await (await download).createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of saved) chunks.push(chunk as Buffer); + expect(Buffer.concat(chunks).toString()).toBe(fileText); + await page.context().close(); +}); + test('signing out asks first when unsent mail would be lost, then wipes the device', async ({ browser }) => { const subject = `Unsent at sign-out ${RUN}`; const page = await userPage(browser, ENT.alice);