test: write a draft offline with a file, and read a kept file offline, on both servers

This commit is contained in:
Linus Rath
2026-11-03 09:25:28 +01:00
parent 5dc0f05a70
commit f5c97c1997
+158 -1
View File
@@ -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<ServerDraft[]> {
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<string> {
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: `<p>Html ${RUN}</p><img alt="pixel" src="cid:pixel@example.org">` } },
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);