chore: merge the reviewed store, pop-outs and desktop core into the settings branch

Eviction now goes by the base's signal: `storeLost` and the `store-reset`
notice, with `bulwark-store-opened` as the marker and `prune-files` for files
with no key. The storage page and the lost count hang off that. What a user
picks is stored under `bulwark-offline-scope`, the key `offlineScopeFor`
reads, and `email-snapshot` follows the base's rule. `OfflineEnabled` is read
from the desktop bridge when it's there.
This commit is contained in:
Linus Rath
2026-11-03 10:40:47 +01:00
201 changed files with 11501 additions and 709 deletions
+97 -4
View File
@@ -160,6 +160,46 @@ async function offlineDatabaseExists(page: Page): Promise<boolean> {
});
}
/**
* The store's files as bytes, with no worker holding them: which of `needles`
* occur in them. Goes to a same-origin file without scripts first, so that
* leaving the app ends the owner.
*/
async function rawStoreFiles(page: Page, needles: string[]): Promise<{ bytes: number; found: string[] }> {
await page.goto(new URL('/sw.js', page.url()).href);
return page.evaluate(async (wanted) => {
const read = async (dir: FileSystemDirectoryHandle): Promise<string[]> => {
const parts: string[] = [];
for await (const handle of (dir as unknown as { values(): AsyncIterable<FileSystemHandle> }).values()) {
if (handle.kind === 'directory') parts.push(...await read(handle as FileSystemDirectoryHandle));
else parts.push(new TextDecoder('latin1').decode(await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer()));
}
return parts;
};
const root = await navigator.storage.getDirectory();
const files = await read(await root.getDirectoryHandle('.bulwark-store'));
return {
bytes: files.reduce((sum, file) => sum + file.length, 0),
found: wanted.filter((needle) => files.some((file) => file.includes(needle))),
};
}, needles);
}
/** The file of the account's store, from the key database. */
async function storeFileName(page: Page): Promise<string> {
return page.evaluate(() => new Promise<string>((resolve, reject) => {
const open = indexedDB.open('bulwark-store');
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const all = open.result.transaction('accounts').objectStore('accounts').getAll();
all.onsuccess = () => {
open.result.close();
resolve((all.result as Array<{ file: string }>)[0].file);
};
};
}));
}
/** The accounts the local store holds a key for. Their mail is in encrypted files nothing here can read. */
async function offlineDatabase(page: Page): Promise<{ exists: boolean; accounts: number }> {
return page.evaluate(async () => {
@@ -238,6 +278,45 @@ test('with the browser offline, kept mail opens, also after a reload without a n
await page.context().close();
});
test('the files on the device hold no readable mail, and a store whose key went starts over', async ({ browser }) => {
const subject = `Offline encrypted ${RUN}`;
const secret = `Secret body ${RUN}`;
await deliverAndWait(subject, secret);
const page = await userPage(browser, ENT.alice);
await enableOffline(page);
await toMail(page);
await expect.poll(async () => (await offlineDatabase(page)).accounts, { timeout: 30_000 }).toBeGreaterThan(0);
await openMessage(page, subject);
await expect(body(page)).toContainText(secret, { timeout: 20_000 });
// Long enough for the store to have the message and its text on disk.
await page.waitForTimeout(5_000);
const file = await storeFileName(page);
const raw = await rawStoreFiles(page, [subject, secret, 'SQLite format 3', file]);
expect(raw.bytes).toBeGreaterThan(100_000);
// The name of the file is the one thing here in the clear: the search works.
expect(raw.found).toEqual([file]);
// The browser clears the key database and leaves the files.
await page.evaluate(() => new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase('bulwark-store');
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
}));
await page.goto(new URL('/', page.url()).href);
// Once on screen and once for screen readers.
await expect(page.locator('p', { hasText: "couldn't be read" }).first()).toBeVisible({ timeout: 60_000 });
await toMail(page);
await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 });
await expect.poll(async () => (await offlineDatabase(page)).accounts, { timeout: 30_000 }).toBeGreaterThan(0);
const newFile = await storeFileName(page);
expect(newFile).not.toBe(file);
const after = await rawStoreFiles(page, [file, newFile]);
expect(after.found).toEqual([newFile]);
await page.context().close();
});
test('a mail written offline waits in the outbox, the required label still stops it, and it goes out labelled when back online', async ({ browser }) => {
const subject = `Outbox label ${RUN}`;
await setPolicy(browser, { organisation: { labels: { list: LABELS, defaultId: '', required: true, extraHeader: '' } } });
@@ -396,8 +475,8 @@ test('emptying the Trash offline shows at once and reaches the server once back
await page.context().close();
});
test('a send whose response got lost goes out once', async ({ browser }) => {
const subject = `Offline lost response ${RUN}`;
/** A send taken from the outbox gets no answer. `arrives` is what reached the server of it. */
async function loseResponse(browser: Browser, subject: string, arrives: (body: { methodCalls: Array<[string, unknown, string]> }) => void) {
const page = await userPage(browser, ENT.alice);
await enableOffline(page);
await toMail(page);
@@ -413,9 +492,12 @@ test('a send whose response got lost goes out once', async ({ browser }) => {
let lost = 0;
await context.unroute(origin);
await context.route(origin, async (route) => {
if (lost === 0 && (route.request().postData() ?? '').includes('EmailSubmission/set')) {
const sent = route.request().postData() ?? '';
if (lost === 0 && sent.includes('EmailSubmission/set')) {
lost += 1;
await route.fetch();
const body = JSON.parse(sent);
arrives(body);
await route.fetch({ postData: JSON.stringify(body) });
await route.abort('connectionreset');
return;
}
@@ -430,6 +512,16 @@ test('a send whose response got lost goes out once', async ({ browser }) => {
expect(copies).toHaveLength(1);
expect(copies[0].keywords.$draft).toBeFalsy();
await context.close();
}
test('a send whose response got lost goes out once', async ({ browser }) => {
await loseResponse(browser, `Offline lost response ${RUN}`, () => {});
});
test('a send of which only the draft arrived is submitted, and goes out once', async ({ browser }) => {
await loseResponse(browser, `Offline lost submission ${RUN}`, (body) => {
body.methodCalls = body.methodCalls.filter(([name]) => name === 'Email/set');
});
});
test('signing out asks first when unsent mail would be lost, then wipes the device', async ({ browser }) => {
@@ -733,6 +825,7 @@ test('a store the browser deleted is noticed, says what was lost and syncs again
await page.goto('/settings/backup/offline');
await expect(page.getByTestId('offline-evicted')).toContainText('The browser deleted the mail kept on this device', { timeout: 60_000 });
await expect(page.getByText("Offline mail on this device couldn't be read", { exact: false }).first()).toBeVisible();
await expect(page.getByTestId('offline-evicted')).toContainText('2 unsent messages or changes were lost');
await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(0);
await page.getByTestId('offline-evicted').getByRole('button', { name: 'OK' }).click();
+675
View File
@@ -0,0 +1,675 @@
import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
import { ENT, adminContext, contextOptions, deliver, jmapAs, requireEntEnv, shot, withPolicy, ENT_PASSWORD } from './helpers/ent';
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
/**
* Windows of an installed app (design B, step 2) through the real UI on a
* VM 106 test Stalwart with the ent-* accounts:
* - the title bar next to the native window controls;
* - "Save all to a folder", with a stubbed showDirectoryPicker;
* - pop-out message and composer windows: session, draft handover, send,
* close with unsaved text, a message deleted or moved, state across windows.
*
* Headless Chromium has no installed-app mode, so the display mode and the
* overlay API are stubbed by an init script (which reaches popups too).
* window.open, the second window and everything behind it are real. What this
* can't show: a real install opening a popup inside the app rather than in
* the browser. A person has to check that once in Chrome and Edge.
*
* Rig:
* BUILD=1 bash start-prod.sh <worktree> <port> <jmap-url> pw-1.0
* bash run-spec.sh <worktree> <port> <jmap-url> 1.0 integration/tests/23-do-pwa-windows.spec.ts
*/
test.setTimeout(180_000);
const RUN = Date.now().toString(36);
/** A server check every second: tighter polling gets a test account rate limited. */
const POLL = [1000];
const STANDALONE = `(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (query) => query.includes('display-mode: standalone')
? { matches: true, media: query, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent() { return false; } }
: real(query);
})();`;
const OVERLAY = `(() => {
const overlay = new EventTarget();
overlay.visible = true;
overlay.getTitlebarAreaRect = () => ({ x: 0, y: 0, width: window.innerWidth - 140, height: 40 });
Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay });
})();`;
const BRIDGE = `window.bulwarkDesktop = { windows: {} };`;
async function appPage(browser: Browser, ...initScripts: string[]): Promise<Page> {
const context = await browser.newContext(contextOptions());
for (const script of initScripts) await context.addInitScript(script);
const page = await context.newPage();
await neutralizeDevOverlay(page);
await page.goto('/');
await page.fill('#username', ENT.alice);
await page.fill('#password', ENT_PASSWORD);
await page.click('button[type="submit"]');
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
return page;
}
/** A toast: the text is also in the live region for screen readers. */
function toast(page: Page, text: string | RegExp) {
return page.getByRole('region', { name: 'Notifications' }).getByText(text).first();
}
function row(page: Page, subject: string) {
return page.locator('[data-testid="email-list-item"]', { hasText: subject }).first();
}
async function openInMain(page: Page, subject: string): Promise<void> {
await expect(row(page, subject)).toBeVisible({ timeout: 60_000 });
await row(page, subject).click();
}
async function deliverToAlice(subject: string): Promise<string> {
await deliver({ from: ENT.bob, to: [ENT.alice], subject, text: `Body of ${subject}` });
const alice = await jmapAs(ENT.alice);
const found = await alice.waitForEmail(subject, { timeoutMs: 30_000 });
return found.id;
}
/** A message in Alice's inbox with two attachments of the same name and one other. */
async function messageWithAttachments(subject: string): Promise<void> {
const alice = await jmapAs(ENT.alice);
const inbox = await alice.mailboxByRole('inbox');
const res = await alice.request([['Email/set', {
accountId: alice.accountId,
create: { m: {
mailboxIds: { [inbox!.id]: true },
from: [{ email: ENT.bob }],
to: [{ email: ENT.alice }],
subject,
bodyValues: {
t: { value: 'See the files.' },
a: { value: 'first copy' },
b: { value: 'second copy' },
c: { value: 'notes' },
},
bodyStructure: {
type: 'multipart/mixed',
subParts: [
{ partId: 't', type: 'text/plain' },
{ partId: 'a', type: 'text/plain', name: 'same.txt', disposition: 'attachment' },
{ partId: 'b', type: 'text/plain', name: 'same.txt', disposition: 'attachment' },
{ partId: 'c', type: 'text/plain', name: 'notes.txt', disposition: 'attachment' },
],
},
} },
}, '0']]);
if (!res.methodResponses[0][1].created?.m) throw new Error(JSON.stringify(res.methodResponses[0][1]));
}
/** Stubs the picker on the page, with a folder that records what it is given. */
async function stubDirectoryPicker(page: Page, opts: { failOn?: string; cancel?: boolean } = {}): Promise<void> {
await page.evaluate(({ failOn, cancel }) => {
const files = new Map<string, string>();
(window as unknown as { __folderFiles: Map<string, string> }).__folderFiles = files;
(window as unknown as { __pickerCalls: number }).__pickerCalls = 0;
(window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = async () => {
(window as unknown as { __pickerCalls: number }).__pickerCalls += 1;
if (cancel) throw new DOMException('closed', 'AbortError');
return {
name: 'Saved mail',
async getFileHandle(name: string, options?: { create?: boolean }) {
if (!files.has(name) && !options?.create) throw new DOMException('missing', 'NotFoundError');
return {
createWritable: async () => ({
write: async (data: Blob) => {
if (name === failOn) throw new DOMException('Disk full', 'QuotaExceededError');
files.set(name, await data.text());
},
close: async () => {},
abort: async () => {},
}),
};
},
async removeEntry(name: string) { files.delete(name); },
};
};
}, opts);
}
const folderFiles = (page: Page) => page.evaluate(() => [...(window as unknown as { __folderFiles: Map<string, string> }).__folderFiles.entries()]);
function watchAuthCalls(context: BrowserContext): string[] {
const calls: string[] = [];
context.on('request', (request) => {
const url = new URL(request.url());
if (request.method() !== 'PUT') return;
if (url.pathname === '/api/auth/token' || url.pathname === '/api/auth/session') {
calls.push(`${request.frame().page().url()} ${url.pathname}${url.search}`);
}
});
return calls;
}
async function openPopout(page: Page, trigger: () => Promise<void>): Promise<Page> {
const [popup] = await Promise.all([page.context().waitForEvent('page'), trigger()]);
await popup.waitForLoadState('domcontentloaded');
return popup;
}
async function popOutMessage(page: Page, subject: string): Promise<Page> {
await openInMain(page, subject);
const button = page.locator('[data-testid="viewer-open-in-window"]').first();
await expect(button).toBeVisible({ timeout: 30_000 });
const popup = await openPopout(page, () => button.click());
await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
return popup;
}
async function draftsCalled(subject: string): Promise<Array<{ id: string; subject: string }>> {
const alice = await jmapAs(ENT.alice);
const drafts = await alice.mailboxByRole('drafts');
const res = await alice.request([
['Email/query', { accountId: alice.accountId, filter: { inMailbox: drafts!.id, subject } }, '0'],
['Email/get', { accountId: alice.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'],
]);
return res.methodResponses[1][1].list;
}
test.beforeAll(() => requireEntEnv());
test.describe('the manifest and the title bar', () => {
test('the manifest asks for the overlay and falls back to standalone', async ({ request, baseURL }) => {
const res = await request.get(`${baseURL ?? process.env.IT_WEBMAIL_URL}/manifest.webmanifest`);
const manifest = await res.json();
expect(manifest.display).toBe('standalone');
expect(manifest.display_override).toEqual(['window-controls-overlay', 'standalone']);
});
test('without an overlay nothing changes', async ({ browser }) => {
const page = await appPage(browser);
await expect(page.locator('[data-window-title-bar]')).toHaveCount(0);
const padding = await page.evaluate(() => {
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
return { padding: getComputedStyle(root).paddingTop, top: root.getBoundingClientRect().top };
});
expect(padding).toEqual({ padding: '0px', top: 0 });
await page.context().close();
});
test('with one, the app starts below a drag strip that leaves the controls free', async ({ browser }) => {
const page = await appPage(browser, OVERLAY);
const strip = page.locator('[data-window-title-bar]');
await expect(strip).toBeVisible();
const box = await strip.boundingBox();
expect(box).toMatchObject({ x: 0, y: 0, height: 40 });
expect(box!.width).toBe(1440 - 140);
const region = await strip.evaluate((el) => {
const style = getComputedStyle(el) as CSSStyleDeclaration & { appRegion?: string; webkitAppRegion?: string };
return style.appRegion ?? style.webkitAppRegion;
});
expect(region).toBe('drag');
// The window title, in small text.
await expect(strip).toContainText(/\S/);
const rail = await page.evaluate(() => {
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
return { padding: getComputedStyle(root).paddingTop };
});
expect(rail.padding).toBe('40px');
await shot(page, 'pwa-windows-title-bar');
// A full-page dialog keeps clear of it too.
await page.context().close();
});
});
test.describe('save all attachments to a folder', () => {
const SUBJECT = `Files ${RUN}`;
test.beforeAll(() => messageWithAttachments(SUBJECT));
test('writes every file, numbers the same name, and says how many', async ({ browser }) => {
const page = await appPage(browser);
await stubDirectoryPicker(page);
await openInMain(page, SUBJECT);
await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
await expect(toast(page, 'Saved 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 });
const files = new Map(await folderFiles(page));
expect([...files.keys()].sort()).toEqual(['notes.txt', 'same (1).txt', 'same.txt']);
expect(files.get('notes.txt')).toContain('notes');
expect([files.get('same.txt'), files.get('same (1).txt')].map((text) => text!.trim()).sort()).toEqual(['first copy', 'second copy']);
await page.context().close();
});
test('a write failing midway says how many were saved', async ({ browser }) => {
const page = await appPage(browser);
await stubDirectoryPicker(page, { failOn: 'notes.txt' });
await openInMain(page, SUBJECT);
await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
await expect(toast(page, 'Saved 2 of 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 });
await page.context().close();
});
test('closing the picker does nothing', async ({ browser }) => {
const page = await appPage(browser);
await stubDirectoryPicker(page, { cancel: true });
await openInMain(page, SUBJECT);
await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
await expect.poll(() => page.evaluate(() => (window as unknown as { __pickerCalls: number }).__pickerCalls)).toBe(1);
await expect(toast(page, /Saved \d/)).toHaveCount(0);
await page.context().close();
});
test('is gone when the organisation blocks downloads', async ({ browser }) => {
const admin = await adminContext(browser);
const page = await appPage(browser);
await stubDirectoryPicker(page);
await withPolicy(admin, { organisation: { devices: { noDownloads: true } } }, async () => {
await page.reload();
await stubDirectoryPicker(page);
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
await openInMain(page, SUBJECT);
await expect(page.getByTestId('attachments-downloads-off').first()).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="attachments-save-to-folder"]')).toHaveCount(0);
});
await admin.close();
await page.context().close();
});
});
test.describe('pop-out windows', () => {
test('paints from the server HTML: its own frame, no spinner, no rail, the theme set before the first paint', async ({ browser }) => {
const context = await browser.newContext(contextOptions());
for (const path of ['/en/popout/compose', '/en/popout/message/x1']) {
const res = await context.request.get(path);
expect(res.status(), path).toBe(200);
const html = await res.text();
expect(html, path).toContain('data-popout-skeleton');
expect(html, path).toContain('data-popout-status="loading"');
expect(html, path).not.toContain('animate-spin');
expect(html, path).not.toContain('data-sk-rail');
expect(html, path).not.toContain('data-boot-skeleton=');
expect(html.indexOf('theme-storage'), path).toBeLessThan(html.indexOf('data-popout-window'));
}
await context.close();
});
test('a browser tab offers none of it', async ({ browser }) => {
const subject = `Tab ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser);
await openInMain(page, subject);
await expect(page.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="viewer-open-in-window"]')).toHaveCount(0);
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
await expect(page.locator('[data-testid="composer-open-in-window"]')).toHaveCount(0);
await page.context().close();
});
test('the bridge counts too: display-mode is `browser` in Electron', async ({ browser }) => {
const subject = `Bridge ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, BRIDGE);
await openInMain(page, subject);
await expect(page.locator('[data-testid="viewer-open-in-window"]').first()).toBeVisible({ timeout: 30_000 });
await page.context().close();
});
test('a message opens alone, on the main window\'s session, with no rail and no token renewal', async ({ browser }) => {
const subject = `Popout ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const calls = watchAuthCalls(page.context());
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
await expect(popup.locator('nav')).toHaveCount(0);
expect(popup.url()).toContain('/popout/message/');
await expect(popup).toHaveTitle(new RegExp(subject));
expect(calls.filter((call) => call.includes('/popout/'))).toEqual([]);
await shot(popup, 'pwa-windows-popout-message');
await page.context().close();
});
test('opening the same message again focuses its window', async ({ browser }) => {
const subject = `Twice ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
const opened: Page[] = [];
page.context().on('page', (p) => opened.push(p));
const url = popup.url();
let reloaded = false;
popup.on('framenavigated', () => { reloaded = true; });
await page.locator('[data-testid="viewer-open-in-window"]').first().click();
await popup.waitForTimeout(1000);
expect(opened).toEqual([]);
expect(reloaded).toBe(false);
expect(popup.url()).toBe(url);
await page.context().close();
});
test('a session with nothing to restore still works, and survives in the list', async ({ browser }) => {
const subject = `Forgotten ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
await page.evaluate(() => {
const registry = JSON.parse(localStorage.getItem('account-registry')!);
for (const account of registry.state.accounts) account.rememberMe = false;
localStorage.setItem('account-registry', JSON.stringify(registry));
});
await page.context().clearCookies({ name: 'jmap_session' });
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
const accounts = await page.evaluate(() => JSON.parse(localStorage.getItem('account-registry')!).state.accounts.length);
expect(accounts).toBeGreaterThan(0);
await expect(accountSwitcher(page)).toBeVisible();
await page.context().close();
});
test('a reload with the main window gone restores from the cookies, without evicting', async ({ browser }) => {
const subject = `Alone ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
const before = await popup.evaluate(() => localStorage.getItem('account-registry'));
await page.close();
await popup.reload();
await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
expect(await popup.evaluate(() => localStorage.getItem('account-registry'))).toBe(before);
await popup.context().close();
});
test('a message deleted or moved while open says so', async ({ browser }) => {
const subject = `Gone ${RUN}`;
const emailId = await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
const alice = await jmapAs(ENT.alice);
const archive = await alice.mailboxByRole('archive');
if (archive) {
await alice.moveEmail(emailId, archive.id);
await popup.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect(popup.locator('[data-popout-moved]')).toContainText('moved to', { timeout: 30_000 });
// A move keeps the id: the message stays readable.
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible();
}
await alice.request([['Email/set', { accountId: alice.accountId, destroy: [emailId] }, '0']]);
await popup.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect(popup.getByText('This message is gone')).toBeVisible({ timeout: 30_000 });
await page.context().close();
});
test('deleting from the pop-out closes it', async ({ browser }) => {
const subject = `Delete ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
const closed = popup.waitForEvent('close');
await popup.getByRole('button', { name: /^Delete$/ }).first().click();
await closed;
// The main window's list follows.
await expect(row(page, subject)).toHaveCount(0, { timeout: 30_000 });
const alice = await jmapAs(ENT.alice);
const trash = await alice.mailboxByRole('trash');
await expect.poll(async () => Object.keys((await alice.findEmailBySubject(subject))?.mailboxIds ?? {}), { intervals: POLL }).toEqual([trash!.id]);
});
test('state follows across windows without a reload', async ({ browser }) => {
const subject = `Sync ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
let navigated = 0;
popup.on('framenavigated', () => { navigated += 1; });
// Theme.
await page.evaluate(() => {
const stored = JSON.parse(localStorage.getItem('theme-storage') ?? '{"state":{"theme":"light"},"version":1}');
stored.state.theme = 'dark';
localStorage.setItem('theme-storage', JSON.stringify(stored));
});
await expect(popup.locator('html')).toHaveClass(/dark/, { timeout: 10_000 });
// Language.
await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'de' }, version: 0 })));
await expect(popup.locator('html')).toHaveAttribute('lang', 'de', { timeout: 15_000 });
await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'en' }, version: 0 })));
await expect(popup.locator('html')).toHaveAttribute('lang', 'en', { timeout: 15_000 });
// Read state: from the pop-out to the main window's list. The main window
// looks at another message: the one it has open it would mark read again.
await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 });
const other = `Sync other ${RUN}`;
await deliverToAlice(other);
await openInMain(page, other);
await popup.getByTitle('Mark as unread').first().click();
await expect(row(page, subject)).toHaveAttribute('data-unread', 'true', { timeout: 30_000 });
// And from the main window to the pop-out.
await row(page, subject).click();
await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 });
await expect(popup.getByTitle('Mark as unread').first()).toBeVisible({ timeout: 30_000 });
expect(navigated).toBe(0);
await page.context().close();
});
});
test.describe('a composer in its own window', () => {
async function popOutComposer(page: Page, subject: string): Promise<Page> {
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
const input = page.locator('[data-testid="composer-to"] input').first();
await input.click();
await input.fill(ENT.bob);
await input.press('Enter');
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
await page.locator('[data-testid="email-composer"] [contenteditable="true"]').first().fill(`Body of ${subject}`);
const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click());
await expect(popup.locator('[data-popout-window="compose"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 30_000 });
return popup;
}
test('the draft moves over under one draft id, and the window closes after sending', async ({ browser }) => {
const subject = `Moved ${RUN}`;
const page = await appPage(browser, STANDALONE);
const popup = await popOutComposer(page, subject);
// Gone from the main window, with no "continue draft" left behind.
await expect(page.locator('[data-testid="email-composer"]')).toHaveCount(0);
await expect(popup.locator('[data-testid="composer-to"]')).toContainText(ENT.bob);
await expect(popup.locator('[data-testid="email-composer"] [contenteditable="true"]').first()).toContainText(`Body of ${subject}`);
await expect(popup.locator('nav')).toHaveCount(0);
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
const [first] = await draftsCalled(subject);
await shot(popup, 'pwa-windows-popout-composer');
// Further edits replace that draft, they don't start another.
await popup.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`);
await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
await expect.poll(async () => (await draftsCalled(subject)).map((draft) => draft.id).includes(first.id), { timeout: 15_000, intervals: POLL }).toBe(false);
const closed = popup.waitForEvent('close');
await popup.locator('[data-testid="composer-send"]').first().click();
await closed;
const bob = await jmapAs(ENT.bob);
await bob.waitForEmail(`${subject} edited`, { timeoutMs: 60_000 });
await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(0);
// The main window says it went, since the pop-out can't any more.
await expect(toast(page, 'Email sent')).toBeVisible({ timeout: 30_000 });
await page.context().close();
});
test('closing the window with unsaved text saves the draft', async ({ browser }) => {
const subject = `Unsaved ${RUN}`;
const page = await appPage(browser, STANDALONE);
const popup = await popOutComposer(page, subject);
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
const typed = `${subject} typed`;
await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
// Inside the 2 s of the autosave, so only the close can save it. No prompt: the main window takes it.
let prompted = false;
popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); });
const closed = popup.waitForEvent('close');
await popup.close({ runBeforeUnload: true });
await closed;
expect(prompted).toBe(false);
await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
// It replaced the version the window started from.
await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.subject === subject).length, { timeout: 20_000, intervals: POLL }).toBe(0);
await page.context().close();
});
test('with no main window left it asks, and staying lets the save finish', async ({ browser }) => {
const subject = `Asked ${RUN}`;
const page = await appPage(browser, STANDALONE);
const popup = await popOutComposer(page, subject);
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
await page.close();
const typed = `${subject} typed`;
await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
const asked = new Promise<void>((resolve) => {
popup.once('dialog', (dialog) => {
expect(dialog.type()).toBe('beforeunload');
void dialog.dismiss();
resolve();
});
});
await popup.close({ runBeforeUnload: true });
await asked;
expect(popup.isClosed()).toBe(false);
await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
});
test('a blocked window leaves the composer where it is', async ({ browser }) => {
const subject = `Blocked ${RUN}`;
const page = await appPage(browser, STANDALONE);
await page.evaluate(() => { window.open = () => null; });
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
await page.locator('[data-testid="composer-open-in-window"]').first().click();
await expect(toast(page, /blocked the new window/)).toBeVisible();
await expect(page.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject);
await page.context().close();
});
});
test.describe('what a window must not lose', () => {
test('text whose save fails in the main window lands in its "Continue draft" slot', async ({ browser }) => {
const subject = `Refused ${RUN}`;
const page = await appPage(browser, STANDALONE);
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click());
await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 });
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
// Give the pop-out time to see the main window answer its ping.
await popup.waitForTimeout(500);
const typed = `${subject} typed`;
await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
// The server stops taking saves, for the main window's rescue too.
await page.context().route('**/jmap/**', async (route) => {
if ((route.request().postData() ?? '').includes('Email/set')) await route.abort();
else await route.continue();
});
let prompted = false;
popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); });
const closed = popup.waitForEvent('close');
await popup.close({ runBeforeUnload: true });
await closed;
expect(prompted).toBe(false);
await expect(toast(page, /save/i)).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('Continue draft').first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(typed).first()).toBeVisible();
await page.context().unroute('**/jmap/**');
await page.context().close();
});
test('editing a draft from a message window opens a live composer, and only then closes', async ({ browser }) => {
const subject = `Edit ${RUN}`;
const alice = await jmapAs(ENT.alice);
const draftId = await alice.createDraft(subject, ENT.bob);
const page = await appPage(browser, STANDALONE);
const drafts = await alice.mailboxByRole('drafts');
await page.goto(`/mail/folder/${drafts!.id}`);
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
await expect(row(page, subject)).toBeVisible({ timeout: 60_000 });
const message = await openPopout(page, async () => {
await row(page, subject).click({ button: 'right' });
await page.getByTestId('ctx-open-window').click();
});
await expect(message.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
const closed = message.waitForEvent('close');
const composer = await openPopout(message, async () => {
await message.getByRole('button', { name: /^Edit/ }).first().click();
});
await expect(composer.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 });
await closed;
// Live: it still saves over the same draft, with the window that opened it gone.
await composer.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`);
await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.id === draftId).length, { timeout: 20_000, intervals: POLL }).toBe(0);
await page.context().close();
});
});
test.describe('the attachment bar', () => {
test('stays one row in a narrow pane, with both buttons in reach', async ({ browser }) => {
const subject = `Narrow ${RUN}`;
await messageWithAttachments(subject);
const page = await appPage(browser);
await stubDirectoryPicker(page);
await openInMain(page, subject);
const save = page.locator('[data-testid="attachments-save-to-folder"]').first();
await expect(save).toBeVisible({ timeout: 30_000 });
await page.setViewportSize({ width: 900, height: 900 });
const pane = page.locator('[data-testid="reader-attachments"]').first();
await expect(pane).toBeVisible();
const row = save.locator('xpath=..');
const rowBox = await row.boundingBox();
const saveBox = await save.boundingBox();
expect(rowBox!.height).toBeLessThan(48);
expect(saveBox!.x + saveBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1);
await expect(save).toHaveAttribute('aria-label', /folder/i);
await page.context().close();
});
});
test.describe('the desktop app\'s behaviour', () => {
test('plain-text mail can be selected, list rows can\'t', async ({ browser }) => {
const subject = `Selectable ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, BRIDGE);
await openInMain(page, subject);
const body = page.locator('[data-plugin-source="email-body"]').first();
await expect(body).toBeVisible({ timeout: 30_000 });
expect(await body.evaluate((el) => getComputedStyle(el).userSelect)).toBe('text');
expect(await row(page, subject).evaluate((el) => getComputedStyle(el).userSelect)).toBe('none');
// A real selection, the way a user would make one.
await body.click({ clickCount: 3 });
expect(await page.evaluate(() => window.getSelection()?.toString() ?? '')).toContain(`Body of ${subject}`);
await page.context().close();
});
test('hangs on the bridge: a browser gets none of it', async ({ browser }) => {
const plain = await appPage(browser);
expect(await plain.locator('html').getAttribute('data-desktop')).toBeNull();
const button = plain.locator('button').first();
expect(await button.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none');
await plain.context().close();
const app = await appPage(browser, BRIDGE);
expect(await app.locator('html').getAttribute('data-desktop')).toBe('');
const chrome = app.locator('button').first();
expect(await chrome.evaluate((el) => getComputedStyle(el).userSelect)).toBe('none');
expect(await app.evaluate(() => getComputedStyle(document.documentElement).overscrollBehaviorY)).toBe('none');
await app.context().close();
});
});