build the app, and ship it on Linux and Windows as well as macOS

The client itself: the sync engine and mirror, the Gmail and IMAP providers,
the screener, the three boxes and two piles, compose and send, the sanitiser
and tracker stripping, contacts, clips, backup, notifications and the guide.

The pipeline that ships it. Three runners now build in parallel and the
publish gate wants all four platform keys in latest.json before a release
leaves draft. Linux gets two more packages Tauri does not build: a flatpak
repackaged from the deb against GNOME 48, and a Nix package that relinks the
deb against nixpkgs so it runs as a native Wayland client rather than through
Xwayland. CI runs the Rust suite on all three desktops rather than on Linux
alone, and rebuilds the flatpak manifest on every push to main.

Two things that were only ever exercised on macOS and were wrong everywhere
else. The menu bar was built by adjusting the submenus macOS is given, so on
a platform whose default menu has no File or View the new File menu landed
after Edit, Check for Updates landed nowhere, and the three places and the
reading pane had no menu at all. The deb declared libwebkit2gtk-4.1-0 and
libgtk-3-0 twice, because tauri.conf.json named the dependencies Tauri
already emits.

The updater key exists now, so releases can sign their artifacts.
This commit is contained in:
pj committed 2026-09-06 12:15:30 +05:30
1 parent 088ec9c6e4
commit eb59cb5f8d
423 files changed
+93217 -251

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
// More than one account at a time: the chip that switches between them, the merged list behind
// Ctrl+0, and the one thing the chip is allowed to say about sync.
//
// The fixture has two accounts on two hues, which is what makes the coloured edge in All accounts
// something a test can read rather than something a person has to be told is there.
import { expect, test, type Page } from "@playwright/test";
import { failCommands, listReady, MIDDAY, openApp, rows, settle } from "./app";
const chip = (page: Page) => page.locator(".account-chip");
/** Settings, on the Remove an account sheet, with the confirmation for one row's button up. */
async function askToRemove(page: Page, row: number) {
await page.keyboard.press("Meta+,");
await page.getByRole("button", { name: "Remove an account" }).click();
const sheet = page.getByRole("dialog", { name: "Remove an account" });
await sheet.locator(".remove-row").nth(row).getByRole("button", { name: "Remove" }).click();
await expect(sheet.locator(".confirm")).toBeVisible();
return sheet;
}
/** The account edge on every row: the 2px rule the list draws in All accounts and nowhere else. */
const edges = (page: Page) =>
page.locator(".row-account").evaluateAll((marks) =>
marks.map((mark) => mark.getAttribute("data-hue") ?? ""),
);
/**
* A sync status arriving from the backend. In the browser the fixture dispatches these as window
* events under the Tauri names, so this is the backend's own surface rather than the app's.
*/
async function saySync(page: Page, accountId: string, phase: string): Promise<void> {
await page.evaluate(
({ accountId, phase }) => {
window.dispatchEvent(
new CustomEvent("sync-progress", {
detail: {
accountId,
phase,
lastSyncMs: null,
error: phase === "error" ? "the token was revoked" : null,
pendingWrites: 0,
// The word is the engine's: a refused token arrives already named, and the chip only
// prints it. An error with no name is "Sync trouble", which is the rule useSync pins.
message: phase === "error" ? "Signed out" : null,
hydrated: 300,
total: 1200,
oldestMs: null,
},
}),
);
},
{ accountId, phase },
);
await settle(page);
}
test("the chip lists both accounts with their colours, and All accounts behind them", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await expect(chip(page)).toContainText("[email protected]");
// The chip wears the account's own colour rather than the one its address hashes to.
await expect(chip(page).locator(".avatar")).toHaveAttribute("data-hue", "4");
await chip(page).click();
const options = page.locator(".account-option");
await expect(options).toHaveCount(3);
await expect(options.nth(0)).toContainText("[email protected]");
await expect(options.nth(0).locator(".avatar")).toHaveAttribute("data-hue", "4");
await expect(options.nth(1)).toContainText("[email protected]");
await expect(options.nth(1).locator(".avatar")).toHaveAttribute("data-hue", "2");
await expect(options.nth(2)).toContainText("All accounts");
// Four ways into settings and the chip is one of them.
await page.locator(".account-settings").click();
await expect(page.locator(".settings")).toBeVisible();
await expect(page.locator(".settings-title")).toHaveText("Accounts");
});
test("Ctrl and a number switch the account the list is showing", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const first = await rows(page);
expect(first.length).toBeGreaterThan(0);
await page.keyboard.press("Control+2");
await expect(chip(page)).toContainText("[email protected]");
await expect(chip(page).locator(".avatar")).toHaveAttribute("data-hue", "2");
await listReady(page);
// A different mailbox, not the same one relabelled.
const second = await rows(page);
expect(second.map((row) => row.subject)).not.toEqual(first.map((row) => row.subject));
// One account at a time carries no edge: the colour only answers a question All accounts asks.
expect(await edges(page)).toEqual([]);
await page.keyboard.press("Control+1");
await expect(chip(page)).toContainText("[email protected]");
});
test("Ctrl+0 is All accounts, and every row wears its account's colour", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Control+0");
await expect(chip(page)).toContainText("All accounts");
await listReady(page);
const merged = await rows(page);
const found = await edges(page);
// One edge per row, and both accounts are in the one list.
expect(found).toHaveLength(merged.length);
expect(new Set(found)).toEqual(new Set(["4", "2"]));
// Two pixels, which is what docs/ui.md pins the edge at.
const width = await page
.locator(".row-account")
.first()
.evaluate((mark) => mark.getBoundingClientRect().width);
expect(width).toBeCloseTo(2, 1);
});
test("the chip says so when an account is offline, signed out or paused", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
// Nothing is said while sync is working, which is the whole rule.
await expect(page.locator(".sync-note")).toHaveCount(0);
await saySync(page, "acct-1", "syncing");
await expect(page.locator(".sync-note")).toHaveCount(0);
await saySync(page, "acct-1", "error");
await expect(page.locator(".sync-note")).toHaveText("Signed out");
await saySync(page, "acct-1", "paused");
await expect(page.locator(".sync-note")).toHaveText("Paused");
await saySync(page, "acct-1", "offline");
await expect(page.locator(".sync-note")).toHaveText("Offline");
// The other account is fine, so its chip says nothing.
await saySync(page, "acct-1", "idle");
await saySync(page, "acct-2", "error");
await expect(page.locator(".sync-note")).toHaveCount(0);
await page.keyboard.press("Control+2");
await expect(page.locator(".sync-note")).toHaveText("Signed out");
// And All accounts speaks for whichever of them is broken.
await page.keyboard.press("Control+0");
await expect(page.locator(".sync-note")).toHaveText("Signed out");
});
test("removing an account holds the confirmation and says what it is doing", async ({ page }) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
const sheet = await askToRemove(page, 1);
// One button per row: there is no Revoke to weigh against Remove any more. The question says
// what the button costs at Google, and the box that deletes the local data starts ticked.
await expect(sheet.locator(".remove-row")).toHaveCount(0);
await expect(sheet.locator(".confirm-body")).toContainText("every machine");
await expect(sheet.getByRole("checkbox")).toBeChecked();
await sheet.getByRole("button", { name: "Remove" }).click();
// Cancel cannot take back a delete that is already running, and neither can Escape.
const removing = sheet.getByRole("button", { name: "Removing" });
await expect(removing).toBeVisible();
await expect(removing).toBeDisabled();
await expect(sheet).toHaveAttribute("data-busy", "");
await expect(sheet.getByRole("button", { name: "Cancel" })).toBeDisabled();
await expect(sheet.getByRole("checkbox")).toBeDisabled();
await page.keyboard.press("Escape");
await expect(sheet).toBeVisible();
await expect(sheet).toHaveCount(0);
await expect(page.locator(".acct")).toHaveCount(1);
await expect(page.locator(".toast-text")).toContainText("was removed and Margin's access at Google revoked");
await expect(page.locator(".toast-text")).not.toContainText("stay on this computer");
});
test("unticking the box keeps the mail on this computer and still revokes", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const sheet = await askToRemove(page, 0);
await sheet.getByRole("checkbox").uncheck();
await sheet.getByRole("button", { name: "Remove" }).click();
await expect(sheet).toHaveCount(0);
await expect(page.locator(".toast-text")).toContainText("Margin's access at Google revoked");
await expect(page.locator(".toast-text")).toContainText("stay on this computer");
await expect(page.locator(".acct")).toHaveCount(1);
});
test("a removal the backend refuses is a failure, not a removed account", async ({ page }) => {
await failCommands(page, ["account_remove"]);
await openApp(page, { now: MIDDAY() });
await listReady(page);
const sheet = await askToRemove(page, 0);
await sheet.getByRole("button", { name: "Remove" }).click();
await expect(page.locator(".toast-text")).toContainText("Could not remove that account");
// Still on the question, with both accounts where they were.
await expect(sheet.locator(".confirm")).toBeVisible();
await expect(sheet.getByRole("button", { name: "Remove" })).toBeEnabled();
await expect(page.locator(".acct")).toHaveCount(2);
});
+401
View File
@@ -0,0 +1,401 @@
// What every spec needs: a seeded load of the real app, and the handful of measurements this UI has
// to be judged on.
//
// Nothing here reaches into the app's internals. There is no test id anywhere in `src/`, so these
// helpers read what the browser actually laid out: rectangles, computed styles, rendered text. That
// is deliberate. A list row is two lines at 58 pixels or it is not, and a test that asked the app
// what it thought it had rendered would agree with the bug.
//
// `contrastOf` is ported from the calendar unchanged. It composites every translucent layer between
// the text and the page, which matters here because a selected row is a wash over a band over
// paper, and reading one background colour would measure the wrong thing.
import { expect, type Locator, type Page } from "@playwright/test";
export interface Box {
x: number;
y: number;
width: number;
height: number;
top: number;
bottom: number;
left: number;
right: number;
}
export interface OpenOptions {
theme?: "light" | "dark";
/** Off means the list takes the window and a thread opens in place. */
readingPane?: boolean;
/** Extra localStorage entries, written before the app's first script runs. */
storage?: Record<string, string>;
/** Start with nothing connected, which is the only way to reach the connect screen. */
firstRun?: boolean;
/** The route to open. Defaults to the app itself. */
hash?: string;
now?: Date;
}
/**
* A fixed instant on the day the suite is being run, at a given hour of the pinned zone.
*
* The fixture is anchored to the local day, so a thread that says "09:40" says it because of when
* the suite ran. A test that reads a time has to say which hour it means, the same way it says
* which theme it means. Asia/Kolkata is UTC+5:30 the whole year round, so the offset is written out.
*/
export function clockAt(hour: number, minute = 0): Date {
const day = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Kolkata",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
const pad = (n: number) => String(n).padStart(2, "0");
return new Date(`${day}T${pad(hour)}:${pad(minute)}:00+05:30`);
}
/** The middle of the working day, which is when a mailbox looks like a mailbox. */
export const MIDDAY = () => clockAt(11, 30);
const SEEDED = "__test-seeded";
/**
* Loads the app with a known store. The seed is written once per context rather than on every
* navigation, so a test that reloads to check what survived is not silently reset underneath it.
*/
export async function openApp(page: Page, options: OpenOptions = {}): Promise<void> {
const seed: Record<string, string> = {
"marginmail-theme": options.theme ?? "light",
"marginmail-pane": options.readingPane === false ? "0" : "1",
...(options.firstRun ? { "marginmail-dev-empty": "1" } : {}),
...(options.storage ?? {}),
};
await page.addInitScript(
({ values, flag }) => {
try {
if (localStorage.getItem(flag) === "1") return;
localStorage.clear();
for (const [key, value] of Object.entries(values)) localStorage.setItem(key, value);
localStorage.setItem(flag, "1");
} catch {
/* a context without storage is not a context this app runs in */
}
},
{ values: seed, flag: SEEDED },
);
if (options.now) await page.clock.setFixedTime(options.now);
await page.goto(options.hash ? `/${options.hash}` : "/");
}
/** The list has laid out and the fixture has arrived. */
export async function listReady(page: Page): Promise<void> {
await expect(page.locator(".row").first()).toBeVisible();
await settle(page);
}
/** Two frames, which is long enough for a layout pass and the render it causes. */
export function settle(page: Page): Promise<void> {
return page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
export async function box(target: Locator): Promise<Box> {
const rect = await target.boundingBox();
if (!rect) throw new Error("element has no box");
return {
...rect,
top: rect.y,
bottom: rect.y + rect.height,
left: rect.x,
right: rect.x + rect.width,
};
}
export interface RowInfo {
sender: string;
subject: string;
snippet: string;
time: string;
height: number;
top: number;
/** The dot in the gutter, which is the only thing that says a thread is new. */
unseen: boolean;
/** The message count, printed between sender and time only when there is more than one. */
count: string;
selected: boolean;
/** The group head this row sits under, read off the DOM rather than asked of the app. */
group: string;
note: string | null;
}
/** Every row on screen, measured and read. */
export function rows(page: Page): Promise<RowInfo[]> {
return page.evaluate(() => {
const text = (el: Element | null) => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
let group = "";
const out: RowInfo[] = [];
const walk = document.querySelectorAll<HTMLElement>(".group-head, .row");
for (const el of walk) {
if (el.classList.contains("group-head")) {
group = text(el.querySelector(".group-head-label") ?? el);
continue;
}
const rect = el.getBoundingClientRect();
out.push({
sender: text(el.querySelector(".row-sender")),
subject: text(el.querySelector(".row-subject")),
snippet: text(el.querySelector(".row-snippet")),
time: text(el.querySelector(".row-time")),
height: rect.height,
top: rect.top,
// The DTO calls it `unseen` and the row calls it new, because the group above it is
// called New for you and the dot is the new-mail dot. Same fact, two vocabularies.
unseen: el.hasAttribute("data-new"),
count: text(el.querySelector(".row-count")),
selected: el.hasAttribute("data-selected"),
group,
note: el.querySelector(".row-note") ? text(el.querySelector(".row-note")) : null,
});
}
return out;
});
}
/** The group heads in order, which is what "the Inbox has two groups" means. */
export function groups(page: Page): Promise<string[]> {
return page.evaluate(() =>
[...document.querySelectorAll(".group-head-label")].map((el) =>
(el.textContent ?? "").replace(/\s+/g, " ").trim(),
),
);
}
/** The value of a CSS custom property on the root, for the geometry the mockups pin down. */
export function token(page: Page, name: string): Promise<string> {
return page.evaluate(
(property) => getComputedStyle(document.documentElement).getPropertyValue(property).trim(),
name,
);
}
/** The open dialog's accessible name, or null. Every overlay in the app is one. */
export function openDialog(page: Page): Promise<string | null> {
return page.evaluate(() => {
const dialog = document.querySelector('[role="dialog"]');
return dialog ? dialog.getAttribute("aria-label") : null;
});
}
/**
* The message bodies, which live in sandboxed iframes and so cannot be read with an ordinary
* locator. Returns each frame's rendered text, which is enough to assert that a body arrived,
* that quoted text is not in it, and that a blocked image left no broken picture behind.
*/
export async function bodyText(page: Page): Promise<string[]> {
const frames = page.frames().filter((f) => f !== page.mainFrame());
const out: string[] = [];
for (const frame of frames) {
out.push(await frame.evaluate(() => document.body.innerText.replace(/\s+/g, " ").trim()));
}
return out;
}
export interface Contrast {
ratio: number;
text: string;
background: string;
}
/**
* Contrast of a piece of text against whatever is actually behind it, compositing every translucent
* layer between the element and the page.
*/
export function contrastOf(page: Page, selector: string): Promise<Contrast | null> {
return page.evaluate((sel) => {
const el = document.querySelector<HTMLElement>(sel);
if (!el) return null;
// Resolved through a canvas rather than by reading the string, so any colour space works.
// getComputedStyle hands back whatever syntax the author wrote, and a modern colour function
// falls through an rgb regex as transparent, which reports a readable row as 1:1.
const probe = document.createElement("canvas");
probe.width = 1;
probe.height = 1;
const ctx = probe.getContext("2d", { willReadFrequently: true })!;
const parse = (value: string): [number, number, number, number] => {
const match = value.match(/^rgba?\(([^)]+)\)$/);
if (match) {
const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0, parts[3] ?? 1];
}
if (!value || value === "transparent" || value === "none") return [0, 0, 0, 0];
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = "rgba(0, 0, 0, 0)";
ctx.fillStyle = value;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return [r, g, b, a / 255];
};
const over = (
top: [number, number, number, number],
bottom: [number, number, number],
): [number, number, number] => [
top[0] * top[3] + bottom[0] * (1 - top[3]),
top[1] * top[3] + bottom[1] * (1 - top[3]),
top[2] * top[3] + bottom[2] * (1 - top[3]),
];
const layers: [number, number, number, number][] = [];
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
layers.push(parse(getComputedStyle(node).backgroundColor));
}
let background: [number, number, number] = [255, 255, 255];
for (let i = layers.length - 1; i >= 0; i--) background = over(layers[i], background);
const text = over(parse(getComputedStyle(el).color), background);
const channel = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const luminance = (rgb: [number, number, number]) =>
0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
const a = luminance(text);
const b = luminance(background);
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const show = (rgb: [number, number, number]) => rgb.map((n) => Math.round(n)).join(",");
return { ratio: Math.round(ratio * 100) / 100, text: show(text), background: show(background) };
}, selector);
}
export interface MessageInfo {
name: string;
address: string;
time: string;
/** Older messages are one line: a preview instead of a body. */
collapsed: boolean;
preview: string;
}
/** The messages in the reading pane, read the way a person reads them: heads, not state. */
export function paneMessages(page: Page): Promise<MessageInfo[]> {
return page.evaluate(() => {
const text = (el: Element | null) => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
return [...document.querySelectorAll<HTMLElement>(".msg")].map((el) => ({
name: text(el.querySelector(".msg-name")).replace(text(el.querySelector(".addr")), "").trim(),
address: text(el.querySelector(".msg-name .addr")),
time: text(el.querySelector(".msg-time")),
collapsed: el.hasAttribute("data-collapsed"),
preview: text(el.querySelector(".msg-preview")),
}));
});
}
export interface PaletteRow {
group: string;
label: string;
keys: string[];
}
/** Every row of the open palette, with the group it sits under and the caps it prints. */
export function paletteRows(page: Page): Promise<PaletteRow[]> {
return page.evaluate(() => {
const text = (el: Element | null) => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
const out: PaletteRow[] = [];
for (const section of document.querySelectorAll(".palette-list > li")) {
const group = text(section.querySelector(".palette-group"));
for (const row of section.querySelectorAll(".palette-row")) {
out.push({
group,
label: text(row.querySelector(".palette-label")),
keys: [...row.querySelectorAll(".palette-keys .key")].map((k) => text(k)),
});
}
}
return out;
});
}
/** Opens the row at `index` by clicking it, and waits for the thread to arrive in the pane. */
export async function openRow(page: Page, index: number): Promise<void> {
await page.locator(".row").nth(index).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await settle(page);
}
/**
* Makes the fixture refuse a command, which is the only way to see what a failed call looks like:
* the dev backend is in the page rather than on the wire, so there is no request to intercept.
*
* The module request itself is answered instead, with a shim that forwards to the real fixture and
* throws for the commands named. Nothing in `src/` knows this happened, which is the point: the
* app under test is the app that ships.
*/
export async function failCommands(page: Page, commands: string[]): Promise<void> {
await page.route("**/src/dev/mockIpc.ts*", async (route) => {
// The shim's own import of the real thing carries a query, and answering that one too would
// be a module that imports itself.
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { mockCall as real } from "/src/dev/mockIpc.ts?real";`,
`const refused = ${JSON.stringify(commands)};`,
`export function mockCall(command, args) {`,
` if (refused.includes(command)) return Promise.reject(new Error("the mailbox is offline"));`,
` return real(command, args);`,
`}`,
].join("\n"),
});
});
}
/** The toast at the foot of the window: what it says, and whether it offers a way back. */
export async function toast(page: Page): Promise<{ text: string; action: string | null } | null> {
return page.evaluate(() => {
const el = document.querySelector(".toast");
if (!el) return null;
const text = (el.querySelector(".toast-text")?.textContent ?? "").replace(/\s+/g, " ").trim();
const action = el.querySelector(".toast-action");
return { text, action: action ? (action.textContent ?? "").trim() : null };
});
}
/** Every button on the selection's action bar, with the key it prints. */
export function actionBar(page: Page): Promise<{ label: string; key: string }[]> {
return page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>(".action-verbs .button")].map((el) => {
const key = (el.querySelector(".key")?.textContent ?? "").trim();
const all = (el.textContent ?? "").replace(/\s+/g, " ").trim();
return { label: (key && all.endsWith(key) ? all.slice(0, -key.length) : all).trim(), key };
}),
);
}
/** The rows the selection holds, read off the checkboxes rather than asked of the store. */
export function checkedRows(page: Page): Promise<string[]> {
return page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>(".row[data-checked]")].map((el) =>
(el.querySelector(".row-sender")?.textContent ?? "").trim(),
),
);
}
/** How far down the list has been scrolled, which a place has to get back when search gives it up. */
export function listScroll(page: Page): Promise<number> {
return page.evaluate(() => document.querySelector(".list")?.scrollTop ?? 0);
}
/** Which place is up, read off the root rather than asked of the app. */
export function place(page: Page): Promise<string | null> {
return page.evaluate(() => document.querySelector(".app")?.getAttribute("data-place") ?? null);
}
+411
View File
@@ -0,0 +1,411 @@
// Writing. The card, the reply box in the thread, and the send that is held long enough to change
// your mind about.
//
// The undo delay is real time here rather than a faked clock: `page.clock.install` replaces
// `requestAnimationFrame` along with the timers, and `settle` in app.ts waits on two frames, so a
// faked clock would hang every helper in the suite. Ten seconds of waiting in one test is the
// cheaper of the two prices.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { listReady, MIDDAY, openApp, settle, toast } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
/**
* The Inbox, with the clock pinned or running.
*
* Every other spec pins it, because the fixture is anchored to the local day and a row that says
* "11:42" says it because of when the suite ran. The send tests are the exception: `setFixedTime`
* stops `Date.now()` moving, and a countdown read off a stopped clock counts down from ten to ten.
*/
const openInbox = async (page: Page, pinned = true) => {
await openApp(page, pinned ? { now: MIDDAY() } : {});
await listReady(page);
};
/** The card, with a recipient, a subject and a line of body in it. */
async function writeOne(page: Page, to = "maya", subject = "Thursday") {
await page.keyboard.press("c");
await expect(page.locator(".compose")).toBeVisible();
await page.locator(".compose .chip-input").first().pressSequentially(to);
await expect(page.locator(".suggest-row").first()).toBeVisible();
await page.keyboard.press("Enter");
await page.locator(".compose-subject").fill(subject);
await page.locator(".compose .editor-body").click();
await page.keyboard.type("Church Street at eight?");
await settle(page);
}
/** The number the toast is counting down, or null when it is not counting anything. */
async function countdown(page: Page): Promise<number | null> {
const said = await toast(page);
const match = said ? /·\s*(\d+)\s*s/.exec(said.text) : null;
return match ? Number(match[1]) : null;
}
test("c opens the card over the list, and Cmd+Shift+P makes it the window", async ({ page }) => {
await openInbox(page);
await page.keyboard.press("c");
const card = page.locator(".compose");
await expect(card).toBeVisible();
await expect(card.locator("h2")).toHaveText("New message");
// 600 wide in the bottom right, with the list still on screen behind it.
const box = (await card.boundingBox())!;
expect(Math.round(box.width)).toBe(600);
expect(box.x + box.width).toBeGreaterThan(1400 - 40);
await expect(page.locator(".row").first()).toBeVisible();
await page.keyboard.press("Meta+Shift+P");
await expect(card).toHaveAttribute("data-expanded", "");
const wide = (await card.boundingBox())!;
expect(wide.width).toBeGreaterThan(1300);
await page.keyboard.press("Meta+Shift+P");
await expect(card).not.toHaveAttribute("data-expanded", "");
expect(Math.round((await card.boundingBox())!.width)).toBe(600);
});
test("typing a recipient offers suggestions and Enter makes a chip", async ({ page }) => {
await openInbox(page);
await page.keyboard.press("c");
await page.locator(".compose .chip-input").first().pressSequentially("maya");
const suggestions = page.locator(".suggest-row");
await expect(suggestions.first()).toBeVisible();
await expect(suggestions.first()).toContainText("Maya Raghunathan");
await expect(suggestions.first()).toContainText("[email protected]");
await page.keyboard.press("Enter");
await expect(page.locator(".compose .chip")).toHaveCount(2); // the From chip and this one
await expect(page.locator(".compose-field", { hasText: "To" }).locator(".chip")).toContainText(
"Maya Raghunathan",
);
// The list closes once it has been taken, and what was typed goes with it.
await expect(suggestions).toHaveCount(0);
await expect(page.locator(".compose .chip-input").first()).toHaveValue("");
});
test("an address nobody suggested becomes a chip on a comma", async ({ page }) => {
await openInbox(page);
await page.keyboard.press("c");
await page.locator(".compose .chip-input").first().pressSequentially("[email protected],");
await expect(
page.locator(".compose-field", { hasText: "To" }).locator(".chip"),
).toContainText("[email protected]");
});
test("Escape leaves the draft alone, and c brings it back", async ({ page }) => {
await openInbox(page);
await writeOne(page);
await page.keyboard.press("Escape");
await expect(page.locator(".compose")).toHaveCount(0);
await page.keyboard.press("c");
await expect(page.locator(".compose")).toBeVisible();
await expect(page.locator(".compose-subject")).toHaveValue("Thursday");
await expect(page.locator(".compose .editor-body")).toContainText("Church Street at eight?");
await expect(page.locator(".compose-field", { hasText: "To" }).locator(".chip")).toContainText(
"Maya Raghunathan",
);
});
test("Cmd+Shift+, throws the draft away, and c opens a blank one", async ({ page }) => {
await openInbox(page);
await writeOne(page);
await page.locator(".compose .editor-body").click();
await page.keyboard.press("Meta+Shift+,");
await expect(page.locator(".compose")).toHaveCount(0);
await page.keyboard.press("c");
await expect(page.locator(".compose-subject")).toHaveValue("");
await expect(page.locator(".compose-field", { hasText: "To" }).locator(".chip")).toHaveCount(0);
});
test("Cmd+Enter sends, the toast counts down, and z inside the delay brings the draft back", async ({
page,
}) => {
await openInbox(page, false);
await writeOne(page);
await page.keyboard.press("Meta+Enter");
await expect(page.locator(".compose")).toHaveCount(0);
// The queue is a round trip, so the toast is a beat behind the key.
await expect.poll(async () => (await toast(page))?.text).toContain("Sent to Maya Raghunathan");
expect((await toast(page))?.action).toContain("Undo");
// It is a countdown and not a label: the number goes down on its own.
const started = await countdown(page);
expect(started).not.toBeNull();
await page.waitForTimeout(2_400);
const later = await countdown(page);
expect(later).toBeLessThan(started!);
await page.keyboard.press("z");
await expect(page.locator(".compose")).toBeVisible();
await expect(page.locator(".compose-subject")).toHaveValue("Thursday");
await expect(page.locator(".compose .editor-body")).toContainText("Church Street at eight?");
expect(await toast(page)).toBeNull();
});
test("the toast stops offering Undo once the delay has run out", async ({ page }) => {
test.setTimeout(45_000);
await openInbox(page, false);
await writeOne(page);
await page.keyboard.press("Meta+Enter");
await expect.poll(async () => (await toast(page))?.action).toContain("Undo");
// The delay is ten seconds in the fixture's settings, and the toast goes with it.
await expect.poll(async () => await toast(page), { timeout: 20_000 }).toBeNull();
// `z` now means whatever the list means by it, and the send is not coming back.
await page.keyboard.press("z");
await settle(page);
await expect(page.locator(".compose")).toHaveCount(0);
});
test("Cmd+Shift+Enter skips the wait", async ({ page }) => {
await openInbox(page);
await writeOne(page);
await page.keyboard.press("Meta+Shift+Enter");
await expect.poll(async () => (await toast(page))?.text).toContain("Sent to Maya Raghunathan");
// Nothing to take back, so nothing is offered.
expect((await toast(page))?.action).toBeNull();
});
test("Cmd+Shift+Enter from the card says it is sending while the provider is asked", async ({
page,
}) => {
// The card closes at once and there is no thread row to say "Waiting to send" for it, so the
// toast is the only place the wait can be seen. The dev flag makes the wait long enough to see.
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await writeOne(page);
await page.keyboard.press("Meta+Shift+Enter");
await expect(page.locator(".compose")).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toContain("Sending to Maya Raghunathan");
await expect.poll(async () => (await toast(page))?.text).toContain("Sent to Maya Raghunathan");
expect((await toast(page))?.action).toBeNull();
});
test("r opens a reply box under the last message with the sender as a chip, and a adds the rest", async ({
page,
}) => {
await openInbox(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await settle(page);
await page.keyboard.press("r");
const box = page.locator(".reply");
await expect(box).toBeVisible();
await expect(box.locator(".reply-head")).toContainText("Reply to Arun Kulkarni");
// Under the last message, in the thread, and not the compose card.
await expect(page.locator(".compose")).toHaveCount(0);
const last = (await page.locator(".msg").last().boundingBox())!;
expect((await box.boundingBox())!.y).toBeGreaterThan(last.y);
const chips = box.locator(".chip");
await expect(chips).toHaveCount(1);
await expect(chips.first()).toContainText("Arun Kulkarni");
// Escape leaves the editor and keeps the draft, which is what makes `a` mean reply all again.
await page.keyboard.press("Escape");
await expect(box).toBeVisible();
await page.keyboard.press("a");
await expect(box.locator(".chip")).toHaveCount(2);
await expect(box).toContainText("Meridian Leasing");
});
test("a reply box that was closed comes back with what was in it", async ({ page }) => {
await openInbox(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.keyboard.press("r");
await page.locator(".reply .editor-body").click();
await page.keyboard.type("Thursday afternoon suits me.");
await page.locator(".reply-close").click();
await expect(page.locator(".reply")).toHaveCount(0);
await page.keyboard.press("r");
await expect(page.locator(".reply .editor-body")).toContainText("Thursday afternoon suits me.");
});
test("f forwards with nobody in the To field and the message quoted under it", async ({ page }) => {
await openInbox(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.keyboard.press("f");
const box = page.locator(".reply");
await expect(box.locator(".reply-head")).toContainText("Forward");
await expect(box.locator(".chip")).toHaveCount(0);
await expect(box.locator(".editor-body blockquote")).toContainText("Attached the revised draft");
});
test("a reply that is sent leaves the thread waiting to send until it goes", async ({ page }) => {
await openInbox(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.keyboard.press("r");
await page.locator(".reply .editor-body").click();
await page.keyboard.type("Thursday afternoon suits me.");
await page.keyboard.press("Meta+Enter");
await expect(page.locator(".reply")).toHaveCount(0);
await expect(page.locator(".sending")).toContainText("Waiting to send");
await page.locator(".sending-now").click();
await expect(page.locator(".sending")).toHaveCount(0);
});
test("Send now says it is sending, and takes one press while it is", async ({ page }) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
// Under this flag the bodies land behind the head, and `r` answers a message, not a skeleton.
await expect(page.locator(".msg-pending")).toHaveCount(0, { timeout: 10_000 });
await page.keyboard.press("r");
await page.locator(".reply .editor-body").click();
await page.keyboard.type("Thursday afternoon suits me.");
await page.keyboard.press("Meta+Enter");
const line = page.locator(".sending");
await expect(line).toContainText("Waiting to send");
await expect(line).toHaveAttribute("data-phase", "idle");
// The push is a round trip, and the line says so rather than looking the same for all of it.
const now = page.locator(".sending-now");
await now.click();
await expect(line).toHaveAttribute("data-phase", "sending");
await expect(now).toHaveText("Sending");
await expect(now).toBeDisabled();
await expect(line).toHaveCount(0);
});
test("Focus & Reply sends an item, collapses it, and the toast brings it back", async ({ page }) => {
await openInbox(page, false);
await page.keyboard.press("Shift+F");
await expect(page.locator(".focus-item").first()).toBeVisible();
const first = page.locator(".focus-item").first();
const subject = await first.locator(".focus-subject").textContent();
await first.locator(".focus-box").fill("Wednesday at four suits us.");
await page.keyboard.press("Meta+Enter");
// The page shortens as it is worked through: the item it answered is one line now.
const sent = page.locator(".focus-sent");
await expect(sent).toContainText("Sent to");
await expect(sent).toContainText(subject!.trim());
await expect.poll(async () => (await toast(page))?.action).toContain("Undo");
// Sending moves the caret on to the next box, which is what "send this reply and move on" means,
// so `z` there is a letter and the toast is the way back. `z` is still registered on this stage
// for when the caret is not in a box, because the reading pane is not mounted behind it.
await page.locator(".toast-action").click();
await expect(page.locator(".focus-sent")).toHaveCount(0);
await expect(page.locator(".focus-item").first().locator(".focus-box")).toHaveValue(
"Wednesday at four suits us.",
);
});
test("the composer refuses an attachment that would not fit, and says so", async ({ page }) => {
await openInbox(page);
await page.keyboard.press("c");
// Dropped rather than picked: a webview's file picker hands back no path, and drag and drop is
// the route that carries one on a desktop.
await page.locator(".compose").evaluate((card) => {
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array(64)], "notes.txt", { type: "text/plain" }));
card.dispatchEvent(new DragEvent("drop", { dataTransfer: transfer, bubbles: true }));
});
await expect(page.locator(".compose .attachment")).toContainText("notes.txt");
await page.locator(".compose").evaluate((card) => {
const transfer = new DataTransfer();
const huge = new File([new Uint8Array(8)], "raw-scan.tiff", { type: "image/tiff" });
// A real 40 MB file in a test is 40 MB of memory for one number.
Object.defineProperty(huge, "size", { value: 40 * 1024 * 1024 });
transfer.items.add(huge);
card.dispatchEvent(new DragEvent("drop", { dataTransfer: transfer, bubbles: true }));
});
await expect.poll(async () => (await toast(page))?.text).toContain("35 MB");
await expect(page.locator(".compose .attachment")).toHaveCount(1);
});
test("Cmd+Shift+I moves the introducer to Bcc and thanks them, and again puts them back", async ({
page,
}) => {
await openInbox(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.keyboard.press("r");
await page.locator(".reply .editor-body").click();
await page.keyboard.press("Meta+Shift+I");
const bcc = page.locator(".reply .compose-field", { hasText: "Bcc" });
await expect(bcc.locator(".chip")).toContainText("Arun Kulkarni");
await expect(page.locator(".reply .editor-body")).toContainText(
"Thank you for the introduction",
);
await page.keyboard.press("Meta+Shift+I");
await expect(bcc.locator(".chip")).toHaveCount(0);
await expect(page.locator(".reply .editor-body")).not.toContainText(
"Thank you for the introduction",
);
});
test("Remind me if no reply is a toggle with a date on it", async ({ page }) => {
await openInbox(page);
await page.keyboard.press("c");
await expect(page.locator(".compose-date")).toHaveCount(0);
await page.locator(".compose-foot .button", { hasText: "Remind me if no reply" }).click();
await expect(page.locator(".compose-date")).toHaveValue(/\d{4}-\d{2}-\d{2}/);
await page.locator(".compose-foot .button", { hasText: "Remind me if no reply" }).click();
await expect(page.locator(".compose-date")).toHaveCount(0);
});
test("the foot says how long a send is held for", async ({ page }) => {
await openInbox(page);
await page.keyboard.press("c");
await expect(page.locator(".compose-delay")).toHaveText("Undo send: 10 s");
});
test("the compose card over the Inbox", async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openInbox(page);
await page.keyboard.press("c");
await page.locator(".compose .chip-input").first().pressSequentially("maya");
await expect(page.locator(".suggest-row").first()).toBeVisible();
await page.keyboard.press("Enter");
await page.locator(".compose-subject").fill("Thursday");
// The first paragraph rather than the middle of the body: a draft opens with the signature under
// an empty line, and clicking between the two is a click ProseMirror is entitled to read either
// way.
await page.locator(".compose .editor-body > p").first().click();
await page.keyboard.type("Maya, Thursday works. Church Street at eight?");
await page.keyboard.press("Enter");
await page.keyboard.type("I will book it if you do not mind the walk from the station.");
await settle(page);
await page.screenshot({ path: join(shots, "compose.png") });
});
+237
View File
@@ -0,0 +1,237 @@
// The first screen, and the states it moves through.
//
// `openApp(page, { firstRun: true })` is an install with nothing connected: the fixture answers
// every read as empty until a consent completes, at which point it fills in and narrates the first
// sync exactly as the backend does. So the whole flow, including the two states nobody can produce
// on demand against the real Google, is driven here by typing an address.
//
// The screen asks for one thing, the address, and works the rest out. A Google address goes to the
// browser, a Microsoft one is told why it cannot, and everything else is tests/imap.spec.ts. A
// cancelled consent and a withheld scope are dispatched as `auth` events, which is what the backend
// sends and what `src/dev/mockIpc.ts` sends. Nothing here reaches into the app.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { openApp, settle } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
/** Quoted from docs/ui.md. This sentence is the promise the sign-in is asking to be trusted on. */
const PRIVACY =
"Sign-in happens in your browser with Google. Margin never sees your password. The key Google " +
"hands back is stored only on this device, and you can revoke it any time from your Google account.";
const REQUIRED = "https://www.googleapis.com/auth/gmail.modify";
/** An answer from the consent page, in the shape the backend emits. */
function auth(page: Page, event: Record<string, unknown>): Promise<void> {
return page.evaluate((detail) => {
window.dispatchEvent(new CustomEvent("auth", { detail }));
}, {
ok: false,
error: null,
accountId: null,
email: null,
cancelled: false,
grantedScopes: [],
missingRequired: [],
...event,
});
}
const address = (page: Page) => page.getByLabel("Email address");
/** The whole of the first step: an address and Enter, which is how a person does it. */
async function go(page: Page, email: string): Promise<void> {
await address(page).fill(email);
await page.keyboard.press("Enter");
}
/** A Google address, which is the way in that goes to the browser. */
const connect = (page: Page) => go(page, "[email protected]");
test("the welcome screen is the wordmark, one field, and what works", async ({ page }) => {
await openApp(page, { firstRun: true });
await expect(page.locator(".welcome-title")).toHaveText("Margin Mail");
await expect(address(page)).toBeVisible();
await expect(address(page)).toBeFocused();
await expect(page.getByRole("button", { name: "Continue" })).toBeVisible();
// No provider is named as a button. The one that used to be here was Google's, and a screen that
// opens with a Google button and an "anything else" button under it has decided who it is for.
await expect(page.getByRole("button", { name: /Google/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: /other account/ })).toHaveCount(0);
await expect(page.locator(".welcome-privacy")).toContainText("Any mailbox works here");
// Nothing of the app is behind it: no header, no list, no account chip with nobody in it.
await expect(page.locator(".account-chip")).toHaveCount(0);
await expect(page.locator(".list-col")).toHaveCount(0);
mkdirSync(shots, { recursive: true });
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "connect.png") });
});
test("something that is not an address is said so, and nothing is looked up", async ({ page }) => {
await openApp(page, { firstRun: true });
await go(page, "pj");
await expect(page.locator(".welcome-trouble")).toContainText("An email address is what this needs");
await expect(page.locator(".welcome-title")).toHaveText("Margin Mail");
// Typing again clears the complaint, so it does not sit under a field that has since been fixed.
await address(page).fill("pj@");
await expect(page.locator(".welcome-trouble")).toHaveCount(0);
});
test("a Google address goes to the browser, and waiting offers both ways back to the link", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await connect(page);
await expect(page.locator(".welcome-title")).toHaveText("Waiting for Google");
// The browser that opened is not always the browser in front of you.
await expect(page.getByRole("button", { name: "Open link again" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Copy link" })).toBeEnabled();
// And the promise the sign-in is asking to be trusted on is said on the screen that waits for it.
await expect(page.locator(".welcome-privacy")).toHaveText(PRIVACY);
});
test("a work domain whose mail is delivered to Google goes the same way, after a lookup", async ({
page,
}) => {
await openApp(page, { firstRun: true });
// Nothing about the address says Google. The directory does, from the domain's MX record.
await go(page, "[email protected]");
await expect(page.locator(".welcome-title")).toHaveText("Waiting for Google");
});
test("closing the consent page goes quietly back to the address, which is still there", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await connect(page);
await expect(page.locator(".welcome-title")).toHaveText("Waiting for Google");
await auth(page, { cancelled: true });
await expect(page.locator(".welcome-title")).toHaveText("Margin Mail");
await expect(address(page)).toHaveValue("[email protected]");
// Changing your mind is an answer. Nothing on the screen says anything went wrong.
await expect(page.locator(".welcome-trouble")).toHaveCount(0);
await expect(page.locator(".toast")).toHaveCount(0);
});
test("withholding the one required permission says which, and offers to try again", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await connect(page);
await expect(page.locator(".welcome-title")).toHaveText("Waiting for Google");
await auth(page, { missingRequired: [REQUIRED] });
await expect(page.locator(".welcome-title")).toHaveText("One permission is missing");
// Named in plain English rather than as a scope URL.
await expect(page.locator(".welcome-line")).toContainText("Read and change your mail");
await expect(page.locator(".welcome-line")).not.toContainText("googleapis.com");
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
});
test("an Outlook address is told why before anything is typed, and can try another", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await go(page, "[email protected]");
await expect(page.locator(".welcome-title")).toHaveText("Outlook is not here yet");
await expect(page.locator(".welcome-line")).toContainText("app registration with Microsoft");
// No password was asked for, because none would have worked.
await expect(page.getByLabel("Password")).toHaveCount(0);
await page.getByRole("button", { name: "Try another address" }).click();
await expect(page.locator(".welcome-title")).toHaveText("Margin Mail");
await expect(address(page)).toHaveValue("[email protected]");
});
test("a mailbox with no IMAP behind it says so, by name", async ({ page }) => {
await openApp(page, { firstRun: true });
await go(page, "[email protected]");
await expect(page.locator(".welcome-title")).toHaveText("HEY has no way in");
await expect(page.locator(".welcome-line")).toContainText("no IMAP, no POP");
// Escape is the other way back, and it is the same way back.
await page.keyboard.press("Escape");
await expect(page.locator(".welcome-title")).toHaveText("Margin Mail");
});
test("the first sync is a bar and a count, and becomes the Inbox when the mail lands", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await connect(page);
// Consent is back and the account is written. Before anything is fetched, the one question:
// how far back this device holds. A month is the default and one press starts the sync.
await expect(page.locator(".welcome-title")).toHaveText("How far back?");
await expect(page.locator(".window-choice-option[data-on]")).toHaveText("30 days");
await expect(page.locator(".welcome-bar")).toHaveCount(0);
await page.getByRole("button", { name: "Start" }).click();
await expect(page.locator(".welcome-title")).toHaveText("Bringing in the last month");
await expect(page.locator(".welcome-bar")).toBeVisible();
await expect(page.locator(".welcome-count")).toContainText("of 4,812 messages");
// The bar is a real fraction of a real denominator rather than a spinner, so it is somewhere
// between nothing and everything while the mail is arriving.
await expect
.poll(async () => {
const filled = await page
.locator(".welcome-bar-fill")
.evaluate((el) => new DOMMatrixReadOnly(getComputedStyle(el).transform).a);
return filled > 0 && filled < 1;
})
.toBe(true);
// It hands over when the first page of threads arrives, not when the sync finishes.
await expect(page.locator(".row").first()).toBeVisible();
await expect(page.locator(".welcome")).toHaveCount(0);
// And the first-run panel arrives over that Inbox, once, with what the pass over senders did.
await expect(page.getByRole("dialog", { name: "You are set up" })).toBeVisible();
await expect(page.locator(".onboard-lead")).toContainText("senders");
await expect(page.locator(".choice-option[data-on]")).toHaveText("a week");
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByRole("dialog", { name: "You are set up" })).toHaveCount(0);
// Once per account, and the fact is this device's.
const remembered = await page.evaluate(() => localStorage.getItem("marginmail-onboarded"));
expect(remembered).toContain("acct-1");
});
test("a first sync that fails says why instead of counting for ever", async ({ page }) => {
// The fixture emits the working status and the failure inside one timer, so React renders them
// as one update. A screen that waited to watch the phase change from working to stopped never
// saw the working half, and sat on "Listing your mail" with the reason two layers down in a
// status nothing rendered. That is the whole bug, and this is the shape of it.
await openApp(page, { firstRun: true, storage: { "marginmail-dev-sync-fails": "1" } });
await connect(page);
await page.getByRole("button", { name: "Start" }).click();
await expect(page.locator(".welcome-title")).toHaveText("Your mail did not arrive");
await expect(page.locator(".welcome-bar")).toHaveCount(0);
// Google's own sentence, verbatim, because it names the thing to go and fix.
await expect(page.locator(".welcome-trouble")).toContainText("Gmail API has not been used");
// And two ways on, neither of which is closing the app.
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
await page.getByRole("button", { name: "Go in anyway" }).click();
await expect(page.locator(".welcome")).toHaveCount(0);
});
+268
View File
@@ -0,0 +1,268 @@
// The contact card and the Contacts place.
//
// The card is where a decision about a sender is reversed, so most of what is asserted here is that
// a control changed something and that the change is still there after the card has been closed and
// asked for again. Nothing reads the store: a destination is what the picker says, and a note is
// what the field holds after a round trip through the backend.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { MIDDAY, failCommands, listReady, openApp, openDialog, settle, toast } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
const SAM = "[email protected]";
const MAYA = "[email protected]";
const card = (page: Page) => page.locator(".popover");
/** Opens a thread and asks for its sender's card the way the keyboard does. */
async function cardFor(page: Page, subject: string): Promise<void> {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: subject }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await settle(page);
await page.keyboard.press("i");
await expect(card(page)).toBeVisible();
}
test("i on a focused thread opens the card for its sender", async ({ page }) => {
await cardFor(page, "Piano on Wednesdays");
expect(await openDialog(page)).toBe("Contact card");
await expect(card(page).locator(".popover-name")).toHaveText("Sam Okafor");
await expect(card(page).locator(".popover-sub")).toHaveText(SAM);
// Where their mail delivers, and when that was decided. Both are the whole point of the card.
await expect(card(page).locator(".contact-pick")).toHaveValue("inbox");
await expect(card(page).getByText(/^In, on /)).toBeVisible();
// The two lists at its foot are the sender's own.
await expect(card(page).getByText("Recent threads")).toBeVisible();
});
test("changing Delivers to writes it and moves the mail that is already here", async ({ page }) => {
await cardFor(page, "Piano on Wednesdays");
await card(page).locator(".contact-pick").selectOption("paper-trail");
await expect(card(page).locator(".contact-pick")).toHaveValue("paper-trail");
// A move that only affected future mail would read as a move that did not work, so the thread
// that was in the Inbox is in the Paper Trail now.
await page.keyboard.press("Escape");
await expect(card(page)).toBeHidden();
await page.keyboard.press("3");
await expect(page.locator(".list-title")).toHaveText("Paper Trail");
const moved = page.locator(".row", { hasText: "Piano on Wednesdays" });
await expect(moved).toBeVisible();
// Closed and asked for again, which is the only way to tell a written change from a drawn one.
await moved.click();
await page.keyboard.press("i");
await expect(card(page).locator(".contact-pick")).toHaveValue("paper-trail");
});
test("the domain toggle is offered for a company and not for a consumer address", async ({
page,
}) => {
await cardFor(page, "Piano on Wednesdays");
await expect(card(page).getByRole("switch", { name: "Everyone at sunnydaymusic.example" })).toBeVisible();
await page.keyboard.press("Escape");
await page.locator(".row", { hasText: "Dinner on Thursday?" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.keyboard.press("i");
await expect(card(page).locator(".popover-sub")).toHaveText(MAYA);
// Everyone at gmail.com is not one sender, so the toggle is not offered rather than offered and
// then refused.
await expect(card(page).getByRole("switch", { name: /^Everyone at/ })).toHaveCount(0);
await expect(card(page).getByRole("switch", { name: "Notify" })).toBeVisible();
});
test("Notify and the note round trip", async ({ page }) => {
await cardFor(page, "Piano on Wednesdays");
const notify = card(page).getByRole("switch", { name: "Notify" });
const was = await notify.getAttribute("aria-checked");
await notify.click();
await expect(notify).toHaveAttribute("aria-checked", was === "true" ? "false" : "true");
const note = card(page).getByLabel("Note");
await expect(note).toHaveValue("Cooper's teacher. Prefers email over calls.");
await note.fill("Lessons moved to Thursdays");
await note.blur();
await page.keyboard.press("Escape");
await expect(card(page)).toBeHidden();
await page.keyboard.press("i");
await expect(card(page).getByLabel("Note")).toHaveValue("Lessons moved to Thursdays");
await expect(card(page).getByRole("switch", { name: "Notify" })).toHaveAttribute(
"aria-checked",
was === "true" ? "false" : "true",
);
});
test("Escape closes the card and leaves the thread where it was", async ({ page }) => {
await cardFor(page, "Piano on Wednesdays");
await page.keyboard.press("Escape");
await expect(card(page)).toBeHidden();
expect(await openDialog(page)).toBeNull();
// The card is a layer over the thread, so unwinding it gives the thread back rather than closing
// it too.
await expect(page.locator(".thread-subject")).toBeVisible();
});
test("the Contacts place lists senders with rules and its search narrows them", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("contacts");
await page.keyboard.press("Enter");
await expect(page.locator(".app")).toHaveAttribute("data-stage", "contacts");
await expect(page.locator(".list-title")).toHaveText("Contacts");
const senders = page.locator(".contact-item .row-sender");
await expect(page.locator(".contact-item", { hasText: "Sam Okafor" })).toBeVisible();
await expect.poll(() => senders.count()).toBeGreaterThan(5);
// The row is the mail list's row, which is what "the same anatomy" means.
await expect(page.locator(".contact-item").first().locator(".avatar")).toBeVisible();
await page.getByLabel("Search contacts").fill("sam");
await expect(senders).toHaveCount(1);
await expect(senders.first()).toHaveText("Sam Okafor");
// The card's controls are on the row, so a decision can be changed without opening anything.
const row = page.locator(".contact-item").first();
await row.locator(".contact-pick").selectOption("paper-trail");
await expect(row.locator(".contact-pick")).toHaveValue("paper-trail");
await page.getByLabel("Search contacts").fill("nobodyatall");
await expect(page.locator(".contact-item")).toHaveCount(0);
await expect(page.locator(".empty-state")).toBeVisible();
});
test("a row in the Contacts place opens the card", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("contacts");
await page.keyboard.press("Enter");
await expect(page.locator(".contact-item").first()).toBeVisible();
await page.locator(".contact-item", { hasText: "Sam Okafor" }).locator(".row").click();
await expect(card(page)).toBeVisible();
await expect(card(page).locator(".popover-sub")).toHaveText(SAM);
});
test("the card draws its wait, and the wait goes when the card comes", async ({ page }) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.locator(".row", { hasText: "Piano on Wednesdays" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.keyboard.press("i");
await expect(card(page).locator(".contact-waiting")).toHaveAttribute("data-phase", "loading");
await expect(card(page).locator(".popover-name")).toHaveText("Sam Okafor");
await expect(card(page).locator(".contact-waiting")).toHaveCount(0);
});
test("a card that will not come says so and does not stay open empty", async ({ page }) => {
await failCommands(page, ["contact_card"]);
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Piano on Wednesdays" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.keyboard.press("i");
await expect.poll(async () => (await toast(page))?.text).toContain("Could not open that contact");
await expect(card(page)).toHaveCount(0);
});
test("the Contacts place keeps what it had while the next answer is out", async ({ page }) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("contacts");
await page.keyboard.press("Enter");
// Before any answer: the row's shape rather than nothing, and not a claim that there is nobody.
const list = page.locator(".contacts-list");
await expect(list).toHaveAttribute("data-phase", "loading");
await expect(list.locator(".contacts-waiting")).toBeVisible();
await expect(page.locator(".empty-state")).toHaveCount(0);
await expect.poll(() => page.locator(".contact-item").count()).toBeGreaterThan(5);
await expect(list).toHaveAttribute("data-phase", "idle");
// Typing asks again, and the last answer stays on the page until the next one lands.
await page.getByLabel("Search contacts").fill("sam");
await expect(list).toHaveAttribute("data-phase", "loading");
expect(await page.locator(".contact-item").count()).toBeGreaterThan(5);
await expect(page.locator(".contact-item")).toHaveCount(1);
// The same when the last answer was nobody: the line stays rather than blinking blank.
await page.getByLabel("Search contacts").fill("nobodyatall");
await expect(page.locator(".empty-state")).toHaveText("Nobody by that name");
await page.getByLabel("Search contacts").fill("sam");
await expect(list).toHaveAttribute("data-phase", "loading");
await expect(page.locator(".empty-state")).toHaveText("Nobody by that name");
await expect(page.locator(".contact-item")).toHaveCount(1);
});
test("the Contacts place says so when the list will not come", async ({ page }) => {
await failCommands(page, ["contacts_list"]);
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("contacts");
await page.keyboard.press("Enter");
await expect(page.locator(".contacts-list")).toHaveAttribute("data-phase", "error");
await expect(page.locator(".contacts-note")).toHaveText("Could not load your contacts");
// Not "Nobody yet", which would be a claim about a list nobody has seen.
await expect(page.locator(".empty-state")).toHaveCount(0);
});
test("Unsubscribe on the card says it is working, and the toast is the way back", async ({
page,
}) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("contacts");
await page.keyboard.press("Enter");
// Narrowed to the one row first, so the card hangs off the top of the list and its foot, where
// the button is, is on screen. A popover hangs below its anchor and does not climb.
await page.getByLabel("Search contacts").fill("browser");
await expect(page.locator(".contact-item")).toHaveCount(1);
await page.locator(".contact-item", { hasText: "The Browser" }).locator(".row").click();
await expect(card(page).locator(".popover-sub")).toHaveText("[email protected]");
const button = card(page).locator(".contact-unsub");
await expect(button).toHaveText("Unsubscribe");
await button.click();
await expect(button).toHaveText("Unsubscribing");
await expect(button).toHaveAttribute("data-phase", "unsubscribing");
await expect(button).toBeDisabled();
await expect
.poll(async () => (await toast(page))?.text)
.toBe("Unsubscribed from [email protected]");
expect((await toast(page))?.action).toContain("Undo");
await expect(button).toHaveText("Unsubscribe");
await expect(button).toBeEnabled();
});
test("the card over a thread", async ({ page }) => {
mkdirSync(shots, { recursive: true });
await cardFor(page, "Piano on Wednesdays");
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "contact-card.png") });
});
+171
View File
@@ -0,0 +1,171 @@
// The things HEY owns, kept beside the mail: a note, a name of your own, two threads made one, and
// the two libraries that read the mailbox sideways.
//
// None of these is a provider change and none of them shows anywhere in Gmail. What they have in
// common is that they are yours, so every assertion here is about the decision being visible where
// it was made and still there when you come back to it.
import { expect, test, type Page } from "@playwright/test";
import { listReady, MIDDAY, openApp, rows, toast } from "./app";
const openPlace = async (page: Page, label: string) => {
await page.keyboard.press("Meta+k");
await page.locator(".palette-row", { hasText: label }).first().click();
};
test("y writes a note, and it is in the pane and under the row", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Dinner on Thursday?" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.keyboard.press("y");
await expect(page.locator('[role="dialog"]')).toHaveAttribute("aria-label", "Note to self");
await page.locator(".field-textarea").fill("Book the table before Priya asks again");
await page.locator(".panel-foot .button", { hasText: "Save" }).click();
const note = page.locator(".thread-inner .note");
await expect(note).toContainText("Note to self");
await expect(note).toContainText("Book the table before Priya asks again");
// And one line under the row, on the note surface, which is the only thing a list says about it.
await expect
.poll(async () => (await rows(page)).find((row) => row.sender === "Maya Raghunathan")?.note)
.toBe("Book the table before Priya asks again");
// It came from the backend rather than from the component that wrote it, which is what going
// away and coming back proves. The dev fixture lives in the page and cannot outlive a reload of
// it, so this is as far as the browser build can be asked.
await page.keyboard.press("3");
await expect(page.locator(".list-title")).toHaveText("Paper Trail");
await page.keyboard.press("1");
await page.locator(".row", { hasText: "Dinner on Thursday?" }).click();
await expect(page.locator(".thread-inner .note")).toContainText(
"Book the table before Priya asks again",
);
});
test("a rename shows in the list and in the pane, with what it was", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
// The subject is the control: the thing you want to change is the thing you press.
await page.locator(".thread-name").click();
await expect(page.locator('[role="dialog"]')).toHaveAttribute("aria-label", "Rename this thread");
await page.locator(".field-input").fill("Studio lease, clause 7");
await page.locator(".panel-foot .button", { hasText: "Rename" }).click();
await expect(page.locator(".thread-name")).toHaveText("Studio lease, clause 7");
await expect(page.locator(".thread-subject .renamed")).toHaveText(
'renamed · was "Lease renewal for the studio"',
);
await expect
.poll(async () => (await rows(page)).find((row) => row.sender === "Arun Kulkarni")?.subject)
.toBe("Studio lease, clause 7");
// And the real subject is one press away again, because a name of your own is not a deletion.
await page.locator(".thread-name").click();
await page.locator(".panel-foot .button", { hasText: "Use the real subject" }).click();
await expect(page.locator(".thread-name")).toHaveText("Lease renewal for the studio");
await expect(page.locator(".thread-subject .renamed")).toHaveCount(0);
});
test("g merges a selection into one row with a banner offering Unmerge", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const before = (await rows(page)).length;
await page.keyboard.press("j");
await page.keyboard.press("j");
await page.keyboard.press("x");
await page.keyboard.press("Shift+J");
await page.keyboard.press("g");
// Two rows became one, and the one that is left is the first of them.
await expect(page.locator(".row", { hasText: "Arun Kulkarni" })).toHaveCount(0);
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(1);
await expect.poll(async () => (await rows(page)).length).toBe(before - 1);
await expect.poll(async () => (await toast(page))?.text).toBe("2 threads merged");
// The rows it acted on are gone, so the selection that pointed at them is gone with them.
await expect(page.locator(".action-bar")).toHaveCount(0);
await page.locator(".row", { hasText: "Maya Raghunathan" }).click();
const banner = page.locator(".thread-banner .banner", { hasText: "Merged from" });
await expect(banner).toContainText("2 threads");
await expect(banner).toContainText("Lease renewal for the studio");
await expect(banner.locator(".banner-action")).toHaveText("Unmerge");
await banner.locator(".banner-action").click();
await expect(page.locator(".thread-banner .banner", { hasText: "Merged from" })).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Unmerged");
});
test("Clips lists what was saved and goes back to the thread", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openPlace(page, "Clips");
await expect(page.locator(".list-title")).toHaveText("Clips");
const clips = page.locator(".clip");
await expect(clips).toHaveCount(2);
await expect(clips.nth(0).locator(".clip-text")).toContainText(
"Graphite, cedar, the Napoleonic wars",
);
await expect(clips.nth(1).locator(".clip-meta")).toContainText("Russell Young");
await expect(clips.nth(1).locator(".clip-meta")).toContainText("Pumpkin bread recipe");
// Each one links back, which is the only thing a clip is for.
await clips.nth(1).locator(".clip-open").click();
await expect(page.locator(".thread-subject")).toHaveText("Pumpkin bread recipe");
await expect(page.locator(".list-title")).toHaveText("Inbox");
});
test("All files is a grid that filters by type and by sender", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openPlace(page, "All files");
await expect(page.locator(".list-title")).toHaveText("All files");
const cards = page.locator(".file-card");
await expect(cards.first()).toBeVisible();
const all = await cards.count();
expect(all).toBeGreaterThan(2);
await page.locator(".files-filters .button", { hasText: "Images" }).click();
await expect(cards).toHaveCount(2);
for (const name of await page.locator(".file-name").allTextContents()) {
expect(name).toMatch(/\.png$/);
}
await page.locator(".files-filters .button", { hasText: "PDFs" }).click();
await expect.poll(async () => await cards.count()).toBeLessThan(all);
for (const mark of await page.locator(".file-mark").allTextContents()) {
expect(mark).toBe("PDF");
}
// And by sender, which narrows what is already on screen rather than starting again.
const senders = () =>
page.evaluate(() =>
[...document.querySelectorAll(".file-card")].map((card) =>
((card.querySelectorAll(".file-meta")[0]?.textContent ?? "").split(" · ")[0] ?? "").trim(),
),
);
const [who] = await senders();
expect(new Set(await senders()).size).toBeGreaterThan(1);
await page.locator(".files-picker select").selectOption({ label: who });
await expect.poll(async () => [...new Set(await senders())]).toEqual([who]);
await page.locator(".files-filters .button", { hasText: "Everything" }).click();
await page.locator(".files-picker select").selectOption("");
await expect.poll(async () => await cards.count()).toBe(all);
// Opening a card opens the thread it is in. Nothing was fetched to get here.
await cards.first().click();
await expect(page.locator(".thread-subject")).toBeVisible();
});
+307
View File
@@ -0,0 +1,307 @@
// The Feed, which is the one place where the list and the message are the same thing.
//
// Everything here is read off the page rather than asked of the app: a card is a head, a title and
// a body in a frame, and the hairline is either between two cards or it is not.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import {
bodyText,
failCommands,
listReady,
MIDDAY,
openApp,
place,
settle,
toast,
token,
} from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
interface CardInfo {
sender: string;
address: string;
time: string;
title: string;
/** Whether the body is showing in full rather than clipped at the fade. */
open: boolean;
/** Whether the body has arrived at all. */
loaded: boolean;
focused: boolean;
faded: boolean;
foot: { label: string; key: string }[];
}
function cards(page: Page): Promise<CardInfo[]> {
return page.evaluate(() => {
const text = (el: Element | null) => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
return [...document.querySelectorAll<HTMLElement>(".feed-card")].map((el) => {
const address = text(el.querySelector(".msg-name .addr"));
const body = el.querySelector(".feed-body");
return {
sender: text(el.querySelector(".msg-name")).replace(address, "").trim(),
address,
time: text(el.querySelector(".msg-time")),
title: text(el.querySelector(".feed-title")),
open: body?.hasAttribute("data-open") ?? false,
loaded: body !== null,
focused: el.hasAttribute("data-selected"),
faded: el.querySelector(".feed-fade") !== null,
foot: [...el.querySelectorAll<HTMLElement>(".feed-foot .button")].map((button) => {
const key = text(button.querySelector(".key"));
const all = text(button);
return { label: (key && all.endsWith(key) ? all.slice(0, -key.length) : all).trim(), key };
}),
};
});
});
}
/** The hairline's place in the column: how many cards sit above it, or -1 when it is not there. */
function leftOffAt(page: Page): Promise<number> {
return page.evaluate(() => {
const marker = document.querySelector(".left-off");
if (!marker) return -1;
const above = [...document.querySelectorAll<HTMLElement>(".feed-card")].filter(
(card) => card.compareDocumentPosition(marker) & Node.DOCUMENT_POSITION_FOLLOWING,
);
return above.length;
});
}
/** Which card is the newest one on screen, which is what the hairline will mark next time. */
function topmostCard(page: Page): Promise<number> {
return page.evaluate(() => {
const edge = document.querySelector<HTMLElement>(".feed")!.getBoundingClientRect().top;
return [...document.querySelectorAll<HTMLElement>(".feed-card")].findIndex(
(card) => card.getBoundingClientRect().bottom > edge + 1,
);
});
}
async function openFeed(page: Page): Promise<void> {
await listReady(page);
await page.keyboard.press("2");
await expect(page.locator(".feed-card").first()).toBeVisible();
// The bodies arrive one call behind the cards.
await expect(page.locator(".feed-body").first()).toBeVisible();
await settle(page);
}
test("the Feed is a column of open cards on its own measure, and 2 reaches it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await openFeed(page);
expect(await place(page)).toBe("feed");
expect(await token(page, "--feed-w")).toBe("720px");
const column = await page.locator(".feed-inner").boundingBox();
expect(column?.width).toBe(720);
const all = await cards(page);
expect(all.map((c) => c.sender)).toEqual([
"The Browser",
"Field Notes Dispatch",
"Ledger Lines",
"Bengaluru Systems Meetup",
"Craftsman Notes",
]);
const browser = all[0];
expect(browser.address).toBe("[email protected]");
expect(browser.time).toBe("Today 06:00");
expect(browser.title).toBe("Five things worth reading this weekend");
// The card is already open: the body is here, not a row that opens one.
expect(browser.loaded).toBe(true);
expect(await bodyText(page)).toContainEqual(expect.stringContaining("Good morning."));
// Read more and Save clip on the left, Unsubscribe and Move on the right, each printing its key.
expect(browser.foot).toEqual([
{ label: "Read more", key: "↩" },
{ label: "Save clip", key: "⌘⇧C" },
{ label: "Unsubscribe", key: "⌘U" },
{ label: "Move", key: "v" },
]);
// No read state, no counts, no New for you. Time is the only order.
await expect(page.locator(".feed .group-head")).toHaveCount(0);
await expect(page.locator(".feed [data-new]")).toHaveCount(0);
});
test("a card longer than the clip fades out, and Enter opens and closes it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await openFeed(page);
const first = () => cards(page).then((all) => all[0]);
// The Browser's four paragraphs are longer than one clip; the rest are not.
await expect.poll(async () => (await first()).faded).toBe(true);
expect(await cards(page).then((all) => all[1].faded)).toBe(false);
const clipped = await page.locator(".feed-body").first().boundingBox();
expect(clipped?.height).toBeLessThan(260);
await page.keyboard.press("j");
expect((await first()).focused).toBe(true);
await page.keyboard.press("Enter");
await expect.poll(async () => (await first()).open).toBe(true);
expect((await first()).foot[0].label).toBe("See less");
expect((await first()).faded).toBe(false);
const opened = await page.locator(".feed-body").first().boundingBox();
expect(opened!.height).toBeGreaterThan(clipped!.height);
await page.keyboard.press("Enter");
await expect.poll(async () => (await first()).open).toBe(false);
expect((await first()).foot[0].label).toBe("Read more");
});
test("j and k move between cards and bring the focused one on screen", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await openFeed(page);
const focused = async () => (await cards(page)).find((c) => c.focused)?.sender;
expect(await focused()).toBeUndefined();
await page.keyboard.press("j");
await expect.poll(focused).toBe("The Browser");
await page.keyboard.press("j");
await page.keyboard.press("j");
await expect.poll(focused).toBe("Ledger Lines");
// The card the keyboard is on is a card you can see.
const visible = await page.evaluate(() => {
const card = document.querySelector<HTMLElement>(".feed-card[data-selected]")!;
const column = document.querySelector<HTMLElement>(".feed")!.getBoundingClientRect();
const box = card.getBoundingClientRect();
return box.bottom > column.top && box.top < column.bottom;
});
expect(visible).toBe(true);
await page.keyboard.press("k");
await expect.poll(focused).toBe("Field Notes Dispatch");
});
test("Unsubscribe acts on the card it is on, and Cmd+U on the card the keyboard is on", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await openFeed(page);
// The button on the second card, which the keyboard has never been on.
await page
.locator(".feed-card")
.nth(1)
.locator(".feed-foot .button", { hasText: "Unsubscribe" })
.click();
await expect
.poll(async () => (await toast(page))?.text)
.toBe("Unsubscribed from [email protected]");
expect((await toast(page))?.action).toContain("Undo");
// The click took the focus to that card, so one step up is The Browser.
await page.keyboard.press("k");
await page.keyboard.press("Meta+u");
await expect
.poll(async () => (await toast(page))?.text)
.toBe("Unsubscribed from [email protected]");
});
test("the banner says images come through Margin and counts what was stripped today", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await openFeed(page);
const banner = page.locator(".feed .banner");
await expect(banner).toContainText("Images are loaded through Margin, never from the sender.");
// One card carries a tracker and it arrived today, so that is what the count is.
await expect(banner).toContainText("Trackers stripped from 1 item today.");
await expect(banner.locator("b")).toHaveText("1 item");
});
test("the hairline marks where the last visit ended", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await openFeed(page);
// Nothing has arrived since a visit that never happened.
expect(await leftOffAt(page)).toBe(-1);
// Read down the column and then go somewhere else, which is what leaving a place is.
await page.evaluate(() => {
const column = document.querySelector<HTMLElement>(".feed")!;
column.scrollTop = column.scrollHeight;
});
await settle(page);
const stopped = await topmostCard(page);
expect(stopped).toBeGreaterThan(0);
await page.keyboard.press("1");
await listReady(page);
await page.keyboard.press("2");
await expect(page.locator(".feed-card").first()).toBeVisible();
await expect(page.locator(".left-off")).toHaveText("You left off here");
expect(await leftOffAt(page)).toBe(stopped);
});
test("a card whose body is not cached breathes until it arrives, and is never an empty frame", async ({
page,
}) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.keyboard.press("2");
await expect(page.locator(".feed-card").first()).toBeVisible();
// The mirror has the card and not its body, so the card is its head, its title and the pane's
// skeleton where the body will go. No frame, because an empty frame reads as an empty message.
const first = page.locator(".feed-card").first();
await expect(first.locator(".msg-pending")).toBeVisible();
await expect(first.locator(".feed-body")).toHaveCount(0);
// The fetch goes out behind the view and the view is read again when it lands, which is the half
// that was missing: a card that only ever asked once was empty for the whole session.
await expect(first.locator(".feed-body")).toBeVisible({ timeout: 10_000 });
await expect(first.locator(".msg-pending")).toHaveCount(0);
await expect
.poll(async () => (await bodyText(page)).join(" "), { timeout: 10_000 })
.toContain("Good morning.");
});
test("a body that does not come is one line on the card with a way to ask again", async ({
page,
}) => {
await failCommands(page, ["thread_hydrate"]);
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.keyboard.press("2");
const first = page.locator(".feed-card").first();
const failed = first.locator('[data-state="error"]');
await expect(failed).toBeVisible({ timeout: 10_000 });
await expect(failed).toContainText("Could not fetch this message");
await expect(first.locator(".feed-body")).toHaveCount(0);
// Said on the card, where the way back is, and not as a toast: a Feed of two hundred cards that
// cannot fetch would be two hundred toasts.
await expect(page.locator(".toast")).toHaveCount(0);
// Asking again is asking again: the skeleton comes back while the fetch is out.
await failed.getByText("Try again").click();
await expect(first.locator('[data-state="error"]')).toHaveCount(0);
await expect(first.locator(".msg-pending")).toBeVisible();
await expect(first.locator('[data-state="error"]')).toBeVisible({ timeout: 10_000 });
});
// One picture per test rather than a loop, because `openApp` seeds the store once per context and
// a second call on the same page would take the dark picture in the light palette.
for (const theme of ["light", "dark"] as const) {
test(`the Feed in ${theme}`, async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openApp(page, { theme, now: MIDDAY() });
await openFeed(page);
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, `feed-${theme}.png`) });
});
}
+199
View File
@@ -0,0 +1,199 @@
// The ten pictures the in-app guide ships with, taken from the dev fixture rather than from
// anybody's mailbox.
//
// They are committed and they go into the app bundle, so this file writes nothing unless it is
// asked: `pnpm test:ui` runs the whole suite many times a day and a spec that rewrote ten PNGs on
// each of those runs would leave the tree dirty for reasons nobody chose. `just guide-shots` sets
// the variable.
//
// Twice the scale, because the guide draws each picture at half its pixel width, and an element
// rather than the window wherever the picture is about one thing: a guide caption points at a
// palette or a pile, not at a window with a palette somewhere in it.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { bodyText, listReady, MIDDAY, openApp, settle } from "./app";
test.skip(
() => !process.env.GUIDE_SHOTS,
"the guide's pictures are committed: run `just guide-shots` to remake them",
);
test.use({ deviceScaleFactor: 2 });
const guide = fileURLToPath(new URL("../public/guide", import.meta.url));
function shot(name: string): string {
mkdirSync(guide, { recursive: true });
return join(guide, name);
}
/**
* The Inbox on the pinned day, in the light palette, which is where every one of these starts.
*
* The clock is pinned for the same reason the other specs pin it: the fixture is anchored to the
* local day, so an unpinned run would ship a picture whose times are whatever hour it was taken at.
*/
async function inbox(page: Page): Promise<void> {
await openApp(page, { theme: "light", now: MIDDAY() });
await listReady(page);
}
/** The text is in its real face and the layout has stopped moving. */
async function ready(page: Page): Promise<void> {
await page.evaluate(() => document.fonts.ready);
await settle(page);
}
test("inbox.png", async ({ page }) => {
await inbox(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await expect(page.locator(".msg-frame").first()).toBeVisible();
// The bodies are in sandboxed frames, and a frame that has not loaded yet is a white rectangle
// where the message goes.
await expect
.poll(async () => (await bodyText(page)).join(" "), { timeout: 10_000 })
.toContain("Attached the revised draft");
await expect(page.locator(".pile")).toHaveCount(2);
await ready(page);
// The whole window: the list on the left with the two piles at its foot, the thread on the right.
await page.screenshot({ path: shot("inbox.png") });
});
test("screener.png", async ({ page }) => {
await inbox(page);
await page.keyboard.press("6");
await expect(page.locator(".screen-card")).toHaveCount(3);
await expect(page.locator(".screen-actions .button").first()).toBeVisible();
await ready(page);
// Three senders waiting, each card carrying Yes, Elsewhere and No with the letter that takes it.
await page.locator(".screener").screenshot({ path: shot("screener.png") });
});
test("feed.png", async ({ page }) => {
await inbox(page);
await page.keyboard.press("2");
await expect(page.locator(".feed-card").first()).toBeVisible();
await expect(page.locator(".feed-body").first()).toBeVisible();
await expect(page.locator(".feed .msg-pending")).toHaveCount(0);
await expect
.poll(async () => (await bodyText(page)).join(" "), { timeout: 10_000 })
.toContain("Good morning.");
await ready(page);
// A column of newsletters whose bodies are already open, newest first, with no unread state on
// any of them.
await page.locator(".feed").screenshot({ path: shot("feed.png") });
});
test("piles.png", async ({ page }) => {
await inbox(page);
await expect(page.locator(".pile")).toHaveCount(2);
await expect(page.locator(".pile-subject").first()).toBeVisible();
await ready(page);
// The foot of the list column on its own: two stacks, each showing the thread on top of it and
// how many are underneath.
await page.locator(".piles").screenshot({ path: shot("piles.png") });
});
test("palette.png", async ({ page }) => {
await inbox(page);
await page.keyboard.press("Meta+k");
await expect(page.locator(".palette")).toBeVisible();
// Typed into, because the groups are the point of the picture and an untouched palette is a
// screenful of Places with the rest of them below the fold.
await page.locator(".palette-input").fill("sn");
await expect(page.locator(".palette-group").nth(1)).toBeVisible();
await ready(page);
// One box for everywhere you can go and everything you can run, its rows under the group each
// belongs to, with the key beside the ones that answer to a key.
await page.locator(".palette").screenshot({ path: shot("palette.png") });
});
test("compose.png", async ({ page }) => {
await inbox(page);
await page.keyboard.press("c");
await expect(page.locator(".compose")).toBeVisible();
await page.locator(".compose .chip-input").first().pressSequentially("maya");
await expect(page.locator(".suggest-row").first()).toBeVisible();
await page.keyboard.press("Enter");
await page.locator(".compose-subject").fill("Thursday");
// The first paragraph rather than the middle of the body: a draft opens with the signature under
// an empty line, and clicking between the two is a click ProseMirror is entitled to read either
// way.
await page.locator(".compose .editor-body > p").first().click();
await page.keyboard.type("Maya, Thursday works. Church Street at eight?");
await page.keyboard.press("Enter");
await page.keyboard.type("I will book it if you do not mind the walk from the station.");
await ready(page);
// The card being written in: the recipient as a chip, a subject, two lines of body over the
// signature, and how long a send is held for.
await page.locator(".compose").screenshot({ path: shot("compose.png") });
});
test("contact-card.png", async ({ page }) => {
await inbox(page);
await page.locator(".row", { hasText: "Piano on Wednesdays" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await settle(page);
await page.keyboard.press("i");
await expect(page.locator(".popover-name")).toHaveText("Sam Okafor");
await expect(page.locator(".contact-waiting")).toHaveCount(0);
await ready(page);
// The card over a sender: where their mail delivers, when that was decided, the note kept about
// them, and what they have written lately.
await page.locator(".popover").screenshot({ path: shot("contact-card.png") });
});
test("snooze.png", async ({ page }) => {
await inbox(page);
await page.keyboard.press("j");
await page.keyboard.press("b");
await expect(page.locator(".snooze-option")).toHaveCount(6);
await ready(page);
// Six choices, each printing the key that takes it and the moment it actually means.
await page.locator(".popover").screenshot({ path: shot("snooze.png") });
});
test("shortcuts.png", async ({ page }) => {
await inbox(page);
await page.keyboard.press("?");
await expect(page.locator(".shortcuts-group").first()).toBeVisible();
await ready(page);
// The sheet behind the question mark, every binding grouped by what it acts on.
await page.locator(".sheet").screenshot({ path: shot("shortcuts.png") });
});
test("settings.png", async ({ page }) => {
await inbox(page);
await page.keyboard.press("Meta+,");
await expect(page.locator(".settings")).toBeVisible();
await page.locator(".settings-tab", { hasText: "Appearance" }).click();
await expect(page.locator('[role="tablist"][aria-label="Theme"]')).toBeVisible();
await ready(page);
// The rail of sections down the left with one of them open beside it, which is the shape of
// every section.
await page.locator(".settings").screenshot({ path: shot("settings.png") });
});
+391
View File
@@ -0,0 +1,391 @@
// The guide, which is a panel over the whole window: the mail stays where it was, nothing behind it
// can be pressed while it is up, and closing it puts back exactly what was underneath.
//
// Nothing here reads the app's own idea of what it drew. The rail is what the browser laid out, the
// sections are the group heads any list in this app draws, and the one keycap asserted is read out
// of the binding table rather than typed in, so a remapped key moves the assertion with it.
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { MIDDAY, box, listReady, openApp, openRow, place, settle } from "./app";
import { keyFor } from "../src/keys/bindings";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
const SECTIONS = [
"Getting started",
"The Screener",
"Reading",
"Triage",
"Writing",
"Organising",
"Accounts",
"Questions",
];
/** Every picture the guide is allowed to show, which is the set another package captures. */
const PICTURES = [
"compose",
"contact-card",
"feed",
"inbox",
"palette",
"piles",
"screener",
"settings",
"shortcuts",
"snooze",
];
/**
* The size a picture should be drawn at: half its own pixels, because every capture is at twice the
* size. Read out of the PNG header rather than out of the app, so this asserts what the file says
* and not what the app believes about it. Null when the captures are not on this checkout, since
* nothing here may depend on a picture existing.
*/
function natural(name: string): { width: number; height: number } | null {
const file = join(fileURLToPath(new URL("..", import.meta.url)), "public", "guide", `${name}.png`);
if (!existsSync(file)) return null;
const header = readFileSync(file);
return { width: header.readUInt32BE(16) / 2, height: header.readUInt32BE(20) / 2 };
}
/** The open dialog's accessible name, which is how a panel says it is the one in front. */
const dialog = (page: Page) =>
page.evaluate(() => document.querySelector('[role="dialog"]')?.getAttribute("aria-label") ?? null);
const tabs = (page: Page) =>
page.locator(".guide-tab").evaluateAll((all) => all.map((tab) => tab.textContent ?? ""));
/**
* The section labels in the rail. Scoped to the rail rather than read off the page, because the
* guide is a panel over the app now and the list underneath it draws group heads of its own.
*/
const railGroups = (page: Page) =>
page
.locator(".guide-rail .group-head-label")
.evaluateAll((all) => all.map((el) => (el.textContent ?? "").replace(/\s+/g, " ").trim()));
/** The one field, which is now the first thing on the panel rather than the head of the rail. */
const search = (page: Page) => page.locator(".guide-search input");
/**
* Holds what a press hands to the browser instead of letting it hand it over. The ask button leaves
* the app for a public website, and a suite that pressed it for real would open a tab on GitHub
* every time it ran.
*/
async function heldOpen(page: Page): Promise<void> {
await page.evaluate(() => {
const held: string[] = [];
(window as unknown as { asked: string[] }).asked = held;
window.open = ((url?: string | URL) => {
held.push(String(url));
return null;
}) as typeof window.open;
});
}
const asked = (page: Page) =>
page.evaluate(() => (window as unknown as { asked: string[] }).asked ?? []);
/** The palette is the way in: the guide has no key of its own and does not want one. */
async function openGuide(page: Page): Promise<void> {
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("guide");
await page.keyboard.press("Enter");
await expect(page.locator(".guide")).toBeVisible();
await settle(page);
}
test("the palette opens the guide over the whole window", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
expect(await dialog(page)).toBeNull();
await openGuide(page);
expect(await dialog(page)).toBe("Guide");
await expect(page.locator(".guide-title")).toHaveText("What is different here");
// The app is still there and none of it can be reached: the title bar carries a stacking order of
// its own, and a scrim that let its buttons through would be a modal in name only.
await expect(page.locator(".list-col")).toBeVisible();
const over = await page.evaluate(() => {
const button = document.querySelector(".titlebar .segment button");
if (!button) return "no button";
const box = button.getBoundingClientRect();
const top = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2);
return top?.className ?? "nothing";
});
expect(over).toBe("overlay");
mkdirSync(shots, { recursive: true });
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "guide.png") });
});
test("the rail lists the sections, and one article is on screen at a time", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openGuide(page);
expect(await railGroups(page)).toEqual(SECTIONS);
const all = await tabs(page);
expect(all.length).toBeGreaterThan(SECTIONS.length);
expect(all).toContain("How the Screener works");
await page.locator(".guide-tab", { hasText: "How the Screener works" }).click();
await expect(page.locator(".guide-title")).toHaveText("How the Screener works");
await expect(page.locator(".guide-tab[data-active]")).toHaveText("How the Screener works");
// One article, and only one. The rail is where the others are.
await expect(page.locator(".guide-article")).toHaveCount(1);
await expect(page.locator(".guide-title")).toHaveCount(1);
await expect(page.locator(".guide-article")).toContainText("one card per sender");
await expect(page.locator(".guide-article")).not.toContainText("What is different here");
});
test("the search narrows the rail, by a word in a body as well as in a title", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openGuide(page);
const whole = (await tabs(page)).length;
// A word that is only ever in one article's prose, and in nobody's title.
await search(page).fill("itinerary");
await expect(page.locator(".guide-tab")).toHaveCount(1);
expect(await tabs(page)).toEqual(["Reply later and Set aside"]);
expect(await railGroups(page)).toEqual(["Triage"]);
// A word nothing says: no rail left to walk.
await search(page).fill("kryptonite");
await expect(page.locator(".guide-tab")).toHaveCount(0);
// The article that was up stays up: narrowing the rail is not closing the page.
await expect(page.locator(".guide-title")).toHaveText("What is different here");
await search(page).fill("");
await expect(page.locator(".guide-tab")).toHaveCount(whole);
});
test("the search is the first thing on the panel and already has the keyboard", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openGuide(page);
// Above both columns and the width of the panel, rather than a field in the corner of the rail.
const field = await box(page.locator(".guide-search"));
const rail = await box(page.locator(".guide-rail"));
const article = await box(page.locator(".guide-panel"));
const guide = await box(page.locator(".guide"));
expect(field.bottom).toBeLessThanOrEqual(rail.top);
expect(field.bottom).toBeLessThanOrEqual(article.top);
expect(Math.round(field.left)).toBe(Math.round(guide.left));
expect(Math.round(field.width)).toBe(Math.round(guide.width));
expect(field.width).toBeGreaterThan(rail.width);
// Opened and typed into in one motion: nothing is pressed between the two.
await expect(search(page)).toBeFocused();
await page.keyboard.type("itinerary");
await expect(search(page)).toHaveValue("itinerary");
expect(await tabs(page)).toEqual(["Reply later and Set aside"]);
// And the rail still walks with the keys once the field has given them up.
await search(page).fill("");
await page.locator(".guide-tab").first().click();
const first = await page.locator(".guide-title").textContent();
await page.keyboard.press("j");
await expect(page.locator(".guide-title")).not.toHaveText(first!);
});
test("a search nothing answers offers to ask, with what was typed in the title", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openGuide(page);
const nothing = page.locator(".guide-nothing");
await expect(nothing).toHaveCount(0);
await search(page).fill("kryptonite badger");
await expect(page.locator(".guide-tab")).toHaveCount(0);
await expect(nothing.locator(".empty-state")).toHaveText("Nothing here answers that");
// What the press does is said before it is pressed: whose site, in what, and that it is public.
const said = await nothing.locator(".guide-ask").innerText();
expect(said).toContain("GitHub");
expect(said).toContain("browser");
expect(said).toContain("public");
await heldOpen(page);
await nothing.getByRole("button", { name: "Ask on GitHub" }).click();
const sent = await asked(page);
expect(sent).toHaveLength(1);
const url = new URL(sent[0]);
expect(`${url.origin}${url.pathname}`).toBe(
"https://github.com/priyanshujain/margin-mail/issues/new",
);
expect(url.searchParams.get("labels")).toBe("question");
expect(url.searchParams.get("title")).toBe("kryptonite badger");
});
test("a search that is answered does not offer to ask", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openGuide(page);
await search(page).fill("itinerary");
await expect(page.locator(".guide-tab")).toHaveCount(1);
await expect(page.locator(".guide-nothing")).toHaveCount(0);
await search(page).fill("");
await expect(page.locator(".guide-nothing")).toHaveCount(0);
});
test("the close control and Escape both put back what was underneath", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("3");
await expect(page.locator(".list-title")).toHaveText("Paper Trail");
await openRow(page, 0);
const subject = await page.locator(".thread-subject").textContent();
await openGuide(page);
await page.getByRole("button", { name: "Close" }).click();
expect(await dialog(page)).toBeNull();
// Not merely the place: the thread that was open is still open, at the scroll it was at.
await expect(page.locator(".list-title")).toHaveText("Paper Trail");
expect(await place(page)).toBe("paper-trail");
await expect(page.locator(".thread-subject")).toHaveText(subject!);
await openGuide(page);
await page.keyboard.press("Escape");
expect(await dialog(page)).toBeNull();
expect(await place(page)).toBe("paper-trail");
await expect(page.locator(".thread-subject")).toHaveText(subject!);
});
test("an article prints the key its verb answers to, whatever the keymap says it is", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openGuide(page);
await page.locator(".guide-tab", { hasText: "Archive, trash and spam" }).click();
await expect(page.locator(".guide-title")).toHaveText("Archive, trash and spam");
// Read out of the binding table rather than typed here, and an unmodified verb on purpose: what
// a modifier prints is glyphs the running platform chooses, and what a bare key prints is itself.
const combo = keyFor("archive");
expect(combo).not.toBeNull();
expect(combo).not.toContain("+");
// Every cap on the page, rather than whichever one happens to be first: what is asserted is that
// the key the table answers with is the key the article prints, not where in the article it is.
const caps = await page
.locator(".guide-article .key")
.evaluateAll((all) => all.map((one) => one.textContent ?? ""));
expect(caps).toContain(combo!);
// And the cap rides beside the name of the verb, so the sentence still reads on a phone, where
// every keycap in the app hides itself.
await expect(page.locator(".guide-p").first()).toContainText(
"takes a thread out of the Inbox",
);
});
test("the pictures are the ones that were captured, with an alt and a caption each", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openGuide(page);
const found: string[] = [];
const rail = page.locator(".guide-tab");
for (let at = 0; at < (await rail.count()); at++) {
await rail.nth(at).click();
// The photographs only. A drawn figure is a figure too, and it has no file behind it to be
// captured at twice the size or drawn at half of it.
const shown = await page.locator(".guide-figure:not([data-drawn])").evaluateAll((figures) =>
figures.map((figure) => {
const img = figure.querySelector("img");
return {
src: img?.getAttribute("src") ?? "",
alt: img?.getAttribute("alt") ?? "",
caption: (figure.querySelector("figcaption")?.textContent ?? "").trim(),
drawn: Math.round(img?.getBoundingClientRect().width ?? 0),
room: Math.round(figure.getBoundingClientRect().width),
};
}),
);
for (const picture of shown) {
expect(picture.alt.length).toBeGreaterThan(0);
expect(picture.caption.length).toBeGreaterThan(0);
found.push(picture.src);
// A picture is drawn at the size it was taken at, and only the column narrows it. Anything
// smaller than that is a screenshot of an app whose type has been shrunk out of legibility,
// which is the whole reason it is captured at twice the size in the first place.
const size = natural(picture.src.replace(/^\/guide\/|\.png$/g, ""));
if (size) expect(picture.drawn, picture.src).toBe(Math.min(size.width, picture.room));
}
}
// The src is what is asserted and never that a picture loaded: the files are captured by another
// package and a suite that waited for them would be a suite that fails on a clean checkout.
expect(found.sort()).toEqual(PICTURES.map((name) => `/guide/${name}.png`));
});
// The rule the whole guide is written to: a cap rides beside the name of the verb it belongs to,
// and the sentence has to survive the cap being taken away, because on a phone it is taken away.
// Read on the phone itself rather than by reading the source, since what is being asserted is what
// the paragraph says once the browser has finished with it.
test.describe("on a phone", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("every sentence reads with the keycaps taken out of it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openGuide(page);
await expect(page.locator(".guide-article .key").first()).toBeHidden();
const rail = page.locator(".guide-tab");
const broken: string[] = [];
for (let at = 0; at < (await rail.count()); at++) {
await rail.nth(at).click();
broken.push(
...(await page.locator(".guide-p, .guide-steps li").evaluateAll((all) =>
all
.map((el) => (el as HTMLElement).innerText)
// A space in front of a full stop, or two of them in a row, is where a cap used to be.
.filter((said) => /\s[.,;:?]/.test(said) || /\s\s/.test(said)),
)),
);
}
expect(broken).toEqual([]);
});
});
test("a link in the guide goes where it says, and the guide closes behind it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openGuide(page);
// A cross-link stays in the guide and opens the article it names.
await page.locator(".guide-tab", { hasText: "Where your mail goes" }).click();
await page.locator(".guide-link", { hasText: "How the Screener works" }).click();
await expect(page.locator(".guide-title")).toHaveText("How the Screener works");
await expect(page.locator(".guide-tab[data-active]")).toHaveText("How the Screener works");
// A link to a place in the app closes the guide behind it, because that is what it offered to do.
await page.locator(".guide-tab", { hasText: "Clear the whole queue" }).click();
await page.locator(".guide-link", { hasText: "Open the Screener" }).click();
expect(await dialog(page)).toBeNull();
expect(await place(page)).toBe("screener");
});
+163
View File
@@ -0,0 +1,163 @@
// The question mark in the corner and the small menu behind it.
//
// It is the only thing the app draws over every screen and it sits in the corner the compose card
// already owns, so most of what is asserted here is negative: it covers none of the pane's verbs,
// it does not run off the window, and it is gone the moment the card wants the corner. The menu
// opens upwards, which is the whole reason the Popover primitive learned a second placement, and
// the only honest way to check it is to measure the panel against the viewport.
import { expect, test, type Page } from "@playwright/test";
import { box, listReady, MIDDAY, openApp, openDialog, openRow, settle } from "./app";
const launcher = (page: Page) => page.locator(".help-launcher");
const button = (page: Page) => page.locator(".help-launcher .button");
const menu = (page: Page) => page.locator(".help-menu");
async function openMenu(page: Page): Promise<void> {
await button(page).click();
await expect(menu(page)).toBeVisible();
await settle(page);
}
/** The rows as somebody reads them: the label, and the key printed on the right where there is one. */
function menuRows(page: Page): Promise<{ label: string; key: string }[]> {
return page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>(".help-option")].map((el) => ({
label: (el.querySelector(".help-label")?.textContent ?? "").trim(),
key: (el.querySelector(".key")?.textContent ?? "").trim(),
})),
);
}
test("the question mark sits in the bottom right corner and covers none of the pane", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
// With a thread open the pane has its bar of verbs, which is what there is to be in the way of.
await openRow(page, 0);
const viewport = page.viewportSize()!;
const corner = await box(launcher(page));
expect(Math.round(viewport.width - corner.right)).toBe(20);
expect(Math.round(viewport.height - corner.bottom)).toBe(20);
expect(corner.right).toBeLessThanOrEqual(viewport.width);
expect(corner.bottom).toBeLessThanOrEqual(viewport.height);
// Round, and the size of the app's other icon-only controls rather than a bubble.
expect(corner.width).toBe(corner.height);
expect(corner.width).toBeGreaterThanOrEqual(26);
expect(corner.width).toBeLessThanOrEqual(44);
const verbs = await page.locator(".pane .button").count();
expect(verbs).toBeGreaterThan(0);
const covered = await page.evaluate(() => {
const help = document.querySelector(".help-launcher")!.getBoundingClientRect();
return [...document.querySelectorAll<HTMLElement>(".pane .button")]
.filter((el) => {
const r = el.getBoundingClientRect();
return (
r.width > 0 &&
r.left < help.right &&
r.right > help.left &&
r.top < help.bottom &&
r.bottom > help.top
);
})
.map((el) => (el.textContent ?? "").replace(/\s+/g, " ").trim());
});
expect(covered).toEqual([]);
});
test("the menu opens above the button and is wholly on the screen", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openMenu(page);
const viewport = page.viewportSize()!;
const corner = await box(launcher(page));
const panel = await box(page.locator(".popover"));
// Above the button, which is what the top-end placement is for: hung below it, a panel this tall
// would start 20px from the bottom edge and every row of it would be past the window.
expect(panel.bottom).toBeLessThanOrEqual(corner.top);
expect(panel.top).toBeGreaterThan(0);
expect(panel.bottom).toBeLessThanOrEqual(viewport.height);
expect(panel.right).toBeLessThanOrEqual(viewport.width);
expect(panel.left).toBeGreaterThan(0);
expect(await menuRows(page)).toEqual([
{ label: "Take the tour", key: "" },
{ label: "Guide", key: "" },
{ label: "Keyboard shortcuts", key: "?" },
]);
});
test("Keyboard shortcuts opens the sheet", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openMenu(page);
await page.locator(".help-option", { hasText: "Keyboard shortcuts" }).click();
expect(await openDialog(page)).toBe("Keyboard shortcuts");
await expect(menu(page)).toHaveCount(0);
// The sheet has the window, so the corner button is not floating over its scrim.
await expect(launcher(page)).toHaveCount(0);
});
test("Guide opens the guide over the window", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openMenu(page);
await page.locator(".help-option", { hasText: "Guide" }).click();
await expect.poll(() => openDialog(page)).toBe("Guide");
await expect(page.locator(".guide-title")).toBeVisible();
// Offering the way there again from on top of it would say nothing, and the button would be
// under the scrim anyway.
await expect(launcher(page)).toHaveCount(0);
});
test("Take the tour opens the tour", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openMenu(page);
await page.locator(".help-option", { hasText: "Take the tour" }).click();
expect(await openDialog(page)).toBe("Getting started");
await expect(menu(page)).toHaveCount(0);
});
test("the compose card takes the corner back, and gives it up again", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await expect(launcher(page)).toBeVisible();
await page.keyboard.press("c");
await expect(page.locator(".compose")).toBeVisible();
await expect(launcher(page)).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.locator(".compose")).toHaveCount(0);
await expect(launcher(page)).toBeVisible();
});
test("Escape closes the menu and leaves the app where it was", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openRow(page, 0);
const subject = await page.locator(".thread-subject").textContent();
await openMenu(page);
await page.keyboard.press("Escape");
await expect(menu(page)).toHaveCount(0);
await expect(launcher(page)).toBeVisible();
expect(await openDialog(page)).toBeNull();
await expect(page.locator(".thread-subject")).toHaveText(subject ?? "");
});
+426
View File
@@ -0,0 +1,426 @@
// A mailbox that is not Google, reached with an address, then a name and a password.
//
// `openApp(page, { firstRun: true })` is an install with nothing connected, which is the only way
// to reach the connect screen at all. From there the fixture's directory of providers decides what
// each address does: one publishes its own settings, one is only in the public directory and wants
// an app password, one is worked out from the MX record, one presents a certificate nobody vouches
// for, one is Proton and points at a Bridge that is not running, and anything else is not published
// anywhere, which is the servers sheet.
//
// Nothing here reaches into the app. Every outcome is produced by typing a different address.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { listReady, openApp, settle } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
/** The IMAP column of the sheet, and the SMTP one. They carry the same five field names. */
const incoming = (page: Page): Locator => page.locator(".imap-leg").first();
const outgoing = (page: Page): Locator => page.locator(".imap-leg").nth(1);
/** The first step: the address, submitted from the keyboard, and the sign-in panel it leads to. */
async function lookUp(page: Page, email: string): Promise<void> {
await page.getByLabel("Email address").fill(email);
await page.keyboard.press("Enter");
await expect(page.getByLabel("Your name")).toBeVisible();
}
/** The second step: who you are and the password, wherever the flow is being drawn. */
async function signIn(page: Page, password = "correct horse"): Promise<void> {
await page.getByLabel("Your name").fill("Priyanshu Jain");
await page.getByLabel("Password").fill(password);
await page.keyboard.press("Enter");
}
/**
* The whole of the flow up to the servers' answer, filled the way a person fills it and submitted
* from the keyboard, because Enter has to be the way out of forms this short.
*/
async function connectWith(page: Page, email: string, password = "correct horse"): Promise<void> {
await lookUp(page, email);
await signIn(page, password);
}
/**
* The same flow in its other host: a sheet over Settings rather than the stage of a screen that is
* not the app yet. Everything past the button is the one component, which is what the last two
* tests are for.
*/
async function lookUpFromSettings(page: Page, email: string): Promise<void> {
await page.keyboard.press("Meta+,");
await expect(page.locator(".settings")).toBeVisible();
await page.getByRole("button", { name: "Add account" }).click();
await expect(page.getByRole("dialog", { name: "Add an account" })).toBeVisible();
await lookUp(page, email);
// The panel's own head carries what the stage draws as a heading, and there is only one of them.
await expect(page.locator(".welcome-title")).toHaveCount(0);
}
test("the welcome screen asks for the address and nothing else", async ({ page }) => {
await openApp(page, { firstRun: true });
await expect(page.getByLabel("Email address")).toBeVisible();
expect(await page.locator(".imap-address .field-label").allTextContents()).toEqual([
"Email address",
]);
// Nothing about a provider, and no second way in: the address is the way in.
await expect(page.getByRole("button", { name: /Google/ })).toHaveCount(0);
await expect(page.locator(".welcome-soon")).toHaveCount(0);
await expect(page.getByLabel("Password")).toHaveCount(0);
});
test("the sign-in step is a name and a password, under where the servers came from", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
await expect(page.locator(".welcome-title")).toHaveText("Sign in to Fastmail");
// Published by the provider is a different promise from guessed, and the sentence says which,
// before the password is typed rather than after it was sent.
await expect(page.locator(".welcome-line")).toContainText("Fastmail publishes its own settings");
const facts = await page.locator(".imap-fact").allTextContents();
expect(facts[0].replace(/\s+/g, " ")).toContain("imap.fastmail.example, port 993, TLS");
expect(facts[1].replace(/\s+/g, " ")).toContain("smtp.fastmail.example, port 465, TLS");
expect(await page.locator(".imap-form .field-label").allTextContents()).toEqual([
"Your name",
"Password",
]);
// The password is a password, on a screen whose whole subject is where that password goes.
await expect(page.locator(".imap-form input[type='password']")).toHaveCount(1);
await expect(page.getByLabel("Your name")).toBeFocused();
await expect(page.getByRole("button", { name: "Add account" })).toBeVisible();
await expect(page.getByRole("button", { name: "Change the servers" })).toBeVisible();
await page.getByLabel("Your name").fill("Priyanshu Jain");
mkdirSync(shots, { recursive: true });
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "connect-mail.png") });
// Escape is the way back to the address, which is still there to be corrected.
await page.keyboard.press("Escape");
await expect(page.locator(".welcome-title")).toHaveText("Margin Mail");
await expect(page.getByLabel("Email address")).toHaveValue("[email protected]");
});
test("a provider that wants an app password is told so before the password is typed", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
// The single most common way an IMAP setup fails, said under the field it fails in.
await expect(page.locator(".imap-form .field-hint").nth(1)).toContainText("app password");
await expect(page.locator(".imap-form .field-hint").nth(1)).toContainText("Privacy & Security");
});
test("discovery that finds nothing says so, and the servers come from the sign-in step", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
// Not a failure, and the panel does not read as one: it is the same panel with a different
// first sentence, no server lines, and a button that opens them.
await expect(page.locator(".welcome-title")).toHaveText("Sign in to harborlife.example");
await expect(page.locator(".welcome-line")).toContainText(
"Nobody publishes settings for harborlife.example",
);
await expect(page.locator(".imap-fact")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Enter the servers" })).toBeVisible();
await signIn(page);
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await expect(page.locator(".banner")).toContainText(
"Nobody publishes settings for harborlife.example",
);
await expect(incoming(page).getByLabel("Server")).toHaveValue("imap.harborlife.example");
await expect(outgoing(page).getByLabel("Server")).toHaveValue("smtp.harborlife.example");
// The address is carried across, and so is the password: nothing is typed twice.
await expect(incoming(page).getByLabel("Username")).toHaveValue("[email protected]");
await expect(incoming(page).getByLabel("Password")).toHaveValue("correct horse");
// Which is exactly what the outgoing half does not insist on.
await expect(outgoing(page).getByLabel("Username")).toHaveValue("");
await expect(outgoing(page).getByLabel("Password")).toHaveValue("");
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "connect-mail-servers.png") });
// Escape goes back a step rather than throwing the whole flow away.
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Servers" })).toHaveCount(0);
await expect(page.locator(".welcome-title")).toHaveText("Sign in to harborlife.example");
await expect(page.getByLabel("Your name")).toHaveValue("Priyanshu Jain");
});
test("the servers can be typed without waiting for a lookup", async ({ page }) => {
await openApp(page, { firstRun: true });
await page.getByLabel("Email address").fill("[email protected]");
await page.getByRole("button", { name: "Enter the servers myself" }).click();
// Straight to the sign-in step, with a sentence that does not pretend a lookup was made.
await expect(page.locator(".welcome-title")).toHaveText("Sign in to selfhosted.example");
await expect(page.locator(".welcome-line")).toContainText("The servers are yours to type");
await expect(page.getByRole("button", { name: "Enter the servers" })).toBeVisible();
await signIn(page);
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await expect(incoming(page).getByLabel("Server")).toHaveValue("imap.selfhosted.example");
// Nothing was looked up, so nothing is said about what the lookup found.
await expect(page.locator(".banner")).toHaveCount(0);
});
test("changing the security corrects the port, on both halves", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await expect(incoming(page).getByLabel("Port")).toHaveValue("993");
await incoming(page).getByRole("tab", { name: "STARTTLS" }).click();
await expect(incoming(page).getByLabel("Port")).toHaveValue("143");
await incoming(page).getByRole("tab", { name: "TLS", exact: true }).click();
await expect(incoming(page).getByLabel("Port")).toHaveValue("993");
// The two halves agree on the shape and disagree on the numbers.
await expect(outgoing(page).getByLabel("Port")).toHaveValue("465");
await outgoing(page).getByRole("tab", { name: "STARTTLS" }).click();
await expect(outgoing(page).getByLabel("Port")).toHaveValue("587");
await outgoing(page).getByRole("tab", { name: "None" }).click();
await expect(outgoing(page).getByLabel("Port")).toHaveValue("25");
// And an unencrypted connection says what that means rather than letting it pass.
await expect(outgoing(page).locator(".field-hint[data-tone='error']")).toContainText(
"Nothing on this connection is encrypted",
);
});
test("a refused password says so on the panel it was typed on, and what to do about it", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
// A mistyped password is corrected where it was typed, not in a two-column sheet of servers
// that were never the problem.
await expect(page.locator(".welcome-trouble")).toContainText(
"Signing in to imap.oakridge-school.example did not work",
);
await expect(page.getByRole("dialog", { name: "Servers" })).toHaveCount(0);
// The server's own words, because they name the thing to go and fix.
await expect(page.locator(".imap-said")).toContainText("Application-specific password required");
await expect(page.locator(".imap-advice")).toHaveText(
"This account wants an app password rather than the one you sign in with.",
);
// And the way to change the servers is still there for whoever needs it.
await expect(page.getByRole("button", { name: "Change the servers" })).toBeEnabled();
});
test("a wrong password on the outgoing half says it was the outgoing half", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await incoming(page).getByLabel("Server").fill("mail.harborlife.example");
await outgoing(page).getByLabel("Server").fill("mail.harborlife.example");
await outgoing(page).getByLabel("Password").fill("wrong");
await page.getByRole("button", { name: "Test and add" }).click();
await expect(page.locator(".banner")).toContainText(
"Your mail came through. Sending, through mail.harborlife.example, did not.",
);
await expect(page.locator(".imap-said")).toContainText("Invalid credentials");
});
test("a certificate nobody vouches for is a decision, with the fingerprint to check it by", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
// The rung of the ladder that answered for this one was a guess, and the panel says so before
// anybody types a password into it.
await expect(page.locator(".welcome-line")).toContainText(
"Nobody publishes settings for sunnydaymusic.example",
);
await expect(page.locator(".welcome-line")).toContainText("a guess");
await signIn(page);
await expect(page.getByRole("dialog", { name: "The server's certificate" })).toBeVisible();
await expect(page.locator(".imap-lead")).toContainText(
"mail.sunnydaymusic.example presented a certificate",
);
await expect(page.locator(".imap-lead")).toContainText("it signed its own certificate");
// Scoped to the sheet: the sign-in panel under it still carries its two server lines.
const sheet = page.getByRole("dialog", { name: "The server's certificate" });
const facts = await sheet.locator(".imap-fact dt").allTextContents();
expect(facts).toEqual(["Fingerprint", "Subject", "Issuer", "Expires"]);
await expect(sheet.locator(".imap-fact dd").first()).toContainText("9F:2C:41:8E");
// Never asked about the loopback, so a person seeing this is being asked about the network.
await expect(page.locator(".imap-note")).toContainText("out on the network");
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "connect-mail-certificate.png") });
// Going back is the other answer, and it goes back to the panel whose test raised the question.
await page.getByRole("button", { name: "Go back" }).click();
await expect(page.getByRole("dialog", { name: "Servers" })).toHaveCount(0);
await expect(page.locator(".welcome-title")).toHaveText("Sign in to sunnydaymusic.example");
});
test("trusting a certificate is remembered, and the connection carries on", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
// One question per socket, because that is what the trust store holds: a fingerprint accepted on
// a host and a port. This server answers on both ports, so it is asked about twice, and the
// second panel names the outgoing one.
await page.getByRole("button", { name: "Trust this certificate" }).click();
await expect(page.locator(".imap-note")).toContainText("port 465");
await page.getByRole("button", { name: "Trust this certificate" }).click();
// Both accepted, the servers answered, and there is nothing left to confirm.
await expect(page.locator(".welcome")).toHaveCount(0);
await expect(page.locator(".row").first()).toBeVisible();
});
test("a bridge that is not running says so, rather than printing a socket error", async ({
page,
}) => {
await openApp(page, { firstRun: true });
await lookUp(page, "[email protected]");
// Proton's own published settings, which point at Bridge on this machine, and the hint that says
// which password Bridge wants before it is asked for.
await expect(page.locator(".welcome-title")).toHaveText("Sign in to Proton Mail Bridge");
await expect(page.locator(".imap-fact").first()).toContainText("127.0.0.1, port 1143");
await expect(page.locator(".imap-form .field-hint").nth(1)).toContainText("Mailbox details");
await signIn(page);
await expect(page.locator(".imap-advice")).toContainText("Proton Bridge is not running");
// The socket error is still there for whoever wants it. It is just not the headline.
await expect(page.locator(".imap-said")).toContainText("Connection refused");
});
test("a bridge that is running connects like anything else", async ({ page }) => {
await openApp(page, { firstRun: true, storage: { "marginmail-dev-bridge": "1" } });
await lookUp(page, "[email protected]");
// A port that was typed rather than defaulted survives the security changing under it.
await page.getByRole("button", { name: "Change the servers" }).click();
await incoming(page).getByRole("tab", { name: "TLS", exact: true }).click();
await expect(incoming(page).getByLabel("Port")).toHaveValue("1143");
await page.keyboard.press("Escape");
await signIn(page);
await expect(page.locator(".welcome")).toHaveCount(0);
await expect(page.locator(".row").first()).toBeVisible();
});
test("the account arrives and the welcome screen is over", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
await expect(page.locator(".welcome")).toHaveCount(0);
await expect(page.locator(".row").first()).toBeVisible();
});
test("servers typed by hand connect the same account the same way", async ({ page }) => {
await openApp(page, { firstRun: true });
await connectWith(page, "[email protected]");
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await incoming(page).getByLabel("Server").fill("mail.harborlife.example");
await outgoing(page).getByLabel("Server").fill("mail.harborlife.example");
// Enter, from inside the sheet, because the whole flow has to be finishable without a mouse.
await outgoing(page).getByLabel("Server").press("Enter");
await expect(page.locator(".welcome")).toHaveCount(0);
await expect(page.locator(".row").first()).toBeVisible();
});
test("Settings runs the same flow, and the account it connects is in the list", async ({ page }) => {
await openApp(page);
await listReady(page);
await lookUpFromSettings(page, "[email protected]");
// The same panel as the welcome screen's, with the same sentence about where the settings came
// from, in a sheet whose head is doing the heading's job.
await expect(page.getByRole("dialog", { name: "Sign in to Fastmail" })).toBeVisible();
await expect(page.locator(".welcome-line")).toContainText("Fastmail publishes its own settings");
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "settings-connect-mail.png") });
await signIn(page);
// The account is written and its first sync takes the window, the way a Google account's does
// from Settings: the sign-in sheet is gone, the arriving panel is up, and when the pass ends
// what is behind it is the new account's Inbox with the first-run panel over it.
await expect(page.getByRole("dialog", { name: "Sign in to Fastmail" })).toHaveCount(0);
const panel = page.getByRole("dialog", { name: "Bringing in [email protected]" });
await expect(panel).toBeVisible();
await panel.getByRole("button", { name: "Start" }).click();
await expect(panel).toHaveCount(0);
await expect(page.locator(".settings")).toHaveCount(0);
await expect(page.locator(".account-chip")).toContainText("[email protected]");
const setUp = page.getByRole("dialog", { name: "You are set up" });
await expect(setUp).toBeVisible();
await setUp.getByRole("button", { name: "Done" }).click();
// The tour follows that panel for every account added, a password account included. Escape is
// how somebody on their third mailbox refuses it, and the keys belong to the window again after.
await expect(page.getByRole("dialog", { name: "Getting started" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator(".overlay")).toHaveCount(0);
// And the account is on the list without anything being reloaded.
await page.keyboard.press("Meta+,");
await expect(page.locator(".acct")).toHaveCount(3);
await expect(page.locator(".acct").nth(2).locator(".acct-addr")).toHaveText("[email protected]");
// And its card is the IMAP card: the servers it was just added with, and nothing to grant.
await expect(page.locator(".acct").nth(2).locator(".acct-servers")).toContainText(
"imap.fastmail.example, port 993, TLS",
);
await expect(page.locator(".acct").nth(2).getByRole("button", { name: "Grant" })).toHaveCount(0);
});
test("a refusal in Settings is said in the sheet, and the servers open over it", async ({
page,
}) => {
await openApp(page);
await listReady(page);
await lookUpFromSettings(page, "[email protected]");
await signIn(page);
// A flow no wire protocol has ever run for real fails in Settings the way it fails on the welcome
// screen: out loud, with what the server said, in the panel the password was typed into.
const sheet = page.getByRole("dialog", { name: "Sign in to Oakridge School Mail" });
await expect(sheet).toBeVisible();
await expect(sheet.locator(".welcome-trouble")).toContainText(
"Signing in to imap.oakridge-school.example did not work",
);
await expect(sheet.locator(".imap-advice")).toHaveText(
"This account wants an app password rather than the one you sign in with.",
);
// The servers take the window rather than a place inside the section, so the panel that opened
// them gives it up: one dialog, not a sheet stacked on a sheet.
await page.getByRole("button", { name: "Change the servers" }).click();
await expect(page.getByRole("dialog", { name: "Servers" })).toBeVisible();
await expect(page.locator('[role="dialog"]')).toHaveCount(1);
// Escape goes back to the flow that opened it, still inside Settings, with the account list
// behind it untouched.
await page.keyboard.press("Escape");
await expect(sheet).toBeVisible();
await expect(page.locator(".acct")).toHaveCount(2);
});
+216
View File
@@ -0,0 +1,216 @@
// The invitation card, and the permission it needs.
//
// The fixture's work account was granted everything except Calendar, which is what makes the piano
// thread's invitation render read only with a Grant on it. Answering one therefore needs an account
// that was granted the scope, and the fixture has no invitation on the account that was, so this
// spec shims `accounts_list` the way `failCommands` shims a refusal: the module request is answered
// with a wrapper around the real fixture. Nothing in `src/` knows it happened.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { listReady, MIDDAY, openApp, settle, toast } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
const CALENDAR = "https://www.googleapis.com/auth/calendar.events";
/** Every account, as it would read after somebody had granted the Calendar scope. */
async function withCalendarGranted(page: Page): Promise<void> {
// Granted by running the grant, rather than by dressing up the account list. `account_grant` is
// what the card's own Grant button calls, and the fixture now really does widen the account it
// names, so this exercises the path instead of standing in for it.
await page.route("**/src/dev/mockIpc.ts*", async (route) => {
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { mockCall as real } from "/src/dev/mockIpc.ts?real";`,
`const SCOPE = ${JSON.stringify(CALENDAR)};`,
`for (const account of await real("accounts_list")) {`,
` await real("account_grant", { accountId: account.id, extraScopes: [SCOPE] });`,
`}`,
`export const mockCall = real;`,
].join("\n"),
});
});
}
/** Makes `invite_respond` fail the way Rust fails when the scope was withheld. */
async function refuseForScope(page: Page): Promise<void> {
await page.route("**/src/dev/mockIpc.ts*", async (route) => {
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { mockCall as real } from "/src/dev/mockIpc.ts?real";`,
`const SCOPE = ${JSON.stringify(CALENDAR)};`,
`export async function mockCall(command, args) {`,
` if (command === "invite_respond") throw new Error("missing scope " + SCOPE);`,
` const out = await real(command, args);`,
` if (command !== "accounts_list") return out;`,
` return out.map((a) =>`,
` a.grantedScopes.includes(SCOPE) ? a : { ...a, grantedScopes: [...a.grantedScopes, SCOPE] },`,
` );`,
`}`,
].join("\n"),
});
});
}
/** Every command that went through the fixture, on `window.__calls`. */
async function recordCalls(page: Page): Promise<void> {
await page.route("**/src/dev/mockIpc.ts*", async (route) => {
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { mockCall as real } from "/src/dev/mockIpc.ts?real";`,
`window.__calls = [];`,
`export function mockCall(command, args) {`,
` window.__calls.push({ command, args: args ?? {} });`,
` return real(command, args);`,
`}`,
].join("\n"),
});
});
}
const openPiano = async (page: Page) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Piano on Wednesdays" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await expect(page.locator(".invite")).toBeVisible();
await settle(page);
};
test("the invitation renders with its date, its time and its organiser", async ({ page }) => {
await openPiano(page);
const card = page.locator(".invite");
await expect(card.locator(".invite-title")).toHaveText("Piano lesson: Cooper");
// A calendar page: the month above the day, so the date is read rather than parsed.
// Whatever the locale calls September in three or four letters, which is "Sept" in en-GB.
await expect(card.locator(".invite-date .mon")).toHaveText(/^Sept?$/);
await expect(card.locator(".invite-date .day")).toHaveText(/^\d{1,2}$/);
await expect(card.locator(".invite-when")).toContainText("17:00 to 17:45");
await expect(card.locator(".invite-when")).toContainText("Sunny Day Music, Bandra");
await expect(card.locator(".invite-organizer")).toHaveText("Organised by Sunny Day Music");
await expect(card.locator(".invite-open")).toHaveText("Open in Margin Calendar");
// It is under the message that carried it, not floating at the end of the thread.
const message = page.locator(".msg", { has: page.locator(".invite") });
await expect(message).toHaveCount(1);
});
test("y on a focused card answers, and the three keys are printed on it", async ({ page }) => {
await withCalendarGranted(page);
await openPiano(page);
const card = page.locator(".invite");
const keys = await card.locator(".button .key").allTextContents();
expect(keys).toEqual(["y", "m", "n"]);
await card.click();
await page.keyboard.press("y");
await expect(card.locator(".invite-answered")).toContainText("Going");
await expect.poll(async () => (await toast(page))?.text).toContain("Going · Piano lesson: Cooper");
});
test("m and n mean maybe and decline on the same card", async ({ page }) => {
await withCalendarGranted(page);
await openPiano(page);
const card = page.locator(".invite");
await card.click();
await page.keyboard.press("m");
await expect(card.locator(".invite-answered")).toContainText("Maybe");
await page.keyboard.press("n");
await expect(card.locator(".invite-answered")).toContainText("Not going");
});
test("the card's keys are the card's, and the list's meaning of y is not reached through it", async ({
page,
}) => {
await withCalendarGranted(page);
await openPiano(page);
await page.locator(".invite").click();
await page.keyboard.press("y");
// `y` in the list is a note, and a note sheet opening over an accepted invitation would be the
// one thing docs/keyboard.md says must not happen.
await expect(page.locator('[role="dialog"][aria-label="Note to self"]')).toHaveCount(0);
});
test("without the Calendar permission the card says so in one sentence and offers Grant", async ({
page,
}) => {
await openPiano(page);
const card = page.locator(".invite");
// Read only, and still a card: the date, the time and the organiser are the point of it.
await expect(card.locator(".invite-title")).toHaveText("Piano lesson: Cooper");
await expect(card.locator(".invite-when")).toContainText("17:00 to 17:45");
const sentence = card.locator(".invite-scope");
await expect(sentence).toHaveText(
"Answering an invitation needs Google Calendar, which this account has not given Margin yet.",
);
await expect(sentence).toHaveText(/^[^.]+\.$/);
await expect(card.locator(".button")).toHaveCount(1);
await expect(card.locator(".button")).toHaveText("Grant");
// The keys are not the card's while it cannot answer, so `y` is the list's note again.
await card.click();
await page.keyboard.press("y");
await expect(page.locator('[role="dialog"][aria-label="Note to self"]')).toBeVisible();
});
test("a refusal that names the scope turns the card read only", async ({ page }) => {
await refuseForScope(page);
await openPiano(page);
const card = page.locator(".invite");
await expect(card.locator(".invite-scope")).toHaveCount(0);
await card.click();
await page.keyboard.press("y");
// The scope list said it could and the call said it could not, and the call is the one that
// knows: no toast about a failure, one sentence and the way to fix it.
await expect(card.locator(".invite-scope")).toBeVisible();
await expect(card.locator(".button")).toHaveText("Grant");
await expect(card.locator(".invite-answered")).toHaveCount(0);
});
test("Grant asks for the Calendar scope on the account the thread is in", async ({ page }) => {
await recordCalls(page);
await openPiano(page);
await page.locator(".invite .button", { hasText: "Grant" }).click();
const grant = () =>
page.evaluate(() =>
(
window as unknown as { __calls: { command: string; args: Record<string, unknown> }[] }
).__calls.find((call) => call.command === "account_grant"),
);
await expect.poll(grant).toBeTruthy();
const call = await grant();
expect(call?.args.accountId).toBe("acct-1");
expect(call?.args.extraScopes).toEqual([CALENDAR]);
});
test("a thread with an invite card", async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openPiano(page);
await page.locator(".invite").click();
await settle(page);
await page.screenshot({ path: join(shots, "invite.png") });
});
+177
View File
@@ -0,0 +1,177 @@
// The keyboard, which is the way this app is meant to be used.
//
// Every assertion here presses a key and then looks at the page, because the point of the keymap is
// not that a handler ran: it is that the focus moved, the place changed, or the panel opened.
import { expect, test } from "@playwright/test";
import { MIDDAY, box, listReady, openApp, openDialog, paletteRows, place, rows, settle } from "./app";
test("j and k move the focus, and it stays on screen", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("j");
expect((await rows(page)).find((row) => row.selected)?.sender).toBe("Karthik Rao");
await page.keyboard.press("j");
await page.keyboard.press("j");
expect((await rows(page)).find((row) => row.selected)?.sender).toBe("Arun Kulkarni");
await page.keyboard.press("k");
expect((await rows(page)).find((row) => row.selected)?.sender).toBe("Maya Raghunathan");
// Down past the fold: the focused row has to be brought into the list's own viewport.
for (let i = 0; i < 12; i++) await page.keyboard.press("j");
await settle(page);
const list = await box(page.locator(".list"));
const focused = await box(page.locator(".row[data-selected]"));
expect(focused.top).toBeGreaterThanOrEqual(list.top - 1);
expect(focused.bottom).toBeLessThanOrEqual(list.bottom + 1);
});
test("Enter opens the focused thread", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await expect(page.locator(".thread-subject")).toHaveCount(0);
await page.keyboard.press("j");
await page.keyboard.press("j");
await page.keyboard.press("Enter");
await expect(page.locator(".thread-subject")).toHaveText("Dinner on Thursday?");
// Opening it marks it seen, so the dot goes.
await expect
.poll(async () => (await rows(page)).find((row) => row.sender === "Maya Raghunathan")?.unseen)
.toBe(false);
});
test("1, 2 and 3 change place", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
// The Feed takes the whole stage rather than the list column, so the place on the root is what
// says where you are; the list head only exists for the places that have a list.
await page.keyboard.press("2");
await expect.poll(() => place(page)).toBe("feed");
await page.keyboard.press("3");
await expect(page.locator(".list-title")).toHaveText("Paper Trail");
await page.keyboard.press("1");
await expect(page.locator(".list-title")).toHaveText("Inbox");
await expect.poll(() => place(page)).toBe("inbox");
// The header's segment says the same thing the list head does.
await expect(page.locator(".segment-option[data-active]")).toHaveText(/Inbox/);
});
test("Cmd+backslash hides the pane and the list takes the width", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
expect((await box(page.locator(".list-col"))).width).toBe(420);
await page.keyboard.press("Meta+\\");
await settle(page);
await expect(page.locator(".pane")).toHaveCount(0);
const wide = await box(page.locator(".list-col"));
expect(wide.width).toBeGreaterThan(1000);
// With no pane a thread opens in place, and Escape gives the list back.
await page.keyboard.press("j");
await page.keyboard.press("Enter");
await expect(page.locator(".thread-subject")).toBeVisible();
await expect(page.locator(".list-col")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.locator(".list-col")).toHaveCount(1);
await page.keyboard.press("Meta+\\");
await settle(page);
expect((await box(page.locator(".list-col"))).width).toBe(420);
});
test("the palette opens on Cmd+K, filters, and prints the key of every row that has one", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Meta+k");
expect(await openDialog(page)).toBe("Command palette");
const all = await paletteRows(page);
expect(all.map((row) => row.label)).toContain("Inbox");
expect(all.map((row) => row.label)).toContain("Paper Trail");
expect(all.find((row) => row.label === "Inbox")?.keys).toEqual(["1"]);
expect(all.find((row) => row.label === "Paper Trail")?.group).toBe("Places");
// The invariant is not that every row has a key. Several deliberately do not: the folders, the
// provider's labels, a setting the palette is the only way to reach, an action you run once a
// month. What must hold is that a row either prints a real key or prints no cap at all, because
// an empty keycap is a promise the keyboard does not keep.
for (const row of all) {
for (const key of row.keys) expect(key.trim()).not.toBe("");
}
// And the rows that do answer to a key print the one docs/keyboard.md gives them.
expect(all.find((row) => row.label === "Feed")?.keys).toEqual(["2"]);
expect(all.find((row) => row.label === "Reply later")?.keys).toEqual(["4"]);
expect(all.find((row) => row.label === "Sync now")?.keys).toEqual(["⌘R"]);
expect(all.find((row) => row.label === "Toggle dark mode")?.keys).toEqual([]);
expect(all.find((row) => row.label === "Contacts")?.keys).toEqual([]);
await page.locator(".palette-input").fill("paper");
const filtered = await paletteRows(page);
expect(filtered.map((row) => row.label)).toEqual(["Paper Trail"]);
await page.keyboard.press("Enter");
await expect(page.locator(".list-title")).toHaveText("Paper Trail");
expect(await openDialog(page)).toBeNull();
});
test("the shortcuts sheet is behind the question mark", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("?");
expect(await openDialog(page)).toBe("Keyboard shortcuts");
await expect(page.locator(".shortcuts-group").first()).toBeVisible();
await page.keyboard.press("Escape");
expect(await openDialog(page)).toBeNull();
});
test("an open panel shadows the view's keys", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("?");
expect(await openDialog(page)).toBe("Keyboard shortcuts");
await page.keyboard.press("3");
await expect(page.locator(".list-title")).toHaveText("Inbox");
await page.keyboard.press("Escape");
await page.keyboard.press("3");
await expect(page.locator(".list-title")).toHaveText("Paper Trail");
});
test("n and p walk the messages and o folds one away", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await expect(page.locator(".msg[data-collapsed]")).toHaveCount(1);
await page.keyboard.press("Shift+O");
await expect(page.locator(".msg[data-collapsed]")).toHaveCount(0);
await page.keyboard.press("n");
await expect(page.locator(".msg[data-focus]")).toHaveCount(1);
await page.keyboard.press("o");
await expect(page.locator(".msg[data-collapsed]")).toHaveCount(1);
});
test("Ctrl and a number switch account", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await expect(page.locator(".account-chip")).toContainText("[email protected]");
await page.keyboard.press("Control+2");
await expect(page.locator(".account-chip")).toContainText("[email protected]");
await page.keyboard.press("Control+0");
await expect(page.locator(".account-chip")).toContainText("All accounts");
});
+132
View File
@@ -0,0 +1,132 @@
// The design system's own suite. Two things are worth asserting about a stylesheet: that the
// geometry the layout is built from is really what it claims, and that nothing has quietly written
// a colour instead of reaching for a token. Everything else about how it looks is a screenshot and
// a pair of eyes, which is why this file writes two of them.
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
const root = fileURLToPath(new URL("..", import.meta.url));
const shots = join(root, "screenshots");
/** A hex literal, and not an id selector: #root is three characters of which none is a hex digit. */
const HEX = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/;
/** Back to the top with the layout finished, which is the state both pictures are taken in. */
async function settle(page: Page) {
await page.evaluate(
() =>
new Promise<void>((done) => {
window.scrollTo(0, 0);
requestAnimationFrame(() => requestAnimationFrame(() => done()));
}),
);
}
function stylesheets(dir: string): string[] {
return readdirSync(join(root, dir))
.filter((f) => f.endsWith(".css"))
.map((f) => join(dir, f));
}
function sources(dir: string): string[] {
return readdirSync(join(root, dir))
.filter((f) => f.endsWith(".tsx"))
.map((f) => join(dir, f));
}
test("the kit renders in both palettes", async ({ page }) => {
await page.goto("/#/kit");
await expect(page.locator(".kit")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
// The faces arrive after the first layout and everything below the fold moves when they do. A
// popover measured against the old layout would be hanging off nothing in the picture.
await settle(page);
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await page.screenshot({ path: join(shots, "kit-light.png"), fullPage: true });
await page.getByRole("tab", { name: "Dark" }).click();
await settle(page);
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.screenshot({ path: join(shots, "kit-dark.png"), fullPage: true });
});
test("the primitives with a fixed geometry have it", async ({ page }) => {
await page.goto("/#/kit");
await expect(page.locator(".kit")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
// The list column. Nothing on this page is 420px wide, but every screen is built from the token
// and a change to it would go unnoticed until a mockup stopped matching.
const listWidth = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--list-w").trim(),
);
expect(listWidth).toBe("420px");
const row = page.locator(".row").first();
expect((await row.boundingBox())?.height).toBe(46);
const avatar = row.locator(".avatar");
const box = await avatar.boundingBox();
expect(box?.width).toBe(30);
expect(box?.height).toBe(30);
// A row carrying a note grows for it rather than clipping it, so it is the one row that is not
// 46px and the assertion above has to be reading a row without one.
const noted = page.locator(".row[data-noted]").first();
expect((await noted.boundingBox())?.height).toBeGreaterThan(46);
});
test("no stylesheet in the design system writes a colour", () => {
const files = [...stylesheets("src/ui"), ...stylesheets("src/screens")];
expect(files.length).toBeGreaterThan(10);
const offenders: string[] = [];
for (const file of files) {
readFileSync(join(root, file), "utf8")
.split("\n")
.forEach((line, i) => {
if (HEX.test(line)) offenders.push(`${file}:${i + 1} ${line.trim()}`);
});
}
expect(offenders, "a colour belongs in src/styles/mail.css as a token").toEqual([]);
});
test("every text field in the app tells the webview not to fill it in", () => {
// WebKit treats an `<input type="text">` with no `autocomplete` as a form field, keeps what was
// typed into one, and offers it back later as a pill with a cross on it. Under the palette that
// reads as a suggestion the app is making, which is a promise the palette does not keep: every
// row in it is a command that exists, and the browser's memory of last Tuesday is not one.
//
// Source rather than the rendered page, because the point is that the next input somebody adds
// is covered too, and a field nobody wrote a spec for is exactly the one that will leak.
const files = [...sources("src/ui"), ...sources("src/screens")];
expect(files.length).toBeGreaterThan(10);
const offenders: string[] = [];
for (const file of files) {
const text = readFileSync(join(root, file), "utf8");
for (const tag of text.match(/<input\b[\s\S]*?\n\s*\/>/g) ?? []) {
if (/NO_AUTOFILL|autoComplete=/.test(tag)) continue;
offenders.push(`${file} ${tag.split("\n")[0].trim()}`);
}
}
expect(offenders, "spread NO_AUTOFILL from src/ui onto it").toEqual([]);
});
test("nothing in the app claims a keychain it does not use", () => {
// src-tauri/src/google/secrets.rs is explicit that the OS credential store was removed on every
// platform, and that what replaced it is an encrypted file in the app data directory whose
// desktop protection amounts to "anyone who can read the home directory can read the token".
// Three places in the interface said "keychain" anyway. In an app whose whole pitch is that your
// mail and your decisions stay on your own machine, overstating where a password sits is the one
// kind of copy that is worth a test.
const files = [...sources("src/ui"), ...sources("src/screens")];
const offenders = files.filter((file) =>
/keychain/i.test(readFileSync(join(root, file), "utf8")),
);
expect(offenders, "say what secrets.rs actually does: encrypted on this device").toEqual([]);
});
+283
View File
@@ -0,0 +1,283 @@
// What the list costs while a mailbox is still coming in.
//
// The other suites read what is on screen. This one reads what the app did to put it there: how
// many times it asked SQLite for a page, how many rows it drew to show the same thing, and whether
// it said out loud that it was busy. All three were the difference between this list and one that
// stays smooth through a sync, and none of them is visible in a screenshot.
//
// Nothing here reaches into the app either. The two counters are module shims served in place of
// `src/dev/mockIpc.ts` and `src/ui/Avatar.tsx`, the same trick `failCommands` uses: the app under
// test is the app that ships, and what is counted is what it really called and really drew.
import { expect, test, type Page } from "@playwright/test";
import { listReady, MIDDAY, openApp, openRow, rows, settle, toast } from "./app";
declare global {
interface Window {
/** Every IPC command the app made, by name. Present only under `countCommands`. */
__calls?: Record<string, number>;
/** How many times a row has drawn its avatar, which is once per row render. */
__rows?: number;
/** Lets the next page through. Present only under `failNextPage`. */
__allowPage?: boolean;
}
}
/**
* A fixture that says every list has another page and then refuses to produce it, until the test
* says otherwise. The fixture's own pages fit on one screen, so this is the only way to reach the
* foot of a list that is still paging.
*/
async function failNextPage(page: Page): Promise<void> {
await page.route("**/src/dev/mockIpc.ts*", async (route) => {
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { mockCall as real } from "/src/dev/mockIpc.ts?real";`,
`export function mockCall(command, args) {`,
` if (command !== "threads_list") return real(command, args);`,
` if (args.query.cursor === null) {`,
` return real(command, args).then((page) => ({ ...page, nextCursor: "more" }));`,
` }`,
` if (!window.__allowPage) return Promise.reject(new Error("the mailbox is offline"));`,
` return Promise.resolve({ threads: [], nextCursor: null, footer: null });`,
`}`,
].join("\n"),
});
});
}
/**
* Counts every command the app sends, and forwards each one to the fixture unchanged.
*
* `threads_list` is counted under its place as well as under its own name, because three parts of
* this screen run that one command: the list, and each of the two piles at the foot of it. Only
* the first of them is what a `store-changed` is supposed to cost.
*/
async function countCommands(page: Page): Promise<void> {
await page.route("**/src/dev/mockIpc.ts*", async (route) => {
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { mockCall as real } from "/src/dev/mockIpc.ts?real";`,
`window.__calls = {};`,
`const count = (key) => { window.__calls[key] = (window.__calls[key] ?? 0) + 1; };`,
`export function mockCall(command, args) {`,
` count(command);`,
` if (command === "threads_list") count(command + " " + args.query.place);`,
` return real(command, args);`,
`}`,
].join("\n"),
});
});
}
const calls = (page: Page, command: string): Promise<number> =>
page.evaluate((name) => window.__calls?.[name] ?? 0, command);
/**
* Counts row renders, by counting the avatar every row draws.
*
* The avatar is inside the row and nothing else in the list has one, so it renders exactly when
* its row does and never otherwise: a memoised row that decides it has nothing new to say never
* reaches this. Counting here rather than in `Row` itself is what keeps the measurement honest,
* because a counter wrapped around a memoised component would be counting the wrapper.
*
* The real component is called rather than mounted as a child, because a route that is fulfilled
* by hand is served to the browser rather than to Vite and a bare `react` import would not
* resolve. It takes props and returns markup, so calling it is what mounting it would have done.
*/
async function countRowRenders(page: Page): Promise<void> {
await page.route("**/src/ui/Avatar.tsx*", async (route) => {
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { Avatar as real } from "/src/ui/Avatar.tsx?real";`,
`export * from "/src/ui/Avatar.tsx?real";`,
`window.__rows = 0;`,
`export function Avatar(props) {`,
` window.__rows++;`,
` return real(props);`,
`}`,
].join("\n"),
});
});
}
/** The backend's invalidation, as the browser half of `src/ipc.ts` receives it. */
async function storeChanged(page: Page, scope: string, times = 1): Promise<void> {
await page.evaluate(
({ reason, count }) => {
for (let i = 0; i < count; i++) {
window.setTimeout(
() => window.dispatchEvent(new CustomEvent("store-changed", { detail: reason })),
i * 20,
);
}
},
{ reason: scope, count: times },
);
}
/** One account's status, as `sync-progress` carries it. */
async function syncProgress(page: Page, status: Record<string, unknown>): Promise<void> {
await page.evaluate((detail) => {
window.dispatchEvent(new CustomEvent("sync-progress", { detail }));
}, status);
}
/** The list's own query, as `countCommands` records it. */
const INBOX_QUERY = "threads_list inbox";
/** Long enough for the coalescing window to close and the query it holds to come back. */
const AFTER_THE_BURST = 700;
test("a burst of store-changed costs the list one query rather than one per event", async ({
page,
}) => {
await countCommands(page);
await openApp(page, { now: MIDDAY() });
await listReady(page);
const before = await calls(page, INBOX_QUERY);
// A sync pass that touched anything says so, and while a mailbox is filling in every pass does.
await storeChanged(page, "threads state", 6);
await page.waitForTimeout(AFTER_THE_BURST);
expect(await calls(page, INBOX_QUERY)).toBe(before + 1);
// And the burst left the list where it was rather than emptying it for a frame.
expect((await rows(page)).length).toBeGreaterThan(0);
});
test("a body landing behind an open thread re-reads that thread and leaves the list alone", async ({
page,
}) => {
await countCommands(page);
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openRow(page, 0);
const subject = await page.locator(".thread-subject").textContent();
const lists = await calls(page, INBOX_QUERY);
const views = await calls(page, "thread_view");
// What the cache warmer emits, several times a second, for every body it brings in.
await storeChanged(page, "thread", 3);
await page.waitForTimeout(AFTER_THE_BURST);
expect(await calls(page, INBOX_QUERY)).toBe(lists);
expect(await calls(page, "thread_view")).toBe(views + 3);
// Read again in place: the same thread, no flash, no scroll.
expect(await page.locator(".thread-subject").textContent()).toBe(subject);
});
test("a row that has not changed does not draw again when the list reloads", async ({ page }) => {
await countRowRenders(page);
await countCommands(page);
await openApp(page, { now: MIDDAY() });
await listReady(page);
const drawn = await page.evaluate(() => window.__rows ?? 0);
expect(drawn).toBeGreaterThan(0);
const before = await calls(page, INBOX_QUERY);
// A reload that really did happen, with a page of summaries that are new objects saying exactly
// what the old ones said, which is what every sync pass hands the list.
await storeChanged(page, "threads");
await expect.poll(() => calls(page, INBOX_QUERY)).toBe(before + 1);
await settle(page);
expect(await page.evaluate(() => window.__rows ?? 0)).toBe(drawn);
// And the other half of the bargain: a row whose state did change draws again. `j` takes the
// focus, which is a row that has to pick up the wash and the edge.
await page.keyboard.press("j");
await settle(page);
expect(await page.evaluate(() => window.__rows ?? 0)).toBeGreaterThan(drawn);
});
test("the header says what sync is doing, and stops when it is idle", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const line = page.locator(".sync-busy");
await expect(line).toHaveCount(0);
const caching = {
accountId: "acct-1",
phase: "caching",
lastSyncMs: null,
error: null,
pendingWrites: 0,
message: "Caching recent mail",
hydrated: 1_204,
total: 3_380,
oldestMs: null,
};
await syncProgress(page, caching);
await expect(line).toHaveText("Caching recent mail 1,204 of 3,380");
// Quiet, and nothing to press: a status that could take a click is a status that can be in the
// way of the mail behind it.
expect(await line.evaluate((el) => el.closest("button, a") !== null)).toBe(false);
// And the list is still the list while it says so.
expect((await rows(page)).length).toBeGreaterThan(0);
await syncProgress(page, { ...caching, phase: "idle", message: null, hydrated: 0, total: 0 });
await expect(line).toHaveCount(0);
});
test("a page that did not come is one line at the foot with a way to ask again", async ({ page }) => {
await failNextPage(page);
await openApp(page, { now: MIDDAY() });
await listReady(page);
// The list is shorter than the window, so the foot is reached on arrival and the next page is
// asked for at once. It is refused, and the foot says so; the rows above it are still the rows.
const foot = page.locator('.list-foot[data-state="error"]');
await expect(foot).toContainText("The rest of the list did not arrive.");
await expect(foot.locator(".button")).toHaveText("Try again");
expect((await rows(page)).length).toBeGreaterThan(0);
// Said at the foot, where the way back is, and not as a toast.
expect(await toast(page)).toBeNull();
await page.evaluate(() => {
window.__allowPage = true;
});
await foot.locator(".button").click();
await expect(page.locator('.list-foot[data-state="error"]')).toHaveCount(0);
});
test("a long subject takes the width before the snippet gets any of it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
// The row with the longest subject in the fixture, which is the one that used to be cut in half
// beside a snippet that was cut in half too.
const widths = await page.evaluate(() => {
const out: { subject: string; subjectWidth: number; snippetWidth: number; full: boolean }[] = [];
for (const row of document.querySelectorAll<HTMLElement>(".row")) {
const subject = row.querySelector<HTMLElement>(".row-subject");
const snippet = row.querySelector<HTMLElement>(".row-snippet");
if (!subject || !snippet) continue;
out.push({
subject: (subject.textContent ?? "").trim(),
subjectWidth: subject.getBoundingClientRect().width,
snippetWidth: snippet.getBoundingClientRect().width,
// Nothing of it is cut off.
full: subject.scrollWidth <= subject.clientWidth,
});
}
return out;
});
expect(widths.length).toBeGreaterThan(0);
const longest = widths.reduce((a, b) => (b.subject.length > a.subject.length ? b : a));
expect(longest.full).toBe(true);
// The old rule gave the subject 62% of the line whatever it needed, so the longest subject in
// the list was truncated with the snippet beside it holding room it could not use.
expect(longest.subjectWidth).toBeGreaterThan(longest.snippetWidth);
});
+176
View File
@@ -0,0 +1,176 @@
// The two piles, and the page that is a walk down one of them.
//
// A pile is its own query rather than a slice of the list, so the assertions here are deliberately
// about both at once: the row has to leave the Inbox and the card at the foot has to be showing it,
// in the same frame, before the fixture has answered.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { box, listReady, MIDDAY, openApp, rows, settle, toast } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
/** Focuses the row a spec means to act on, by walking to it rather than by clicking it open. */
async function focusRow(page: Page, sender: string): Promise<void> {
for (let i = 0; i < 20; i++) {
const at = (await rows(page)).find((row) => row.selected);
if (at?.sender === sender) return;
await page.keyboard.press("j");
}
throw new Error(`never reached ${sender}`);
}
const pile = (page: Page, index: number) => page.locator(".pile").nth(index);
test("the piles show their top thread and how many are under it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const later = pile(page, 0);
await expect(later).toContainText("Reply later");
await expect(later.locator(".pile-subject")).toHaveText("Re: Cooper's parent-teacher conference");
await expect(later.locator(".pile-who")).toHaveText("Jeff Wolfe, and 2 more");
const aside = pile(page, 1);
await expect(aside).toContainText("Set aside");
await expect(aside.locator(".pile-subject")).toHaveText("Les Misérables tickets");
await expect(aside.locator(".pile-who")).toHaveText("Caroline Bauhaus, and 1 more");
// Three threads on the left and two on the right, which is what the edges behind the card say.
await expect(later.locator(".pile-edge")).toHaveCount(2);
await expect(aside.locator(".pile-edge")).toHaveCount(1);
// The stack stays inside the footprint the action bar takes over.
const stack = await box(later);
const card = await box(later.locator(".pile-card"));
expect(card.bottom).toBe(stack.bottom);
expect(card.top).toBeGreaterThan(stack.top);
});
test("l takes the thread out of the Inbox and puts it on the pile, and l again brings it back", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("l");
// The row is gone and the card is showing it before the fixture has answered: the call goes
// behind the change, not in front of it.
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(0);
await expect(pile(page, 0).locator(".pile-subject")).toHaveText("Dinner on Thursday?");
await expect(pile(page, 0).locator(".pile-who")).toHaveText("Maya Raghunathan, and 3 more");
await expect.poll(async () => (await toast(page))?.text).toBe("Moved to Reply later");
expect((await toast(page))?.action).toContain("Undo");
// And it is really in the place behind the pile, at the top of it.
await page.keyboard.press("4");
await expect(page.locator(".list-title")).toHaveText("Reply later");
await expect.poll(async () => (await rows(page))[0]?.subject).toBe("Dinner on Thursday?");
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("l");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Taken out of Reply later");
await page.keyboard.press("1");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(1);
await expect(pile(page, 0).locator(".pile-subject")).toHaveText(
"Re: Cooper's parent-teacher conference",
);
});
test("the toast undoes the pile it is about", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const before = (await rows(page)).map((row) => row.sender);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("s");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(0);
await expect(pile(page, 1).locator(".pile-subject")).toHaveText("Dinner on Thursday?");
await expect.poll(async () => (await toast(page))?.text).toBe("Moved to Set aside");
await page.locator(".toast-action").click();
await expect.poll(async () => (await rows(page)).map((row) => row.sender)).toEqual(before);
await expect(pile(page, 1).locator(".pile-subject")).toHaveText("Les Misérables tickets");
// And it says so, the way `z` does, with nothing left on it to press twice.
await expect.poll(async () => (await toast(page))?.text).toBe("Undone: Moved to Set aside");
expect((await toast(page))?.action).toBeNull();
});
test("clicking a pile opens its place", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await pile(page, 1).click();
await expect(page.locator(".list-title")).toHaveText("Set aside");
await expect.poll(async () => (await rows(page)).map((row) => row.subject)).toEqual([
"Les Misérables tickets",
"Weekend in Coorg, places to stay",
]);
});
test("Shift+F is one item per Reply later thread, Tab moves and Escape leaves", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Shift+F");
await expect(page.locator(".focus-title")).toHaveText("Focus & Reply");
const items = page.locator(".focus-item");
await expect(items).toHaveCount(3);
await expect(page.locator(".focus-hint")).toContainText("3 left");
await expect(items.nth(0).locator(".focus-subject")).toHaveText(
"Re: Cooper's parent-teacher conference",
);
await expect(items.nth(0).locator(".focus-reply-head")).toContainText("Reply to Jeff Wolfe");
// The item the keyboard is on takes the ring, and Tab hands it on.
await expect(items.nth(0)).toHaveAttribute("data-active", "");
await page.keyboard.press("Tab");
await expect(items.nth(1)).toHaveAttribute("data-active", "");
await page.keyboard.press("Shift+Tab");
await expect(items.nth(0)).toHaveAttribute("data-active", "");
// The box is real and holds what is typed into it.
await items.nth(0).locator(".focus-box").fill("Wednesday at 4pm in person would suit us.");
await page.keyboard.press("Tab");
await expect(items.nth(0).locator(".focus-box")).toHaveValue(
"Wednesday at 4pm in person would suit us.",
);
await page.keyboard.press("Escape");
await expect(page.locator(".focus")).toHaveCount(0);
await expect(page.locator(".list-title")).toHaveText("Inbox");
// And a thread that was skipped is still on the pile, which is the whole promise of skipping.
await expect(pile(page, 0).locator(".pile-who")).toHaveText("Jeff Wolfe, and 2 more");
});
test("the Reply later place offers the page over it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("4");
await page.locator(".pill", { hasText: "Focus & Reply" }).click();
await expect(page.locator(".focus-title")).toHaveText("Focus & Reply");
});
test("the piles and the page they lead to, for the eye", async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "piles.png") });
await page.keyboard.press("Shift+F");
await expect(page.locator(".focus-item").first()).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "focus-reply.png") });
});
+385
View File
@@ -0,0 +1,385 @@
// The Screener, driven the way a person drives it: look at the cards, press a letter, and see
// which card left and what the toast says about it.
//
// A decision holds its card in place, buttons down, until the fixture answers, and only then does
// the card leave. That is deliberate and it is what the double-click test is about: a card that
// left at once put the next card under the pointer in time for the second click. Clear all is
// still optimistic, because nothing takes anything's place there.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { bodyText, listReady, MIDDAY, openApp, place, settle, toast } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
/** A sender at a consumer domain, which the fixture has none of and a domain rule cannot cover. */
const CONSUMER = {
accountId: "acct-1",
sender: { name: "Nadia Okafor", address: "[email protected]" },
threadKey: "<[email protected]>",
subject: "Sunday",
snippet: "Are you around on Sunday? I can bring the ladder.",
dateMs: Date.now() - 3_600_000,
suggestion: "inbox",
reason: "Written by a person",
waiting: 1,
};
interface ShimOptions {
/** Keeps every command and its arguments on `window`, so a spec can say what was not sent. */
record?: boolean;
/** Extra cards `screener_list` answers with, for a sender the fixture does not have. */
extra?: unknown[];
}
/**
* Answers the fixture module's own request with a shim in front of it, the same trick
* `failCommands` uses. Nothing in `src/` knows it happened: the app under test is the app that
* ships, and the fixture behind this is the fixture it ships with.
*/
async function shimIpc(page: Page, options: ShimOptions): Promise<void> {
await page.route("**/src/dev/mockIpc.ts*", async (route) => {
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { mockCall as real } from "/src/dev/mockIpc.ts?real";`,
`const extra = ${JSON.stringify(options.extra ?? [])};`,
`const record = ${options.record ? "true" : "false"};`,
`if (record) window.__calls = [];`,
`export function mockCall(command, args) {`,
` if (record) window.__calls.push({ command, args });`,
` const answer = real(command, args);`,
` if (command !== "screener_list" || extra.length === 0) return answer;`,
` const mine = extra.filter((c) => !args?.accountId || c.accountId === args.accountId);`,
` return answer.then((cards) => [...cards, ...mine]);`,
`}`,
].join("\n"),
});
});
}
function calls(page: Page): Promise<{ command: string; args: Record<string, unknown> }[]> {
return page.evaluate(() => (window as unknown as { __calls: never[] }).__calls ?? []);
}
interface CardInfo {
sender: string;
address: string;
subject: string;
snippet: string;
why: string;
/** The three buttons, with the letter each one prints. */
actions: { label: string; key: string }[];
focused: boolean;
}
/** Every card on screen, read the way a person reads it. */
function cards(page: Page): Promise<CardInfo[]> {
return page.evaluate(() => {
const text = (el: Element | null) => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
return [...document.querySelectorAll<HTMLElement>(".screen-card")].map((el) => {
const address = text(el.querySelector(".screen-name .addr"));
return {
sender: text(el.querySelector(".screen-name")).replace(address, "").trim(),
address,
subject: text(el.querySelector(".screen-subject")),
snippet: text(el.querySelector(".screen-snippet")),
why: text(el.querySelector(".pill")),
actions: [...el.querySelectorAll<HTMLElement>(".screen-actions .button")].map((button) => {
const key = text(button.querySelector(".key"));
const all = text(button);
return { label: (key && all.endsWith(key) ? all.slice(0, -key.length) : all).trim(), key };
}),
focused: el.hasAttribute("data-selected"),
};
});
});
}
/** Opens the Screener from the Inbox the way the keyboard does, and waits for the cards. */
async function openScreener(page: Page): Promise<void> {
await listReady(page);
await page.keyboard.press("6");
await expect(page.locator(".screen-card").first()).toBeVisible();
await settle(page);
}
test("a card per sender, with its reason and where it suggests they go", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await openScreener(page);
expect(await place(page)).toBe("screener");
await expect(page.locator(".screen-intro h1")).toHaveText("Screener");
await expect(page.locator(".screen-intro p")).toContainText(
"Three people wrote to you for the first time",
);
const all = await cards(page);
expect(all.map((c) => c.sender)).toEqual([
"Todd Markham",
"The Browser",
"Evite on behalf of Robyn Madison",
]);
const todd = all[0];
expect(todd.address).toBe("[email protected]");
expect(todd.subject).toBe("Re: Life insurance quote");
expect(todd.snippet).toContain("I hope all is well with you");
expect(todd.why).toBe("Written by a person · suggested Inbox");
// The suggestion is on the button as well as in the pill: `y` says where it is going.
expect(todd.actions).toEqual([
{ label: "Yes, to Inbox", key: "y" },
{ label: "Elsewhere", key: "v" },
{ label: "No", key: "n" },
]);
expect(all[1].why).toBe("Has an unsubscribe header · suggested Feed");
expect(all[1].actions[0].label).toBe("Yes, to Feed");
expect(all[2].why).toBe("Sent by a service for a person · suggested Paper Trail");
expect(all[2].actions[0].label).toBe("Yes, to Paper Trail");
// The keyboard is on the top card, and it is the only one wearing the ring.
expect(all.map((c) => c.focused)).toEqual([true, false, false]);
// One faint sentence about what a No costs and how to take it back.
await expect(page.locator(".screen-note")).toContainText("Screened out");
await expect(page.locator(".screen-note")).toContainText("contact card");
});
test("y accepts the suggestion, the next card takes the focus, and the toast puts it back", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await openScreener(page);
await page.keyboard.press("y");
// Gone once the fixture has answered, which in the fixture is the same frame.
await expect(page.locator(".screen-card")).toHaveCount(2);
const after = await cards(page);
expect(after[0].sender).toBe("The Browser");
// Dealing cards: the hand stays where it was rather than going back to the top of the pile.
expect(after[0].focused).toBe(true);
await expect.poll(async () => (await toast(page))?.text).toBe("Screened in to Inbox");
expect((await toast(page))?.action).toContain("Undo");
await page.locator(".toast-action").click();
await expect(page.locator(".screen-card")).toHaveCount(3);
expect((await cards(page))[0].sender).toBe("Todd Markham");
// And it says what it undid, the way `z` does, with nothing left to press twice.
await expect.poll(async () => (await toast(page))?.text).toBe("Undone: Screened in to Inbox");
expect((await toast(page))?.action).toBeNull();
});
test("a double-click on Yes decides one sender, because the card waits for the answer", async ({
page,
}) => {
await shimIpc(page, { record: true });
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await openScreener(page);
const first = page.locator(".screen-card").first();
await first.locator(".screen-actions .button").first().dblclick();
// The card is still there, marked, with all three buttons down, so the second click landed on
// a button that could not take it rather than on the next sender's Yes.
await expect(first).toHaveAttribute("data-state", "deciding");
await expect(page.locator(".screen-card")).toHaveCount(3);
for (const button of await first.locator(".screen-actions .button").all()) {
await expect(button).toBeDisabled();
}
await expect(page.locator(".screen-card")).toHaveCount(2, { timeout: 5_000 });
expect((await cards(page))[0].sender).toBe("The Browser");
await expect.poll(async () => (await toast(page))?.text).toBe("Screened in to Inbox");
const decided = (await calls(page)).filter((c) => c.command === "screener_decide");
expect(decided).toHaveLength(1);
expect(decided[0].args).toMatchObject({ address: "[email protected]" });
});
/** A held sender whose thread the fixture does hold, so an expanded card has a body to fetch. */
const CACHED = {
accountId: "acct-1",
sender: { name: "Meridian Properties", address: "[email protected]" },
threadKey: "<[email protected]>",
subject: "Lease renewal for the studio",
snippet: "Attached the revised draft. The only change is clause 7, which now",
dateMs: Date.now() - 7_200_000,
suggestion: "paper-trail",
reason: "Sent by a service for a person",
waiting: 1,
};
test("an expanded card whose body is not cached breathes until it arrives", async ({ page }) => {
await shimIpc(page, { extra: [CACHED] });
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await openScreener(page);
await page.locator(".screen-card", { hasText: "Meridian Properties" }).click();
await page.keyboard.press("Enter");
// The skeleton in the message's slot, over the Reply button, and no frame until there is a body
// to put in it.
const slot = page.locator(".screen-message");
await expect(slot.locator(".msg-pending")).toBeVisible();
await expect(slot.locator(".msg-frame")).toHaveCount(0);
await expect(slot.locator(".button")).toHaveText(/Reply/);
await expect(slot.locator(".msg-frame")).toBeVisible({ timeout: 10_000 });
await expect(slot.locator(".msg-pending")).toHaveCount(0);
await expect
.poll(async () => (await bodyText(page)).join(" "), { timeout: 10_000 })
.toContain("Attached the revised draft");
});
test("j and k walk the cards", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await openScreener(page);
const focused = async () => (await cards(page)).find((c) => c.focused)?.sender;
expect(await focused()).toBe("Todd Markham");
await page.keyboard.press("j");
await expect.poll(focused).toBe("The Browser");
await page.keyboard.press("j");
await expect.poll(focused).toBe("Evite on behalf of Robyn Madison");
// The end of the pile is the end of the pile, not the top of it again.
await page.keyboard.press("j");
await expect.poll(focused).toBe("Evite on behalf of Robyn Madison");
await page.keyboard.press("k");
await expect.poll(focused).toBe("The Browser");
});
test("Enter opens the whole message on the card, and Enter closes it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await openScreener(page);
await page.keyboard.press("Enter");
await expect(page.locator(".screen-message")).toHaveCount(1);
// Replying screens the sender in, so the card says so beside the button.
await expect(page.locator(".screen-message .button")).toHaveText(/Reply/);
await expect(page.locator(".screen-message-note")).toContainText("screens this sender in");
// The body itself is a `thread_view`, which the dev fixture cannot answer for this sender: its
// Screener cards name thread keys that are in no place and so are in no fixture list. So what
// the slot shows is the line for a body that did not come, with the way to ask again, rather
// than an empty frame over the button.
const failed = page.locator(".screen-message [data-state=\"error\"]");
await expect(failed).toContainText("Could not fetch this message");
await expect(failed.getByText("Try again")).toBeVisible();
await page.keyboard.press("Enter");
await expect(page.locator(".screen-message")).toHaveCount(0);
});
test("v opens the picker, and a consumer address is not offered a domain rule", async ({ page }) => {
await shimIpc(page, { extra: [CONSUMER] });
await openApp(page, { now: MIDDAY() });
await openScreener(page);
await expect(page.locator(".screen-card")).toHaveCount(4);
// A company can carry a rule for everyone at it.
await page.keyboard.press("v");
await expect(page.locator('[role="dialog"]')).toHaveAttribute(
"aria-label",
"Where does their mail go?",
);
await expect(page.locator(".screen-option")).toHaveText(["Inbox", "Feed", "Paper Trail"]);
await expect(page.locator(".toggle-label")).toHaveText("Everyone at harborlife.example");
await page.keyboard.press("Escape");
// Nobody at gmail.com is one sender, and `state::write::set_rule` refuses the rule, so the
// toggle is not offered rather than offered and taken back.
await page.locator(".screen-card", { hasText: "Nadia Okafor" }).click();
await page.keyboard.press("v");
await expect(page.locator(".screen-option")).toHaveCount(3);
await expect(page.locator(".toggle-label")).toHaveCount(0);
await page.locator(".screen-option", { hasText: "Paper Trail" }).click();
await expect.poll(async () => (await toast(page))?.text).toBe("Screened in to Paper Trail");
});
test("n screens the sender out and sends nothing", async ({ page }) => {
await shimIpc(page, { record: true });
await openApp(page, { now: MIDDAY() });
await openScreener(page);
await page.keyboard.press("n");
await expect(page.locator(".screen-card")).toHaveCount(2);
// Plain words about what happened, and nothing that congratulates anybody.
await expect.poll(async () => (await toast(page))?.text).toBe("Screened out");
const sent = await calls(page);
const decided = sent.filter((c) => c.command === "screener_decide");
expect(decided).toHaveLength(1);
expect(decided[0].args).toMatchObject({
address: "[email protected]",
destination: "screened-out",
wholeDomain: false,
});
// Nothing is sent. Not a draft, not a message, not an unsubscribe on the way out.
expect(
sent.filter((c) => /^(send|draft_save|unsubscribe)/.test(c.command)).map((c) => c.command),
).toEqual([]);
});
test("Clear all asks before it screens everybody out", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await openScreener(page);
await page.locator(".screen-tools .button", { hasText: "Clear all" }).click();
await expect(page.locator(".confirm-title")).toHaveText("Screen out three senders?");
// Asking is asking: nobody has left the pile yet.
await expect(page.locator(".screen-card")).toHaveCount(3);
await page.locator(".confirm-actions .button", { hasText: "Cancel" }).click();
await expect(page.locator(".screen-card")).toHaveCount(3);
await page.locator(".screen-tools .button", { hasText: "Clear all" }).click();
await page.locator(".confirm-actions .button", { hasText: "Screen them out" }).click();
await expect(page.locator(".screen-card")).toHaveCount(0);
await expect(page.locator(".empty-state")).toHaveText("No one is waiting");
await expect.poll(async () => (await toast(page))?.text).toBe("3 senders screened out");
});
test("the Inbox pill counts senders, prints its key, and goes when nobody is waiting", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const pill = page.locator(".list-head .pill");
await expect(pill).toHaveText(/Screen 3 new senders/);
// The only count anywhere in this app, and it counts senders rather than messages.
await expect(pill.locator(".key")).toHaveText("6");
await pill.click();
expect(await place(page)).toBe("screener");
await page.locator(".screen-tools .button", { hasText: "Clear all" }).click();
await page.locator(".confirm-actions .button", { hasText: "Screen them out" }).click();
await expect(page.locator(".screen-card")).toHaveCount(0);
await page.keyboard.press("1");
await listReady(page);
await expect(page.locator(".list-head .pill")).toHaveCount(0);
});
// One picture per test rather than a loop, because `openApp` seeds the store once per context and
// a second call on the same page would take the dark picture in the light palette.
for (const theme of ["light", "dark"] as const) {
test(`the Screener in ${theme}`, async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openApp(page, { theme, now: MIDDAY() });
await openScreener(page);
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, `screener-${theme}.png`) });
});
}
+131
View File
@@ -0,0 +1,131 @@
// Search, which is not a place you go to but a thing that takes the stage and gives it back.
//
// The two assertions worth making are the ones a store on its own cannot promise: that the results
// behave like a list (the pane still opens a thread from them) and that Escape puts back exactly
// what was there, scroll and selection included.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
import { checkedRows, listReady, listScroll, MIDDAY, openApp, rows, settle } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
const senders = (page: import("@playwright/test").Page) =>
rows(page).then((all) => all.map((row) => row.sender));
test("/ focuses search, and Escape gives the Inbox back with its scroll and its selection", async ({
page,
}) => {
// Shorter than the suite's default on purpose. What is being proved is that Escape puts the
// scroll back, which needs a list with somewhere to scroll to, and tying that to the number of
// rows that happen to fit at the current row height is how this test broke when the row height
// changed.
await page.setViewportSize({ width: 1440, height: 560 });
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("j");
await page.keyboard.press("x");
await page.locator(".list").evaluate((el) => el.scrollTo(0, el.scrollHeight));
await settle(page);
const parked = await listScroll(page);
expect(parked).toBeGreaterThan(0);
await page.keyboard.press("/");
expect(await page.evaluate(() => document.activeElement?.className)).toBe("search-input");
await page.locator(".search-input").fill("lisbon");
await expect(page.locator(".list-title")).toHaveText("Search");
await expect.poll(() => senders(page)).toEqual(["Airbnb"]);
await expect(page.locator(".search-note")).toHaveText(
"Searching what is on this device. Older mail is on Gmail.",
);
// The pane works as usual over the results.
await page.locator(".row").first().click();
await expect(page.locator(".thread-subject")).toHaveText("Your reservation in Lisbon is confirmed");
await page.keyboard.press("Escape");
await expect(page.locator(".list-title")).toHaveText("Inbox");
await expect.poll(() => listScroll(page)).toBeGreaterThan(parked - 4);
expect(await listScroll(page)).toBeLessThan(parked + 4);
expect(await checkedRows(page)).toEqual(["Karthik Rao"]);
});
test("search reaches spam, and the row says which place it came out of", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
// The message you most need to find is the one something else decided you should not see, so a
// search with no `in:` in it reaches spam. Nothing in the Inbox would have found this.
await page.keyboard.press("/");
await page.locator(".search-input").fill("verification");
await expect.poll(() => senders(page)).toEqual(["Aćcount Security"]);
await expect(page.locator(".row .row-mark[title='Spam']")).toHaveCount(1);
// And `in:` narrows to one place when that is what was meant.
await page.locator(".search-input").fill("notice in:trash");
await expect.poll(() => senders(page)).toEqual(["Parcel Notice"]);
await expect(page.locator(".row .row-mark[title='Trash']")).toHaveCount(1);
});
test("an operator in the query reads as one", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("/");
await page.locator(".search-input").fill("from:maya dinner");
await expect.poll(() => senders(page)).toEqual(["Maya Raghunathan"]);
// The query is printed at the head of its results, with the operator drawn as one.
await expect(page.locator(".search-terms .pill")).toHaveText("from:maya");
await expect(page.locator(".search-terms .search-word")).toHaveText("dinner");
});
test("the foot of a short answer offers the provider's search", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("/");
await page.locator(".search-input").fill("lease");
await expect.poll(() => senders(page)).not.toEqual([]);
const older = page.locator(".search-foot .button", { hasText: "Search older mail on Gmail" });
await expect(older).toBeVisible();
await older.click();
// Asking the provider replaces the note with what came back, and the offer is not made twice.
await expect(page.locator(".search-note")).toContainText("from Gmail");
await expect(older).toHaveCount(0);
});
test("the field says it is looking until the answer lands", async ({ page }) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.keyboard.press("/");
await expect(page.locator(".search")).toHaveAttribute("data-phase", "idle");
await page.locator(".search-input").fill("lisbon");
// The Inbox's rows are still under the Search title for most of a second, and the phase on the
// field is the one thing that says they are not the answer yet.
await expect(page.locator(".search")).toHaveAttribute("data-phase", "searching");
await expect.poll(() => senders(page)).toEqual(["Airbnb"]);
await expect(page.locator(".search")).toHaveAttribute("data-phase", "idle");
});
test("search results", async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("/");
await page.locator(".search-input").fill("from:arun lease");
await expect.poll(() => senders(page)).toEqual(["Arun Kulkarni"]);
await page.locator(".row").first().click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "search.png") });
});
+133
View File
@@ -0,0 +1,133 @@
// Selection and the bar that comes with it.
//
// The measurement that matters here is the footprint: the action bar takes the piles' place and
// their exact box, because the foot of the list jumping by a few pixels every time a checkbox
// appears is the kind of thing nobody reports and everybody feels.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
import { actionBar, box, checkedRows, listReady, MIDDAY, openApp, rows, settle, toast } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
test("x shows the checkbox and hands the piles' footprint to the action bar", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await expect(page.locator(".row-check")).toHaveCount(0);
const piles = await box(page.locator(".piles"));
await page.keyboard.press("j");
await page.keyboard.press("x");
// The gutter turns into a checkbox on every row, not only the one that was picked.
await expect(page.locator(".row-check").first()).toBeVisible();
expect(await checkedRows(page)).toEqual(["Karthik Rao"]);
await expect(page.locator(".piles")).toHaveCount(0);
const bar = await box(page.locator(".action-bar"));
expect(bar.top).toBe(piles.top);
expect(bar.bottom).toBe(piles.bottom);
expect(bar.left).toBe(piles.left);
expect(bar.width).toBe(piles.width);
// The six verbs that fit, in the order docs/features.md section 13 lists them, each printing its
// key. The other four in that section keep their keys and lose their buttons, which the bar says
// in a comment: 420 pixels is three labelled buttons to a row.
expect(await actionBar(page)).toEqual([
{ label: "Reply later", key: "l" },
{ label: "Set aside", key: "s" },
{ label: "Snooze", key: "b" },
{ label: "Mark seen", key: "u" },
{ label: "Archive", key: "e" },
{ label: "Trash", key: "#" },
]);
});
test("Shift+J extends, Cmd+A takes the rest, and Escape gives it all back", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const all = (await rows(page)).map((row) => row.sender);
await page.keyboard.press("j");
await page.keyboard.press("x");
await page.keyboard.press("Shift+J");
expect(await checkedRows(page)).toEqual(all.slice(0, 2));
await page.keyboard.press("Shift+J");
expect(await checkedRows(page)).toEqual(all.slice(0, 3));
// Back up the way it came: an extension is a range from the anchor, not a run of toggles.
await page.keyboard.press("Shift+K");
expect(await checkedRows(page)).toEqual(all.slice(0, 2));
await page.keyboard.press("Meta+a");
expect(await checkedRows(page)).toEqual(all.slice(1));
await page.keyboard.press("Escape");
await expect(page.locator(".action-bar")).toHaveCount(0);
await expect(page.locator(".pile")).toHaveCount(2);
await expect(page.locator(".row-check")).toHaveCount(0);
});
test("a bulk archive is one toast, one undo and one call", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const before = (await rows(page)).map((row) => row.sender);
await page.keyboard.press("j");
await page.keyboard.press("x");
await page.keyboard.press("Shift+J");
await page.keyboard.press("Shift+J");
await page.keyboard.press("e");
for (const sender of before.slice(0, 3)) {
await expect(page.locator(".row", { hasText: sender })).toHaveCount(0);
}
await expect(page.locator(".toast")).toHaveCount(1);
await expect.poll(async () => (await toast(page))?.text).toBe("3 threads archived");
// The rows are gone, so the selection that pointed at them is gone with them.
await expect(page.locator(".action-bar")).toHaveCount(0);
// One `z` puts all three back, which is the only way to see from out here that the three rows
// went out as one call and came back as one entry on the undo stack.
await page.keyboard.press("z");
await expect.poll(async () => (await rows(page)).map((row) => row.sender)).toEqual(before);
});
test("the toast undoes its own action rather than the latest one", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const before = (await rows(page)).map((row) => row.sender);
// Two archives in a row: the focus lands on whatever took the first row's place, so the second
// key archives the row under it without having to be aimed again.
await page.keyboard.press("j");
await page.keyboard.press("e");
await page.keyboard.press("e");
await expect(page.locator(".toast")).toHaveCount(1);
// The toast on screen belongs to the second archive and carries its own token, so its button
// takes back the second and leaves the first exactly where it put it.
await expect.poll(async () => (await toast(page))?.text).toBe("Archived");
await page.locator(".toast-action").click();
await expect(page.locator(".row", { hasText: before[1] })).toHaveCount(1);
await expect(page.locator(".row", { hasText: before[0] })).toHaveCount(0);
});
test("the list with a selection", async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("j");
await page.keyboard.press("x");
await page.keyboard.press("Shift+J");
await page.keyboard.press("Shift+J");
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "selection.png") });
});
+753
View File
@@ -0,0 +1,753 @@
// Settings, which is a place rather than a panel: it takes the stage, the header stays, and Escape
// gives the stage back.
//
// All twelve sections are built, and the rule this suite holds them to is the one in docs/plan.md:
// a section is real controls over real commands or it is not a section. So every one of them is
// opened and asked to show the control it is for, and the two that change something on disk before
// they change the screen are driven all the way through.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { failCommands, listReady, MIDDAY, openApp, settle } from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
const SECTIONS = [
"Accounts",
"Appearance",
"Mail",
"Privacy",
"Screener",
"Piles and snooze",
"Writing",
"Notifications",
"Keyboard",
"Backup",
"Data",
"About",
];
async function openSettings(page: Page, storage?: Record<string, string>): Promise<void> {
await openApp(page, { now: MIDDAY(), storage });
await listReady(page);
await page.keyboard.press("Meta+,");
await expect(page.locator(".settings")).toBeVisible();
}
const rail = (page: Page) =>
page.locator(".settings-tab").evaluateAll((tabs) => tabs.map((tab) => tab.textContent ?? ""));
test("Cmd+, opens settings on Accounts, with every section in the rail", async ({ page }) => {
await openSettings(page);
expect(await rail(page)).toEqual(SECTIONS);
await expect(page.locator(".settings-tab[data-active]")).toHaveText("Accounts");
await expect(page.locator(".settings-title")).toHaveText("Accounts");
// The stage, not an overlay: the header is still there and nothing is modal.
await expect(page.locator(".account-chip")).toBeVisible();
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
await expect(page.locator(".list-col")).toHaveCount(0);
mkdirSync(shots, { recursive: true });
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "settings.png") });
// Escape gives the stage back to the mail.
await page.keyboard.press("Escape");
await expect(page.locator(".list-col")).toBeVisible();
});
test("j and k walk the rail", async ({ page }) => {
await openSettings(page);
await page.keyboard.press("j");
await expect(page.locator(".settings-tab[data-active]")).toHaveText("Appearance");
await page.keyboard.press("j");
await expect(page.locator(".settings-tab[data-active]")).toHaveText("Mail");
await expect(page.locator(".settings-title")).toHaveText("Mail");
await page.keyboard.press("k");
await expect(page.locator(".settings-tab[data-active]")).toHaveText("Appearance");
});
/**
* The control each section exists for. About is the one that is a statement rather than a form, so
* what it owes the reader is the version string a bug report carries.
*/
const CONTROLS: [string, string][] = [
["Accounts", ".acct .swatch"],
["Appearance", '[role="tablist"][aria-label="Theme"] [role="tab"]'],
["Mail", '[role="tablist"][aria-label^="Storage window"] [role="tab"]'],
["Privacy", '[role="tablist"][aria-label="Remote images"] [role="tab"]'],
["Screener", '[role="switch"]'],
["Piles and snooze", 'select[aria-label="Tomorrow morning"]'],
["Writing", '[role="tablist"][aria-label="Undo delay"] [role="tab"]'],
["Notifications", '[role="switch"]'],
["Keyboard", 'button:has-text("Open the file")'],
["Backup", '[role="tablist"][aria-label="Backup store"] [role="tab"]'],
["Data", 'button:has-text("Export")'],
["About", ".settings-version"],
];
// One switch is the system's permission and the app's preference together, and the rest of the
// section exists only while it is on.
test("notifications are one switch, and the rest only once it is on", async ({ page }) => {
await openSettings(page, { "marginmail-dev-notify": "denied" });
await page.locator(".settings-tab", { hasText: "Notifications" }).click();
const allow = page.getByRole("switch", { name: "Allow notifications" });
const inbox = page.getByRole("switch", { name: "Notify about the Inbox" });
// Refused by the system: off and locked, one line says so, one button fixes it, nothing else.
await expect(allow).toBeDisabled();
await expect(allow).toHaveAttribute("aria-checked", "false");
await expect(page.locator('[data-permission="denied"]')).toContainText(
"turned off for Margin Mail in System Settings",
);
await expect(page.getByRole("button", { name: "Open System Settings" })).toBeVisible();
await expect(inbox).toHaveCount(0);
await expect(page.getByRole("button", { name: "Send a test notification" })).toHaveCount(0);
await page.screenshot({ path: join(shots, "settings-notifications-denied.png") });
// Never asked: off, and turning it on asks. A yes brings the rest with it, the Inbox included.
await page.evaluate(() => localStorage.setItem("marginmail-dev-notify", "prompt"));
await page.locator(".settings-tab", { hasText: "Writing" }).click();
await page.locator(".settings-tab", { hasText: "Notifications" }).click();
await expect(allow).toBeEnabled();
await expect(allow).toHaveAttribute("aria-checked", "false");
await expect(inbox).toHaveCount(0);
await allow.click();
await expect(allow).toHaveAttribute("aria-checked", "true");
await expect(inbox).toHaveAttribute("aria-checked", "true");
await expect(page.getByRole("switch", { name: "Notify about the Feed" })).toHaveAttribute(
"aria-checked",
"false",
);
await expect(page.getByRole("button", { name: "Send a test notification" })).toBeEnabled();
await page.screenshot({ path: join(shots, "settings-notifications.png") });
// Off hides the rest again.
await allow.click();
await expect(allow).toHaveAttribute("aria-checked", "false");
await expect(inbox).toHaveCount(0);
});
test("every section in the rail opens on real controls", async ({ page }) => {
await openSettings(page);
for (const [name, control] of CONTROLS) {
await page.locator(".settings-tab", { hasText: name }).click();
await expect(page.locator(".settings-title")).toHaveText(name);
await expect(page.locator(`.settings-inner ${control}`).first()).toBeVisible();
}
// Nothing that cannot work: the recovery phrase is not offered a second time, and there is no
// field for an OAuth client of your own, because no command takes one.
await page.locator(".settings-tab", { hasText: "Accounts" }).click();
await expect(page.getByRole("button", { name: /OAuth/i })).toHaveCount(0);
});
test("Accounts shows both accounts, their permissions, and a Grant for the missing one", async ({
page,
}) => {
await openSettings(page);
await expect(page.locator(".acct")).toHaveCount(2);
await expect(page.locator(".acct-addr").first()).toHaveText("[email protected]");
await expect(page.locator(".acct-addr").nth(1)).toHaveText("[email protected]");
// Plain English, never the name of the scope.
const first = page.locator(".acct").first();
await expect(first.locator(".scope .what").first()).toHaveText("Read and change your mail");
await expect(first.locator(".scope")).toHaveCount(6);
await expect(first.locator(".scope .state")).toHaveCount(5);
// One permission is missing on the first account, so exactly one line carries a Grant and says
// what it costs.
const grant = first.getByRole("button", { name: "Grant" });
await expect(grant).toHaveCount(1);
await expect(first.locator(".scope[data-missing] .what")).toHaveText(
"Answer calendar invitations",
);
await expect(first.locator(".scope-why")).toContainText("Calendar is not connected");
// The second account granted everything, so it does not list six lines to say so.
await expect(page.locator(".acct").nth(1).locator(".scope")).toHaveCount(0);
await expect(page.locator(".acct").nth(1)).toContainText("All granted");
// The colour is pickable, and the avatar wears the one that was picked.
await first.locator('.swatch[data-hue="7"]').click();
await expect(first.locator(".swatch[data-on]")).toHaveAttribute("data-hue", "7");
await expect(first.locator(".avatar")).toHaveAttribute("data-hue", "7");
// Removing revokes at Google as well, and the question says what that costs before it runs. The
// only choice left is whether the local data goes too, and the box for that starts ticked.
await page.getByRole("button", { name: "Remove an account" }).click();
await expect(page.getByRole("dialog", { name: "Remove an account" })).toBeVisible();
await expect(page.locator(".remove-row").first().getByRole("button", { name: "Revoke" })).toHaveCount(0);
await page.locator(".remove-row").first().getByRole("button", { name: "Remove" }).click();
await expect(page.locator(".confirm-body")).toContainText("Margin Calendar");
await expect(page.locator(".confirm-body")).toContainText("every machine");
await expect(page.locator(".confirm-option input")).toBeChecked();
});
test("Add account asks for the address and works the rest out", async ({ page }) => {
await openSettings(page);
await page.getByRole("button", { name: "Add account" }).click();
const sheet = page.getByRole("dialog", { name: "Add an account" });
await expect(sheet).toBeVisible();
// Nothing has been started yet and the browser has not been opened: the button that opens this
// used to be Google's, so a Proton or a Fastmail address was sent to a Google consent page. Now
// it is one field, and no provider is named as a button.
await expect(page.locator(".settings-waiting")).toHaveCount(0);
await expect(sheet.getByLabel("Email address")).toBeFocused();
await expect(sheet.getByRole("button", { name: "Continue" })).toBeVisible();
await expect(sheet.getByRole("button", { name: /Google/ })).toHaveCount(0);
mkdirSync(shots, { recursive: true });
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "settings-add-account.png") });
// A Google address is still one press past the address and no typing, and it still hands the
// flow to the browser. The sheet gives the window up: the strip above the list is what waits.
await sheet.getByLabel("Email address").fill("[email protected]");
await page.keyboard.press("Enter");
await expect(sheet).toHaveCount(0);
await expect(page.locator(".settings-waiting")).toContainText("Waiting for Google in your browser");
});
test("Add account holds a panel over the window until the mail is in, then opens that Inbox", async ({
page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: "Add account" }).click();
await page.getByLabel("Email address").fill("[email protected]");
await page.keyboard.press("Enter");
// Consent comes back and the account is written. What used to happen next was nothing: the new
// account sat under Settings on an empty Inbox with one line in the header. Now the wait for
// its mail takes the window, in the engine's words, with the count.
const panel = page.getByRole("dialog", { name: "Bringing in [email protected]" });
await expect(panel).toBeVisible();
// First the one question, because the first sync reads the answer: a year, this time.
await expect(panel.locator(".window-choice")).toBeVisible();
await expect(panel.locator(".arrive-bar")).toHaveCount(0);
await panel.getByRole("radio", { name: "A year" }).click();
await panel.getByRole("button", { name: "Start" }).click();
await expect(panel.locator(".window-choice")).toHaveCount(0);
await expect(panel.locator(".arrive-bar")).toBeVisible();
await expect(panel).toContainText("Bringing in the last year");
await expect(panel.locator(".arrive-count")).toContainText("of 4,812 messages");
// Nothing closes it: not the close control, not Escape, not the scrim.
await expect(panel.getByRole("button", { name: "Close" })).toBeDisabled();
await page.keyboard.press("Escape");
await expect(panel).toBeVisible();
// The pass ends, the panel goes, and what is behind it is the new account's Inbox.
await expect(panel).toHaveCount(0);
await expect(page.locator(".settings")).toHaveCount(0);
await expect(page.locator(".account-chip")).toContainText("[email protected]");
await expect(page.locator(".app")).toHaveAttribute("data-place", "inbox");
// And the first-run panel over it says what the pass over senders did.
await expect(page.getByRole("dialog", { name: "You are set up" })).toBeVisible();
});
test("a first pass that stops leaves the panel with the reason and two ways on", async ({ page }) => {
await openSettings(page, { "marginmail-dev-sync-fails": "1" });
await page.getByRole("button", { name: "Add account" }).click();
await page.getByLabel("Email address").fill("[email protected]");
await page.keyboard.press("Enter");
const panel = page.getByRole("dialog", { name: "Bringing in [email protected]" });
await expect(panel).toBeVisible();
await panel.getByRole("button", { name: "Start" }).click();
await expect(panel).toContainText("refused the first request");
await expect(panel.locator(".arrive-trouble")).toContainText("Gmail API has not been used");
await expect(panel.getByRole("button", { name: "Try again" })).toBeVisible();
// Going in anyway is an Inbox that says what it has, on the account that was added.
await panel.getByRole("button", { name: "Go in anyway" }).click();
await expect(panel).toHaveCount(0);
await expect(page.locator(".account-chip")).toContainText("[email protected]");
await expect(page.locator(".settings")).toHaveCount(0);
});
test("Add account tells an Outlook address why, in the sheet, with the way back", async ({
page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: "Add account" }).click();
await page.getByLabel("Email address").fill("[email protected]");
await page.keyboard.press("Enter");
// Outlook is the third mailbox the person this was built for has, and it does not work. A
// sentence saying so, when the address is typed, is better than a button that fails.
const sheet = page.getByRole("dialog", { name: "Outlook is not here yet" });
await expect(sheet).toBeVisible();
await expect(sheet).toContainText("app registration with Microsoft");
await expect(page.locator(".settings-waiting")).toHaveCount(0);
// The panel's own back control retraces the step, to the address, which is still there.
await sheet.getByRole("button", { name: "Back to the address" }).click();
await expect(page.getByRole("dialog", { name: "Add an account" })).toBeVisible();
await expect(page.getByLabel("Email address")).toHaveValue("[email protected]");
});
test("an IMAP account is its servers, and a Google account is still its permissions", async ({
page,
}) => {
await openSettings(page, { "marginmail-dev-imap": "1" });
await expect(page.locator(".acct")).toHaveCount(3);
const imap = page.locator(".acct").nth(2);
await expect(imap.locator(".acct-addr")).toHaveText("[email protected]");
// Nothing was ever granted, so there is no line offering to grant it. Every one of those buttons
// used to open a Google consent page for an account that has no Google behind it.
await expect(imap.locator(".scope")).toHaveCount(0);
await expect(imap.getByRole("button", { name: "Grant" })).toHaveCount(0);
// What it has instead: the two servers it is actually using, who logs in, and where the password
// is. These come from imap_servers, so they are what the next connection will use.
const facts = imap.locator(".acct-servers .set-row");
await expect(facts).toHaveCount(4);
await expect(facts.nth(0)).toContainText("imap.fastmail.example, port 993, TLS");
await expect(facts.nth(1)).toContainText("smtp.fastmail.example, port 465, TLS");
await expect(facts.nth(2)).toContainText("[email protected]");
await expect(facts.nth(3)).toContainText("Sealed on this device");
// Aliases are the provider's answer and there is no provider here to give one, so the line says
// that rather than reporting a count of nothing.
await expect(imap).toContainText("only ever sends as its own address");
await imap.scrollIntoViewIfNeeded();
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "settings-imap-account.png") });
// And the Google card is untouched: six lines, five granted, one Grant, and no servers.
const google = page.locator(".acct").first();
await expect(google.locator(".scope")).toHaveCount(6);
await expect(google.locator(".scope .state")).toHaveCount(5);
await expect(google.getByRole("button", { name: "Grant" })).toHaveCount(1);
await expect(google.locator(".acct-servers")).toHaveCount(0);
// There is a Google account here, so the footnote about the shared client is about something.
await expect(page.getByText("share one Google client")).toBeVisible();
});
test("the shared Google client footnote is gone when no account is Google", async ({ page }) => {
await openSettings(page, { "marginmail-dev-imap": "only" });
await expect(page.locator(".acct")).toHaveCount(1);
await expect(page.locator(".acct .acct-servers")).toBeVisible();
// Revoking a grant nobody made is not a consequence anybody here has, and a paragraph explaining
// it reads as though the app has quietly signed you in to something.
await expect(page.getByText("share one Google client")).toHaveCount(0);
await expect(page.locator(".scope")).toHaveCount(0);
});
test("Appearance changes the theme and the face, and both survive a reload", async ({ page }) => {
await openSettings(page);
await page.locator(".settings-tab", { hasText: "Appearance" }).click();
await page.getByRole("tab", { name: "Dark" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.getByRole("tab", { name: "Light" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
// The text face writes --font-heading, which is a token change and touches nothing else.
const picker = page.getByLabel("Text font");
await picker.selectOption({ label: "EB Garamond" });
await expect
.poll(() =>
page.evaluate(() => document.documentElement.style.getPropertyValue("--font-heading")),
)
.toContain("EB Garamond");
// And it is written where the boot script in index.html reads it before the first paint.
const stored = await page.evaluate(() => localStorage.getItem("marginmail-fonts"));
expect(stored).toContain("EB Garamond");
await page.reload();
await expect(page.locator(".list-col")).toBeVisible();
const afterReload = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-heading"),
);
expect(afterReload).toContain("EB Garamond");
});
test("the storage window asks before it shrinks, and does not before it widens", async ({
page,
}) => {
await openSettings(page);
await page.locator(".settings-tab", { hasText: "Mail" }).click();
const work = page.getByRole("tablist", { name: "Storage window for [email protected]" });
const personal = page.getByRole("tablist", {
name: "Storage window for [email protected]",
});
// What the account holds now, and how far back it reaches.
await expect(page.locator(".set-card").first()).toContainText("4,812 messages");
await expect(page.locator(".set-card").first()).toContainText("back to");
// Widening only ever adds, so it goes straight through.
await work.getByRole("tab", { name: "90 days" }).click();
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
await expect(work.getByRole("tab", { name: "90 days" })).toHaveAttribute(
"aria-selected",
"true",
);
// Rust queues the backfill off the same write, and while it runs the section shows the same
// thin bar the first sync draws.
await page.evaluate(() => {
window.dispatchEvent(
new CustomEvent("sync-progress", {
detail: {
accountId: "acct-1",
phase: "backfilling",
lastSyncMs: null,
error: null,
pendingWrites: 0,
message: "Bringing in the older mail",
hydrated: 300,
total: 1200,
oldestMs: null,
},
}),
);
});
const bar = page.locator('.set-filling [role="progressbar"]');
await expect(bar).toHaveAttribute("aria-valuenow", "25");
await expect(page.locator(".set-filling")).toContainText("Bringing in the older mail");
// Narrowing evicts, so it says what it would take before it takes it.
await personal.getByRole("tab", { name: "30 days" }).click();
const asking = page.getByRole("dialog", { name: "Storage window" });
await expect(asking).toBeVisible();
await expect(asking.locator(".confirm-title")).toHaveText(
"Keep only the last 30 days of [email protected] on this device?",
);
await expect(asking.locator(".confirm-body")).toContainText("older than 30 days");
await expect(asking.locator(".confirm-body")).toContainText("Nothing is removed from Gmail");
await expect(asking.locator(".confirm-body")).toContainText("kept whatever their age");
// Cancelling leaves the window where it was.
await asking.getByRole("button", { name: "Cancel" }).click();
await expect(personal.getByRole("tab", { name: "90 days" })).toHaveAttribute(
"aria-selected",
"true",
);
await personal.getByRole("tab", { name: "30 days" }).click();
await page.getByRole("button", { name: "Narrow the window" }).click();
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
await expect(personal.getByRole("tab", { name: "30 days" })).toHaveAttribute(
"aria-selected",
"true",
);
});
test("the interface font is the second slot, and it round trips like the first", async ({
page,
}) => {
await openSettings(page);
await page.locator(".settings-tab", { hasText: "Appearance" }).click();
await page.getByLabel("Interface font").selectOption({ label: "Fraunces" });
await expect
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue("--font-ui")))
.toContain("Fraunces");
// Away and back: what comes back is what the command answered with, not what the click guessed.
await page.locator(".settings-tab", { hasText: "Data" }).click();
await page.locator(".settings-tab", { hasText: "Appearance" }).click();
await expect(page.getByLabel("Interface font")).toHaveValue(/fraunces/i);
await expect(page.getByLabel("Text font")).toHaveValue(/literata/i);
});
test("the undo delay round trips through the command", async ({ page }) => {
await openSettings(page);
await page.locator(".settings-tab", { hasText: "Writing" }).click();
const delay = page.getByRole("tablist", { name: "Undo delay" });
await expect(delay.getByRole("tab", { name: "10s" })).toHaveAttribute("aria-selected", "true");
await delay.getByRole("tab", { name: "20s" }).click();
await page.locator(".settings-tab", { hasText: "Screener" }).click();
await page.locator(".settings-tab", { hasText: "Writing" }).click();
await expect(delay.getByRole("tab", { name: "20s" })).toHaveAttribute("aria-selected", "true");
});
test("Backup says the recovery phrase cannot be shown twice, and why", async ({ page }) => {
await openSettings(page);
await page.locator(".settings-tab", { hasText: "Backup" }).click();
await expect(page.locator(".settings-inner")).toContainText("Backing up to Google Drive");
await expect(page.locator(".settings-inner")).toContainText("cannot be shown again");
// The mechanism rather than a rule: the sentence has to say why, not apologise.
await expect(page.locator(".settings-inner")).toContainText("the phrase itself was dropped");
// And no button that could not work if it were pressed.
await expect(page.getByRole("button", { name: /phrase/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Restore" })).toBeVisible();
});
test("Data says what clearing the mirror does and does not throw away", async ({ page }) => {
await openSettings(page);
await page.locator(".settings-tab", { hasText: "Data" }).click();
await expect(page.locator(".settings-inner")).toContainText("Storage used");
await page.getByRole("button", { name: "Clear the mirror for [email protected]" }).click();
const asking = page.getByRole("dialog", { name: "Clear the mirror" });
await expect(asking.locator(".confirm-title")).toContainText("[email protected]");
await expect(asking.locator(".confirm-body")).toContainText("Nothing is removed from Gmail");
await expect(asking.locator(".confirm-body")).toContainText("Every decision you have made is kept");
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.keyboard.press("Escape");
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
});
test("the Mail section, for the eye", async ({ page }) => {
await openSettings(page);
await page.locator(".settings-tab", { hasText: "Mail" }).click();
await expect(page.locator(".set-card")).toHaveCount(2);
mkdirSync(shots, { recursive: true });
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "settings-mail.png") });
});
/**
* Makes the fixture answer as a device that has never had a recovery phrase, which is the only
* state the phrase can be shown in and the one the seeded fixture is past. The module request is
* answered with a shim over the real one, the same way `failCommands` does it.
*/
async function withoutAPhrase(page: Page): Promise<void> {
await page.route("**/src/dev/mockIpc.ts*", async (route) => {
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { mockCall as real } from "/src/dev/mockIpc.ts?real";`,
`export async function mockCall(command, args) {`,
` const out = await real(command, args);`,
` if (command === "settings_get" || command === "settings_set")`,
` return { ...out, backup: { ...out.backup, hasPhrase: false } };`,
` if (command === "backup_configure") return { ...out, hasPhrase: false };`,
` return out;`,
`}`,
].join("\n"),
});
});
}
test("turning backup on shows the phrase, once, with what it is for", async ({ page }) => {
await withoutAPhrase(page);
await openSettings(page);
await page.locator(".settings-tab", { hasText: "Backup" }).click();
// Not offered before there is a backup to have one for.
await expect(page.locator(".phrase")).toHaveCount(0);
await page.getByRole("tab", { name: "Google Drive" }).click();
await expect(page.locator(".phrase")).toHaveText(/(\w+ ){11}\w+/);
await expect(page.locator(".settings-inner")).toContainText("only time they will be on a screen");
mkdirSync(shots, { recursive: true });
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "settings-backup.png") });
});
test("the name and the signature are editable, and the signature is one field in two places", async ({
page,
}) => {
await openSettings(page);
const first = page.locator(".acct").first();
const name = first.getByLabel("Name for [email protected]");
await name.fill("Work");
await name.blur();
await expect(name).toHaveValue("Work");
// The account registry owns the name, so the avatar it draws everywhere follows it.
await expect(first.locator(".avatar")).toHaveText("W");
const signature = first.getByLabel("Signature for [email protected]");
await signature.fill("Priyanshu, 73ai");
await signature.blur();
// docs/settings.md puts the same field in Writing, because that is where somebody writing a
// signature looks for it.
await page.locator(".settings-tab", { hasText: "Writing" }).click();
await expect(page.getByLabel("Signature for [email protected]")).toHaveValue("Priyanshu, 73ai");
});
// The three exports and the import each hold the database lock for as long as it takes to walk
// the mirror. Under the pending flag the fixture takes most of a second over them, which is long
// enough to see that the button that was pressed says so and cannot be pressed again.
test("an export says it is writing and cannot be pressed twice", async ({ page }) => {
await openSettings(page, { "marginmail-dev-pending": "1" });
await page.locator(".settings-tab", { hasText: "Data" }).click();
const section = page.locator(".set-section");
await expect(section).toHaveAttribute("data-phase", "idle");
const mbox = page.getByRole("button", { name: "Export [email protected] as mbox" });
const other = page.getByRole("button", { name: "Export [email protected] as mbox" });
const state = page.locator(".set-stack", { hasText: "App state" }).getByRole("button");
const exporting = state.first();
const importing = state.nth(1);
await mbox.click();
await expect(section).toHaveAttribute("data-phase", "exporting");
await expect(mbox).toHaveText("Writing");
await expect(mbox).toBeDisabled();
// The same lock, so the other exports wait for it rather than queueing a second file.
await expect(other).toBeDisabled();
await expect(exporting).toBeDisabled();
await expect(section).toHaveAttribute("data-phase", "idle");
await expect(mbox).toHaveText("[email protected]");
await expect(mbox).toBeEnabled();
await expect(page.locator(".toast-text")).toContainText("Written to");
await exporting.click();
await expect(exporting).toHaveText("Writing");
await expect(exporting).toBeDisabled();
await expect(mbox).toBeDisabled();
await expect(exporting).toHaveText("Export");
const path = page.getByLabel("App state file to import");
await path.fill("/Users/you/Downloads/margin-mail-state.json");
await importing.click();
await expect(section).toHaveAttribute("data-phase", "importing");
await expect(importing).toHaveText("Importing");
await expect(importing).toBeDisabled();
await expect(section).toHaveAttribute("data-phase", "idle");
await expect(importing).toHaveText("Import");
await expect(path).toHaveValue("");
await expect(page.locator(".toast-text")).toContainText("was imported");
});
test("the backup store moves as it is pressed, and each wait names its own button", async ({
page,
}) => {
await openSettings(page, { "marginmail-dev-pending": "1" });
await page.locator(".settings-tab", { hasText: "Backup" }).click();
const section = page.locator(".set-section");
const store = page.getByRole("tablist", { name: "Backup store" });
const drive = store.getByRole("tab", { name: "Google Drive" });
const off = store.getByRole("tab", { name: "Off" });
await expect(drive).toHaveAttribute("aria-selected", "true");
// The segment shows the new pick before the command answers, and nothing else can be picked
// until it does. It used to sit on the old value for the whole of the key derivation.
await off.click();
await expect(off).toHaveAttribute("aria-selected", "true");
await expect(store).toHaveAttribute("data-disabled", "");
await expect(drive).toBeDisabled();
await expect(section).toHaveAttribute("data-phase", "configuring");
await expect(section).toHaveAttribute("data-phase", "idle");
await expect(store).not.toHaveAttribute("data-disabled", "");
await expect(off).toHaveAttribute("aria-selected", "true");
// Two buttons, two waits, and only the one that was pressed goes grey.
const backUp = page.getByRole("button", { name: /^Back(ing)? up/ });
const restore = page.getByRole("button", { name: /^Restor(e|ing)$/ });
await page
.getByLabel("Recovery phrase")
.fill("candle harbour ribbon meadow anchor lantern pepper thicket marble orchard signal walnut");
await expect(restore).toBeEnabled();
await backUp.click();
await expect(section).toHaveAttribute("data-phase", "backing-up");
await expect(backUp).toHaveText("Backing up");
await expect(backUp).toBeDisabled();
await expect(restore).toBeEnabled();
await expect(section).toHaveAttribute("data-phase", "idle");
await expect(backUp).toHaveText("Back up now");
await expect(page.locator(".toast-text")).toContainText("Backed up");
await restore.click();
await expect(section).toHaveAttribute("data-phase", "restoring");
await expect(restore).toHaveText("Restoring");
await expect(restore).toBeDisabled();
await expect(backUp).toBeEnabled();
await expect(section).toHaveAttribute("data-phase", "idle");
await expect(page.getByLabel("Recovery phrase")).toHaveValue("");
await expect(page.locator(".toast-text")).toContainText("Restored from your backup");
});
test("clearing the mirror holds the confirmation until the command answers", async ({ page }) => {
await openSettings(page, { "marginmail-dev-pending": "1" });
await page.locator(".settings-tab", { hasText: "Data" }).click();
await page.getByRole("button", { name: "Clear the mirror for [email protected]" }).click();
const asking = page.getByRole("dialog", { name: "Clear the mirror" });
await asking.getByRole("button", { name: "Clear it" }).click();
// The button says what it is doing, and nothing on the sheet closes it while it does: not
// Cancel, not the close control, not Escape, not the scrim.
const clearing = asking.getByRole("button", { name: "Clearing" });
await expect(clearing).toBeVisible();
await expect(clearing).toBeDisabled();
await expect(asking).toHaveAttribute("data-busy", "");
await expect(asking.getByRole("button", { name: "Cancel" })).toBeDisabled();
await expect(asking.getByRole("button", { name: "Close" })).toBeDisabled();
await page.keyboard.press("Escape");
// Down the left edge, where the scrim is: the title bar sits over the overlay along the top.
await page.locator(".overlay").click({ position: { x: 20, y: 450 } });
await expect(asking).toBeVisible();
await expect(asking).toHaveCount(0);
await expect(page.locator(".toast-text")).toContainText("is being fetched again");
});
test("a name that cannot be written goes back to what it was", async ({ page }) => {
await failCommands(page, ["account_set_name"]);
await openSettings(page);
const first = page.locator(".acct").first();
const name = first.getByLabel("Name for [email protected]");
const was = await name.inputValue();
await name.fill("Work");
await name.blur();
await expect(page.locator(".toast-text")).toContainText("Could not change that name");
await expect(name).toHaveValue(was);
await expect(first.locator(".avatar")).not.toHaveText("W");
});
test("a box in the title bar leaves Settings rather than opening behind it", async ({ page }) => {
// Clicking Inbox used to change the place underneath and leave Settings covering it, with no way
// back except a key. The place commands always closed Settings; the title bar did not use them.
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Meta+,");
await expect(page.locator(".settings")).toBeVisible();
// Nothing is selected while Settings is up: saying Inbox is selected under a screen that is not
// the Inbox is the same lie the trap was built on.
await expect(page.locator('.titlebar [role="tab"][aria-selected="true"]')).toHaveCount(0);
await page.locator('.titlebar [role="tab"]').filter({ hasText: "Feed" }).click();
await expect(page.locator(".settings")).toHaveCount(0);
await expect(page.locator(".app")).toHaveAttribute("data-place", "feed");
});
+231
View File
@@ -0,0 +1,231 @@
// The window: the header, the list column and the two piles. What a person looks at before they
// have read anything.
//
// Everything here is measured off the page rather than asked of the app. The 420px column and the
// 46px row are the two numbers the whole layout is built from, and they are the two the mockups
// pin down.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
import {
box,
groups,
listReady,
MIDDAY,
openApp,
paletteRows,
rows,
settle,
toast,
token,
} from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
test("the list column is 420px and a row is 46px", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
expect(await token(page, "--list-w")).toBe("420px");
expect(await token(page, "--row-h")).toBe("46px");
const column = await box(page.locator(".list-col"));
expect(column.width).toBe(420);
const first = (await rows(page))[0];
expect(first.height).toBe(46);
});
test("a row carries sender and time over subject and snippet", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const maya = (await rows(page)).find((row) => row.sender === "Maya Raghunathan");
expect(maya).toBeTruthy();
expect(maya!.subject).toBe("Dinner on Thursday?");
expect(maya!.snippet).toContain("Church Street");
expect(maya!.time).toBe("11:42");
// More than one message in the thread, so the count sits between the sender and the time.
expect(maya!.count).toBe("3");
// Two lines, in that order: the sender's line above the subject's.
const lines = await page.evaluate(() => {
const row = [...document.querySelectorAll<HTMLElement>(".row")].find((el) =>
el.textContent?.includes("Maya Raghunathan"),
)!;
const rect = (selector: string) => row.querySelector(selector)!.getBoundingClientRect();
return {
senderTop: rect(".row-sender").top,
timeTop: rect(".row-time").top,
subjectTop: rect(".row-subject").top,
snippetTop: rect(".row-snippet").top,
};
});
expect(Math.abs(lines.senderTop - lines.timeTop)).toBeLessThan(4);
expect(Math.abs(lines.subjectTop - lines.snippetTop)).toBeLessThan(4);
expect(lines.subjectTop).toBeGreaterThan(lines.senderTop);
});
test("the Inbox puts New for you above Previously seen, and only new mail has the dot", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const heads = await groups(page);
expect(heads).toContain("New for you");
expect(heads).toContain("Previously seen");
expect(heads.indexOf("New for you")).toBeLessThan(heads.indexOf("Previously seen"));
const list = await rows(page);
const arun = list.find((row) => row.sender === "Arun Kulkarni")!;
const lena = list.find((row) => row.sender === "Lena Brandt")!;
expect(arun.group).toBe("New for you");
expect(arun.unseen).toBe(true);
expect(lena.group).toBe("Previously seen");
expect(lena.unseen).toBe(false);
// A note on a thread is one line under its row, on the note surface.
expect(lena.note).toBe("Ask about the oak finish before confirming");
});
test("the two piles sit at the foot of the list with their keys", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const piles = page.locator(".pile");
await expect(piles).toHaveCount(2);
await expect(piles.first()).toContainText("Reply later");
await expect(piles.first()).toContainText("4");
await expect(piles.nth(1)).toContainText("Set aside");
await expect(piles.nth(1)).toContainText("5");
const list = await box(page.locator(".list-col"));
const pile = await box(piles.first());
expect(pile.bottom).toBeLessThanOrEqual(list.bottom + 1);
});
test("the account chip opens the switcher, with All accounts behind it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".account-chip").click();
const options = page.locator(".account-option");
await expect(options).toHaveCount(3);
await expect(options.nth(0)).toContainText("[email protected]");
await expect(options.nth(2)).toContainText("All accounts");
await options.nth(1).click();
await expect(page.locator(".account-chip")).toContainText("[email protected]");
});
test("Everything closes with the one line about the window", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("0");
await expect(page.locator(".list-title")).toHaveText("Everything");
await page.locator(".list").evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect(page.locator(".list-foot")).toHaveText(
"Showing the last month. Older mail is on Gmail.",
);
});
test("an empty place says one quiet thing", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
// The second account has nothing in either pile, which is the only empty list the fixture holds.
// An install with no accounts at all is the connect screen now, not an empty Inbox.
await page.keyboard.press("Control+2");
await page.keyboard.press("4");
await expect(page.locator(".list-title")).toHaveText("Reply later");
await expect(page.locator(".list .empty-state")).toHaveText("Nothing here");
await expect(page.locator(".row")).toHaveCount(0);
});
test("the Other group in the palette is the way into the three you go looking in", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Meta+k");
const other = (await paletteRows(page)).filter((row) => row.group === "Other");
expect(other.map((row) => row.label)).toEqual(["Screened out", "Spam", "Trash"]);
// None of the three prints a key, because none of them has one: where a place sits in this list
// is the honest statement of how often you should be in it.
expect(other.flatMap((row) => row.keys)).toEqual([]);
// And each one is a place with a list of its own behind it. Trash and Spam say what Gmail will
// do with them, which is the line that stands in for the Empty button this app cannot offer.
await page.locator(".palette-input").fill("trash");
await page.keyboard.press("Enter");
await expect(page.locator(".list-title")).toHaveText("Trash");
await expect(page.locator(".list-foot")).toHaveText("Gmail empties this after 30 days.");
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("screened");
await page.keyboard.press("Enter");
await expect(page.locator(".list-title")).toHaveText("Screened out");
await expect(page.locator(".list-foot")).toHaveCount(0);
});
test("Sync now says so in the header while the pass is out, and nothing when it is back", async ({
page,
}) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await expect(page.locator(".sync-busy")).toHaveCount(0);
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("sync now");
await page.keyboard.press("Enter");
// An ordinary pass has no sentence of its own, so the ask itself is the line.
await expect(page.locator(".sync-busy")).toHaveText("Checking for mail");
// And nothing at the end of it: a pass that found nothing new is not news.
await expect(page.locator(".sync-busy")).toHaveCount(0, { timeout: 5_000 });
expect(await toast(page)).toBeNull();
});
test("the app renders in dark", async ({ page }) => {
await openApp(page, { theme: "dark", now: MIDDAY() });
await listReady(page);
expect(await page.evaluate(() => document.documentElement.getAttribute("data-theme"))).toBe(
"dark",
);
expect(await token(page, "--paper")).toBe("#1d1a16");
await expect(page.locator(".row").first()).toBeVisible();
});
// One picture per test rather than a loop, because `openApp` seeds the store once per context and
// a second call on the same page would take the dark picture in the light palette.
for (const theme of ["light", "dark"] as const) {
test(`the Inbox in ${theme}`, async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openApp(page, { theme, now: MIDDAY() });
await listReady(page);
await page.evaluate(() => document.fonts.ready);
// The lease thread is the one the mockup has open, and it is the second row of New for you.
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await settle(page);
await page.screenshot({ path: join(shots, `inbox-${theme}.png`) });
});
}
test("the palette", async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.keyboard.press("Meta+k");
await expect(page.locator(".palette")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "palette.png") });
});
+207
View File
@@ -0,0 +1,207 @@
// Snooze: the picker, where the thread goes, and how it comes back.
//
// Every moment here is computed in the browser from the four times in settings, so the clock is
// pinned and the zone is the config's. A test that let the machine's own hour decide what
// "Tomorrow" meant would pass in one timezone and not in another.
import { expect, test, type Page } from "@playwright/test";
import { failCommands, listReady, MIDDAY, openApp, rows, toast } from "./app";
/** Focuses the row a spec means to act on, by walking to it rather than by clicking it open. */
async function focusRow(page: Page, sender: string): Promise<void> {
for (let i = 0; i < 20; i++) {
const at = (await rows(page)).find((row) => row.selected);
if (at?.sender === sender) return;
await page.keyboard.press("j");
}
throw new Error(`never reached ${sender}`);
}
/** A day in the pinned zone, as `<input type="date">` wants it. */
function day(offset: number): string {
const today = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Kolkata",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
const at = new Date(`${today}T00:00:00Z`);
at.setUTCDate(at.getUTCDate() + offset);
return at.toISOString().slice(0, 10);
}
/** Every row of the open picker: what it says, and the key that takes it. */
function choices(page: Page): Promise<{ label: string; keycap: string; when: string }[]> {
return page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>(".snooze-option")].map((el) => ({
label: (el.querySelector(".snooze-label")?.textContent ?? "").trim(),
keycap: (el.querySelector(".key")?.textContent ?? "").trim(),
when: (el.querySelector(".snooze-when")?.textContent ?? "").trim(),
})),
);
}
test("b opens the six choices with their keys and the moment each of them means", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("b");
await expect(page.locator(".snooze-picker")).toBeVisible();
// The times are the settings file's, not this screen's: three hours, 08:00, Saturday 09:00,
// Monday 08:00. Pinned at 11:30, later today is 14:30.
expect(await choices(page)).toEqual([
{ label: "Later today", keycap: "1", when: "Today 14:30" },
{ label: "Tomorrow", keycap: "2", when: "Tomorrow 08:00" },
{ label: "This weekend", keycap: "3", when: expect.stringContaining("09:00") },
{ label: "Next week", keycap: "4", when: expect.stringContaining("08:00") },
{ label: "Pick a date and time", keycap: "5", when: "" },
{ label: "If no reply by", keycap: "6", when: "" },
]);
// The picker is in front, so the number keys mean these six rather than the places.
await page.keyboard.press("Escape");
await expect(page.locator(".snooze-picker")).toHaveCount(0);
await expect(page.locator(".list-title")).toHaveText("Inbox");
});
test("b comes up before the four times are here, and says it is reading them", async ({ page }) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("b");
// Nothing reads settings before this key does, so the popover comes up into the wait rather
// than after it. A `b` that showed nothing until then was a `b` that got pressed twice.
const picker = page.locator(".snooze-picker");
await expect(picker).toBeVisible();
await expect(picker).toHaveAttribute("data-state", "loading");
await expect(picker.locator(".snooze-wait")).toHaveText("Reading your snooze times");
await expect(page.locator(".snooze-option")).toHaveCount(0);
await expect(page.locator(".snooze-option")).toHaveCount(6, { timeout: 5_000 });
await expect(picker).not.toHaveAttribute("data-state", /./);
expect((await choices(page))[0]).toEqual({ label: "Later today", keycap: "1", when: "Today 14:30" });
});
test("b says why when the settings could not be read", async ({ page }) => {
await failCommands(page, ["settings_get"]);
await openApp(page, { now: MIDDAY() });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("b");
const picker = page.locator(".snooze-picker");
await expect(picker).toHaveAttribute("data-state", "error");
await expect(picker.locator(".snooze-wait")).toContainText("Could not read your snooze times");
await expect(picker.locator(".snooze-wait")).toContainText("the mailbox is offline");
await expect(page.locator(".snooze-option")).toHaveCount(0);
// Escape still puts it away, and nothing was chosen, so nothing moved.
await page.keyboard.press("Escape");
await expect(picker).toHaveCount(0);
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(1);
});
test("the toast's Undo puts the thread back and says so", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("b");
await expect(page.locator(".snooze-picker")).toBeVisible();
await page.keyboard.press("1");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Snoozed");
await page.locator(".toast-action").click();
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(1);
await expect.poll(async () => (await toast(page))?.text).toBe("Undone: Snoozed");
expect((await toast(page))?.action).toBeNull();
});
test("choosing one takes the thread out of the list, and 7 shows it with its return time", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("b");
await expect(page.locator(".snooze-picker")).toBeVisible();
await page.keyboard.press("2");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Snoozed");
expect((await toast(page))?.action).toContain("Undo");
await page.keyboard.press("7");
await expect(page.locator(".list-title")).toHaveText("Snoozed");
// In this place the time slot is about the return rather than about when the mail arrived, and
// the moment is the one the picker offered.
await expect
.poll(async () => (await rows(page)).find((row) => row.sender === "Maya Raghunathan")?.time)
.toBe("Tomorrow 08:00");
for (const row of await rows(page)) expect(row.time).toMatch(/\d\d:\d\d$/);
});
test("a thread that is late says so, and comes back into Back above New for you", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("b");
await expect(page.locator(".snooze-picker")).toBeVisible();
await page.keyboard.press("5");
await page.locator(".snooze-field input").fill(`${day(-1)}T09:00`);
await page.locator(".snooze-form-foot .button", { hasText: "Snooze" }).click();
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(0);
// Due and not yet returned: the row says it is late rather than printing a time that has gone.
await page.keyboard.press("7");
await expect
.poll(async () => (await rows(page)).find((row) => row.sender === "Maya Raghunathan")?.time)
.toBe("Due yesterday");
// Nothing runs in the background, so the return happens when somebody looks. The window coming
// back to the foreground is one of the three moments that count.
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(0);
await page.keyboard.press("1");
await expect
.poll(async () => (await rows(page)).find((row) => row.sender === "Maya Raghunathan")?.group)
.toBe("Back");
const groups = (await rows(page)).map((row) => row.group);
expect(groups.indexOf("Back")).toBeLessThan(groups.indexOf("New for you"));
});
test("the picker acts on a selection, and Escape leaves it alone", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("j");
await page.keyboard.press("x");
await page.keyboard.press("Shift+J");
await page.keyboard.press("b");
await expect(page.locator(".snooze-picker")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator(".snooze-picker")).toHaveCount(0);
// Nothing was chosen, so nothing moved and nothing was said.
expect(await toast(page)).toBeNull();
await page.keyboard.press("b");
await expect(page.locator(".snooze-picker")).toBeVisible();
await page.keyboard.press("1");
await expect.poll(async () => (await toast(page))?.text).toBe("2 threads snoozed");
await page.keyboard.press("7");
await expect.poll(async () => (await rows(page)).length).toBe(3);
});
+477
View File
@@ -0,0 +1,477 @@
// The reading pane. A thread is a subject, the people in it, and the messages, with everything
// older than the last one folded down to a line.
//
// The bodies are in sandboxed iframes, so `bodyText` reads across frames: that is the point of the
// helper and it is the only way to prove a message actually rendered rather than merely arrived.
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
import {
bodyText,
box,
failCommands,
listReady,
MIDDAY,
openApp,
openRow,
paneMessages,
settle,
toast,
} from "./app";
const shots = join(fileURLToPath(new URL("..", import.meta.url)), "screenshots");
/**
* The pane has a message in it, and not merely a thread.
*
* A body is a document of its own, created by the browser a beat after the thread it belongs to
* arrives, and the height the parent writes on the frame is the one signal that the parent has
* found it: sizing it and catching its clicks are the same moment. Waiting for the subject instead
* is asking the browser about a document it has not made yet, which is a test that fails on a busy
* machine and passes on a quiet one.
*/
async function bodyReady(page: import("@playwright/test").Page): Promise<void> {
await expect
.poll(async () =>
page.locator(".msg-frame").first().evaluate((el) => (el as HTMLIFrameElement).style.height),
)
.not.toBe("");
}
const openLease = async (page: import("@playwright/test").Page) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await bodyReady(page);
await settle(page);
};
test("the pane shows the subject, the people and the message count", async ({ page }) => {
await openLease(page);
await expect(page.locator(".thread-subject")).toHaveText("Lease renewal for the studio");
await expect(page.locator(".thread-meta")).toContainText("Arun Kulkarni and you");
await expect(page.locator(".thread-meta")).toContainText("2 messages");
// One face per participant, stacked.
await expect(page.locator(".thread-meta .avatar")).toHaveCount(2);
});
test("the latest message is open and the older ones are a line each", async ({ page }) => {
await openLease(page);
const messages = await paneMessages(page);
expect(messages).toHaveLength(2);
expect(messages[0].collapsed).toBe(true);
expect(messages[0].name).toBe("You");
expect(messages[0].preview).toContain("thanks for sending the renewal over");
expect(messages[1].collapsed).toBe(false);
expect(messages[1].name).toBe("Arun Kulkarni");
expect(messages[1].address).toBe("[email protected]");
expect(messages[1].time).toBe("Today 10:15");
});
test("a message body renders inside its own frame", async ({ page }) => {
await openLease(page);
await expect(page.locator(".msg-frame")).toHaveCount(1);
await expect(page.locator(".msg-frame")).toHaveAttribute("sandbox", "allow-same-origin");
const bodies = await bodyText(page);
expect(bodies.join(" ")).toContain("Attached the revised draft");
// The quoted half of the message is behind its pill and is not in the body.
expect(bodies.join(" ")).not.toContain("On Mon, Priyanshu Jain wrote");
// The face has to be injected too: a child document does not inherit the parent's @font-face
// rules, and a body that fell back would render in Times without anything else looking wrong.
const face = await page.locator(".msg-frame").evaluate((el) => {
const doc = (el as HTMLIFrameElement).contentDocument!;
return getComputedStyle(doc.body).fontFamily;
});
expect(face).toContain("Literata");
// The frame is sized to its content, so the pane scrolls as one page.
const height = await page.locator(".msg-frame").evaluate((el) => el.getBoundingClientRect().height);
expect(height).toBeGreaterThan(100);
});
test("a plain text body in dark reads in the dark palette", async ({ page }) => {
await openApp(page, { theme: "dark", now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Dinner on Thursday?" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
// The frame is a document of its own, so the app's tokens are written onto its root rather than
// inherited. Light ink on the page and light ink in the frame is what proves that happened.
await expect
.poll(async () =>
page.locator(".msg-frame").first().evaluate((el) => {
const doc = (el as HTMLIFrameElement).contentDocument!;
return getComputedStyle(doc.body).color;
}),
)
.toBe("rgb(236, 230, 218)");
});
/** What a body actually renders as, which is the only thing worth asserting about a surface. */
async function surfaceOf(page: import("@playwright/test").Page): Promise<string> {
return page.locator(".msg-frame").first().evaluate((el) => {
const doc = (el as HTMLIFrameElement).contentDocument!;
const style = getComputedStyle(doc.body);
const root = getComputedStyle(doc.documentElement);
return `${style.color} on ${style.backgroundColor}, ${root.colorScheme}`;
});
}
const DARK = "rgb(236, 230, 218) on rgb(29, 26, 22), dark";
// `light only` and not `only light`: the browser reorders it on the way back out of the computed
// style, and the value written in public/message.css is the one the CSS grammar asks for.
const PAPER = "rgb(35, 32, 27) on rgb(252, 251, 247), light only";
test("a body that paints no surface of its own reads on the theme in dark", async ({ page }) => {
await openApp(page, { theme: "dark", now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
// This message arrived as HTML, and that is not the question. It carries bold runs, links and a
// signature and it paints nothing, so there is no page of the sender's to preserve and it reads
// like the rest of the app. The rule this replaces put every HTML body on a white page, which
// made a colleague's mail a slab of white in a dark window.
await expect(page.locator(".msg-frame").first()).toHaveAttribute("data-surface", "theme");
await expect.poll(() => surfaceOf(page)).toBe(DARK);
});
test("a body that paints its own page keeps it in dark", async ({ page }) => {
await openApp(page, { theme: "dark", now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Your reservation in Lisbon is confirmed" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
// The other half. This one wraps itself in a tinted surface, which is a design rather than an
// accident, so it keeps the light palette in both themes and the pane's chrome goes dark around
// it. `only light` is what stops the browser putting its own dark canvas under a page we have
// just painted white.
await expect(page.locator(".msg-frame").first()).toHaveAttribute("data-surface", "paper");
await expect.poll(() => surfaceOf(page)).toBe(PAPER);
});
test("the message head offers a way out when the rule guesses wrong", async ({ page }) => {
await openApp(page, { theme: "dark", now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await expect.poll(() => surfaceOf(page)).toBe(DARK);
// Every heuristic misses, so one press overrules it for this message. It is only offered in dark
// and only on a body that arrived as HTML, because that is the only place the two surfaces look
// different and the only place a sender's page could have been taken away.
const flip = page.locator(".msg-head .button", { hasText: "" }).last();
await expect(flip).toHaveAttribute("title", "Read this message on a light page");
await flip.click();
await expect.poll(() => surfaceOf(page)).toBe(PAPER);
await expect(flip).toHaveAttribute("title", "Read this message in dark");
await flip.click();
await expect.poll(() => surfaceOf(page)).toBe(DARK);
});
test("the surface control is not offered in light, where it would do nothing", async ({ page }) => {
await openLease(page);
await expect(page.locator(".msg-head .button")).toHaveCount(0);
});
test("quoted text opens from its pill", async ({ page }) => {
await openLease(page);
await page.locator(".pill", { hasText: "Show quoted text" }).click();
await expect(page.locator(".msg-frame")).toHaveCount(2);
await expect
.poll(async () => (await bodyText(page)).join(" "))
.toContain("On Mon, Priyanshu Jain wrote");
});
test("the tracker banner names the vendor", async ({ page }) => {
await openLease(page);
const banner = page.locator(".banner");
await expect(banner).toContainText("Blocked");
await expect(banner).toContainText("1 tracker");
await expect(banner).toContainText("HubSpot");
await expect(banner).toContainText("Remote images are off for this sender");
await expect(banner.locator(".banner-action")).toHaveText("Show images");
});
test("a link in a body is caught by the parent rather than followed", async ({ page }) => {
await openLease(page);
// No fixture body carries a link, so the anchor is put into the frame here. What is being tested
// is the listener the parent attached to the frame's document, not the sanitiser's output.
const caught = await page.locator(".msg-frame").evaluate((el) => {
const doc = (el as HTMLIFrameElement).contentDocument!;
const anchor = doc.createElement("a");
anchor.href = "https://example.org/lease";
anchor.textContent = "the lease";
doc.body.appendChild(anchor);
const event = new doc.defaultView!.MouseEvent("click", { bubbles: true, cancelable: true });
anchor.dispatchEvent(event);
return event.defaultPrevented;
});
expect(caught).toBe(true);
});
test("Show images asks for the message again and the banner stops offering it", async ({ page }) => {
await openLease(page);
await page.locator(".banner-action", { hasText: "Show images" }).click();
await expect(page.locator(".banner-action")).toHaveCount(0);
// The tracker is still worth saying: it was stripped whatever happens to the images.
await expect(page.locator(".banner")).toContainText("HubSpot");
});
test("Show images says it is loading, and refuses a second press, until the pictures land", async ({
page,
}) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect(page.locator(".banner-action")).toHaveText("Show images", { timeout: 10_000 });
await page.locator(".banner-action").click();
const action = page.locator(".banner-action");
await expect(action).toHaveText("Loading images…");
await expect(action).toBeDisabled();
await expect(action).toHaveAttribute("aria-busy", "true");
await expect(page.locator(".banner-action")).toHaveCount(0, { timeout: 10_000 });
await expect(page.locator(".banner")).toContainText("HubSpot");
});
test("the attachment is a chip with its kind and size", async ({ page }) => {
await openLease(page);
const chip = page.locator(".attachment");
await expect(chip).toHaveCount(1);
await expect(chip).toContainText("Studio-lease-2026-v2.pdf");
await expect(chip.locator(".ext")).toHaveText("PDF");
await expect(chip.locator(".size")).toHaveText("412 KB");
});
test("the attachment chip hands the file to the OS, and says it is working until then", async ({
page,
}) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
const chip = page.locator(".attachment");
await expect(chip).toHaveAttribute("data-phase", "idle", { timeout: 10_000 });
await chip.click();
await expect(chip).toHaveAttribute("data-phase", "opening");
await expect(chip).toBeDisabled();
// Opened, and nothing to say about it: the file is in whatever owns it now.
await expect(chip).toHaveAttribute("data-phase", "idle", { timeout: 10_000 });
await expect(chip).toBeEnabled();
expect(await toast(page)).toBeNull();
});
test("a file that will not open says so on the chip and in a toast", async ({ page }) => {
await failCommands(page, ["attachment_open"]);
await openLease(page);
const chip = page.locator(".attachment");
await chip.click();
await expect(chip).toHaveAttribute("data-phase", "error");
await expect(chip).toBeEnabled();
await expect.poll(async () => (await toast(page))?.text ?? "").toContain("the mailbox is offline");
});
test("a note on the thread is a block on the note surface", async ({ page }) => {
await openLease(page);
const note = page.locator(".note");
await expect(note).toContainText("Note to self");
await expect(note).toContainText("Check the four percent cap");
});
test("More drops the rest of the thread's verbs, from the button and from .", async ({ page }) => {
await openLease(page);
await page.locator(".pane-bar").getByRole("button", { name: "More" }).click();
const menu = page.getByRole("dialog", { name: "More" });
await expect(menu).toBeVisible();
// The verbs the bar does not carry, in the order the keyboard table lists them, each with its
// key. Move to a label and Unsubscribe are not here because nothing owns them in the Inbox.
await expect(menu.getByRole("menuitem")).toHaveText([
/^Reply all/,
/^Forward/,
/^Mark (un)?seen/,
/^Unstar/,
/^Add a note/,
/^Contact card/,
/^Label/,
/^Trash/,
/^Mark as spam/,
]);
await expect(menu.getByRole("menuitem", { name: /^Trash/ })).toContainText("#");
await page.keyboard.press("Escape");
await expect(menu).toHaveCount(0);
await page.keyboard.press(".");
await expect(menu).toBeVisible();
// A verb's own key works from inside the menu and takes the menu down with it.
await page.keyboard.press("#");
await expect(menu).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Trashed");
await expect(page.locator(".row", { hasText: "Lease renewal for the studio" })).toHaveCount(0);
});
test("a row in the More menu runs its verb", async ({ page }) => {
await openLease(page);
await page.keyboard.press(".");
const menu = page.getByRole("dialog", { name: "More" });
await menu.getByRole("menuitem", { name: /^Unstar/ }).click();
await expect(menu).toHaveCount(0);
// The menu reads the row, so opening it again is how the test sees the star has gone.
await page.keyboard.press(".");
await expect(menu.getByRole("menuitem", { name: /^Star/ })).toBeVisible();
});
test("Paper Trail groups by week and reads in the interface face", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("3");
await expect(page.locator(".list-title")).toHaveText("Paper Trail");
await openRow(page, 1);
await expect(page.locator(".thread-subject")).toHaveText("Bill payment pending");
// The pane bar carries Move to Inbox in this place and nowhere else.
await expect(page.locator(".pane-bar")).toContainText("Move to Inbox");
await bodyReady(page);
expect(
await page.locator(".msg-frame").last().evaluate((el) => {
const doc = (el as HTMLIFrameElement).contentDocument!;
return doc.body.hasAttribute("data-plain");
}),
).toBe(true);
});
test("a thread addressed to forty people has a head of two lines and a message on screen", async ({
page,
}) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-crowd": "1" } });
await listReady(page);
await page.locator(".row", { hasText: "Q3 planning offsite" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await settle(page);
// Four faces and one chip for the rest, rather than thirty-nine circles across the pane.
await expect(page.locator(".thread-meta .avatar")).toHaveCount(5);
await expect(page.locator(".thread-meta .avatar[data-more]")).toHaveText("+35");
await expect(page.locator(".thread-meta .thread-people")).toHaveText(
"Melanie Brennan, Yanni Kyriacos, Aditi Rao and 36 others",
);
// The names that did not fit are one hover away rather than gone.
await expect(page.locator(".thread-meta .thread-people")).toHaveAttribute(
"title",
/Kofi Mensah/,
);
// The head is one line of people and at most two of subject, whatever the sender wrote.
const meta = await box(page.locator(".thread-meta"));
expect(meta.height).toBeLessThan(32);
const subject = await box(page.locator(".thread-subject"));
expect(subject.height).toBeLessThan(60);
// Which is the point of all three: the message is above the fold rather than under it.
const body = await box(page.locator(".msg-frame"));
expect(body.top).toBeLessThan(280);
expect((await bodyText(page)).join(" ")).toContain("The room is booked from ten");
});
test("a thread whose bodies have not arrived shows a skeleton, never the last one you read", async ({
page,
}) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.locator(".row", { hasText: "Lease renewal for the studio" }).click();
await expect
.poll(async () => (await bodyText(page)).join(" "))
.toContain("Attached the revised draft");
await page.locator(".row", { hasText: "Dinner on Thursday?" }).click();
// The pane is the thread that was clicked on the frame it was clicked, drawn from the row the
// list was already holding, with the body still on its way.
await expect(page.locator(".pane[data-opening] .thread-subject")).toHaveText(
"Dinner on Thursday?",
);
await expect(page.locator(".msg-pending").first()).toBeVisible();
expect((await bodyText(page)).join(" ")).not.toContain("Attached the revised draft");
// And the view lands, and then the bodies land behind it, without the page having flashed.
await expect(page.locator(".pane[data-opening]")).toHaveCount(0);
await expect(page.locator(".thread-subject")).toHaveText("Dinner on Thursday?");
await expect(page.locator(".msg-pending")).toHaveCount(0, { timeout: 10_000 });
expect((await bodyText(page)).join(" ")).toContain("Priya said the place on Church Street");
});
test("a body the provider refuses becomes a line that offers to try again", async ({ page }) => {
await openApp(page, {
now: MIDDAY(),
storage: { "marginmail-dev-pending": "1", "marginmail-dev-hydrate-fails": "1" },
});
await listReady(page);
await page.locator(".row", { hasText: "Dinner on Thursday?" }).click();
await expect(page.locator(".thread-subject")).toHaveText("Dinner on Thursday?");
// The count came back as zero and nothing landed, which is not an error to the command and used
// to leave the skeleton breathing for as long as the thread was open. No toast either: nothing
// failed that a person could act on from a toast, and the slot itself carries the way back.
const failed = page.locator('.msg-pending[data-state="error"]');
await expect(failed).toBeVisible({ timeout: 10_000 });
await expect(failed).toContainText("Could not fetch this message");
expect(await toast(page)).toBeNull();
// Asked for again with the provider answering this time: the line is a skeleton for a beat, and
// then the body.
await page.evaluate(() => localStorage.removeItem("marginmail-dev-hydrate-fails"));
await failed.locator(".pill", { hasText: "Try again" }).click();
await expect(page.locator('.msg-pending[data-state="error"]')).toHaveCount(0);
await expect(page.locator(".msg-pending")).toHaveCount(0, { timeout: 10_000 });
await expect
.poll(async () => (await bodyText(page)).join(" "))
.toContain("Priya said the place on Church Street");
});
test("a fetch the mailbox refuses outright says so, and the slot still offers to try again", async ({
page,
}) => {
await failCommands(page, ["thread_hydrate"]);
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await page.locator(".row", { hasText: "Dinner on Thursday?" }).click();
await expect
.poll(async () => (await toast(page))?.text ?? "", { timeout: 10_000 })
.toContain("Could not fetch the rest of this thread");
await expect(page.locator('.msg-pending[data-state="error"]')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.msg-pending[data-state="error"] .pill')).toHaveText("Try again");
});
test("a thread, for the eye", async ({ page }) => {
mkdirSync(shots, { recursive: true });
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.locator(".row", { hasText: "Piano on Wednesdays" }).click();
await expect(page.locator(".thread-subject")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await settle(page);
await page.screenshot({ path: join(shots, "thread.png") });
});
+187
View File
@@ -0,0 +1,187 @@
// The tour, driven the two ways somebody meets it: after the panel the first run ends on, and from
// the palette every time after that.
//
// The keycap assertions never type a letter. What a slide prints is compared with what the app
// prints for the same verb somewhere else, on the Screener's own buttons and in the palette, and
// both of those are generated from the binding table. So a remap moves all of them together and a
// letter written into the tour's copy fails here.
import { expect, test, type Page } from "@playwright/test";
import { MIDDAY, listReady, openApp, openDialog, paletteRows, settle } from "./app";
const TITLE = "Getting started";
const dialog = (page: Page) => page.getByRole("dialog", { name: TITLE });
const heading = (page: Page) => page.locator(".tour-title");
/** Which slide is up, read off the DOM rather than asked of the app. */
function slide(page: Page): Promise<number> {
return page.evaluate(() =>
Number(document.querySelector(".tour")?.getAttribute("data-slide") ?? -1),
);
}
/** Which dot is filled, which has to be the same answer. */
function dot(page: Page): Promise<number> {
return page.evaluate(() =>
[...document.querySelectorAll(".tour-dot")].findIndex((el) => el.hasAttribute("data-on")),
);
}
/** The keycaps in the slide's copy, in the order they are read. */
function caps(page: Page, selector: string): Promise<string[]> {
return page.evaluate(
(sel) => [...document.querySelectorAll(sel)].map((el) => (el.textContent ?? "").trim()),
selector,
);
}
/** The palette route: the only way in once the first run is over. */
async function openTour(page: Page): Promise<void> {
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("tour");
await page.keyboard.press("Enter");
await expect(dialog(page)).toBeVisible();
await settle(page);
}
test("the palette opens the tour, on the first slide", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Meta+k");
await page.locator(".palette-input").fill("tour");
// One row matches, so Return runs it rather than whatever happened to be first.
expect((await paletteRows(page)).map((row) => row.label)).toEqual(["Take the tour"]);
await page.keyboard.press("Enter");
expect(await openDialog(page)).toBe(TITLE);
expect(await slide(page)).toBe(0);
await expect(heading(page)).toHaveText("Nobody new reaches you until you say so");
await expect(page.locator(".tour-dot")).toHaveCount(9);
expect(await dot(page)).toBe(0);
// The primary control holds the focus, which is the whole of why Return and Space advance.
await expect(page.locator(".tour-foot .button[data-variant='primary']")).toBeFocused();
await expect(page.getByRole("button", { name: "Back" })).toBeDisabled();
});
test("the arrows, the buttons and the dots all move it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openTour(page);
await page.keyboard.press("ArrowRight");
expect(await slide(page)).toBe(1);
await expect(heading(page)).toHaveText("Three boxes, not one inbox");
await page.getByRole("button", { name: "Next" }).click();
expect(await slide(page)).toBe(2);
await expect(heading(page)).toHaveText("Reply later and Set aside, instead of flags");
// The click left the focus on Next, so Return is the same press without the mouse.
await page.keyboard.press("Enter");
expect(await slide(page)).toBe(3);
await page.getByRole("button", { name: "Back" }).click();
expect(await slide(page)).toBe(2);
await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowLeft");
expect(await slide(page)).toBe(0);
// Nowhere before the first slide, and the panel stays up rather than closing on the way past it.
await page.keyboard.press("ArrowLeft");
expect(await slide(page)).toBe(0);
await expect(dialog(page)).toBeVisible();
// The dots track it, and they jump.
await page.locator(".tour-dot").nth(6).click();
expect(await slide(page)).toBe(6);
expect(await dot(page)).toBe(6);
await expect(heading(page)).toHaveText("The things kept beside the mail");
});
test("Skip closes it, and so does Escape", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openTour(page);
await page.getByRole("button", { name: "Skip" }).click();
expect(await openDialog(page)).toBeNull();
// And the way out of everything else in the app is the way out of this.
await openTour(page);
await page.keyboard.press("ArrowRight");
await page.keyboard.press("Escape");
expect(await openDialog(page)).toBeNull();
// Reopening starts at the beginning rather than where it was left.
await openTour(page);
expect(await slide(page)).toBe(0);
});
test("the last slide ends it", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await openTour(page);
await page.locator(".tour-dot").last().click();
expect(await slide(page)).toBe(8);
await expect(heading(page)).toHaveText("Undo covers everything");
await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0);
await page.getByRole("button", { name: "Done" }).click();
expect(await openDialog(page)).toBeNull();
});
test("it follows the first-run panel", async ({ page }) => {
await openApp(page, { firstRun: true });
await page.getByLabel("Email address").fill("[email protected]");
await page.keyboard.press("Enter");
await page.getByRole("button", { name: "Start" }).click();
await expect(page.locator(".row").first()).toBeVisible();
const setUp = page.getByRole("dialog", { name: "You are set up" });
await expect(setUp).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(setUp).toHaveCount(0);
// The one moment the app has somebody's attention, so the tour is what the panel hands over to.
await expect(dialog(page)).toBeVisible();
expect(await slide(page)).toBe(0);
});
test("every keycap is the one the binding table gives that verb", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
// The Screener's three buttons, which print their keys the way every button in the app does.
await page.keyboard.press("6");
await expect(page.locator(".screen-card").first()).toBeVisible();
const answers = (
await page.locator(".screen-card").first().locator(".screen-actions .key").allTextContents()
).map((text) => text.trim());
expect(answers).toHaveLength(3);
await page.keyboard.press("1");
await listReady(page);
await page.keyboard.press("Meta+k");
const rows = await paletteRows(page);
await page.keyboard.press("Escape");
const undo = rows.find((row) => row.label === "Undo")?.keys ?? [];
const sheet = rows.find((row) => row.label === "Keyboard shortcuts")?.keys ?? [];
expect(undo).toHaveLength(1);
expect(sheet).toHaveLength(1);
await openTour(page);
// Yes, Elsewhere and No, in the card's order, in the figure and again in the sentence.
expect(await caps(page, ".tour-choices .key")).toEqual(answers);
expect(await caps(page, ".tour-copy .key")).toEqual(answers);
await page.locator(".tour-dot").nth(4).click();
await expect(heading(page)).toHaveText("One key per verb");
expect(await caps(page, ".tour-copy .key")).toEqual(sheet);
await page.locator(".tour-dot").last().click();
expect(await caps(page, ".tour-copy .key")).toEqual(undo);
});
+295
View File
@@ -0,0 +1,295 @@
// The verbs, driven the way a person drives them: focus a row, press the key, and look at what the
// list and the toast now say.
//
// Nothing here waits for a call to finish before it looks. Every one of these actions is optimistic
// and the row is expected to have moved before the fixture answers, which is the whole point of
// doing it that way, so an assertion that passed only after a round trip would be missing the bug.
import { expect, test, type Page } from "@playwright/test";
import { failCommands, listReady, MIDDAY, openApp, paletteRows, rows, toast } from "./app";
/** Focuses the row a spec means to act on, by walking to it rather than by clicking it open. */
async function focusRow(page: Page, sender: string): Promise<void> {
for (let i = 0; i < 20; i++) {
const at = (await rows(page)).find((row) => row.selected);
if (at?.sender === sender) return;
await page.keyboard.press("j");
}
throw new Error(`never reached ${sender}`);
}
/**
* A fixture whose label list fails the first time it is read, which is the read the list makes on
* boot, and takes a while the next time, which is the read the picker makes when it opens. The
* same trick as `failCommands`: the module is answered with a shim in front of the real one.
*/
async function slowLabels(page: Page): Promise<void> {
await page.route("**/src/dev/mockIpc.ts*", async (route) => {
if (new URL(route.request().url()).searchParams.has("real")) return route.fallback();
await route.fulfill({
contentType: "text/javascript",
body: [
`import { mockCall as real } from "/src/dev/mockIpc.ts?real";`,
`let reads = 0;`,
`export function mockCall(command, args) {`,
` if (command !== "labels_list") return real(command, args);`,
` if (reads++ === 0) return Promise.reject(new Error("the mailbox is offline"));`,
` return new Promise((resolve) => setTimeout(resolve, 700)).then(() => real(command, args));`,
`}`,
].join("\n"),
});
});
}
test("e archives the focused thread, says so, and z brings it back", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const before = (await rows(page)).length;
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("e");
// The row is gone before the fixture has answered: the call goes behind the change, not in
// front of it.
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Archived");
expect((await toast(page))?.action).toContain("Undo");
await page.keyboard.press("z");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(1);
await expect.poll(async () => (await rows(page)).length).toBe(before);
});
test("the toast's Undo goes down on the press and says what it undid", async ({ page }) => {
await openApp(page, { now: MIDDAY(), storage: { "marginmail-dev-pending": "1" } });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("e");
await expect.poll(async () => (await toast(page))?.text).toBe("Archived");
await page.locator(".toast-action").click();
// Down before the undo has landed, so there is no second press to be refused.
await expect(page.locator(".toast")).toHaveCount(0);
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(1, {
timeout: 5_000,
});
// And then said, the way `z` says it, with nothing left on it to press.
await expect.poll(async () => (await toast(page))?.text).toBe("Undone: Archived");
expect((await toast(page))?.action).toBeNull();
});
test("# trashes and ! marks spam, both with a way back", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("#");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Trashed");
await page.keyboard.press("z");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(1);
await focusRow(page, "Arun Kulkarni");
await page.keyboard.press("!");
await expect(page.locator(".row", { hasText: "Arun Kulkarni" })).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Marked as spam");
await page.keyboard.press("z");
await expect(page.locator(".row", { hasText: "Arun Kulkarni" })).toHaveCount(1);
});
/** The three places under Other have no key, so the palette is how a test reaches them too. */
async function goToPlace(page: Page, title: string): Promise<void> {
await page.keyboard.press("Meta+k");
await page.locator(".palette-row", { hasText: title }).first().click();
await expect(page.locator(".list-title")).toHaveText(title);
}
test("# in Trash puts a thread back where it was, and ! in Spam takes the mark off", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const parcel = page.locator(".row", { hasText: "Your parcel could not be delivered" });
await goToPlace(page, "Trash");
await expect(parcel).toHaveCount(1);
await focusRow(page, "Parcel Notice");
await page.keyboard.press("#");
await expect(parcel).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Restored");
// Back where it was, which is the Paper Trail its sender is routed to and not the Inbox.
await page.keyboard.press("1");
await expect(page.locator(".list-title")).toHaveText("Inbox");
await expect(parcel).toHaveCount(0);
await page.keyboard.press("3");
await expect(page.locator(".list-title")).toHaveText("Paper Trail");
await expect(parcel).toHaveCount(1);
const phish = page.locator(".row", { hasText: "Account verification required" });
await goToPlace(page, "Spam");
await expect(phish).toHaveCount(1);
await focusRow(page, "Aćcount Security");
await page.keyboard.press("!");
await expect(phish).toHaveCount(0);
await expect.poll(async () => (await toast(page))?.text).toBe("Marked as not spam");
});
test("u toggles the dot and Shift+S toggles the star", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
const seen = () => rows(page).then((all) => all.find((row) => row.sender === "Lena Brandt")?.unseen);
await focusRow(page, "Lena Brandt");
expect(await seen()).toBe(false);
await page.keyboard.press("u");
await expect.poll(seen).toBe(true);
await page.keyboard.press("u");
await expect.poll(seen).toBe(false);
// Marking one seen or unseen shows itself in the row, so it does not also announce itself.
expect(await toast(page)).toBeNull();
const star = page.locator(".row", { hasText: "Lena Brandt" }).locator(".row-mark");
await expect(star).toHaveCount(0);
await page.keyboard.press("Shift+S");
await expect(star).toHaveCount(1);
await page.keyboard.press("Shift+S");
await expect(star).toHaveCount(0);
});
test("a call that does not go through puts the row back and says so", async ({ page }) => {
await failCommands(page, ["flags_set"]);
await openApp(page, { now: MIDDAY() });
await listReady(page);
const before = (await rows(page)).length;
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("e");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(1);
await expect.poll(async () => (await toast(page))?.text).toContain("did not go through");
expect((await rows(page)).length).toBe(before);
// The row goes back where it was rather than to the end of the list.
expect((await rows(page))[1].sender).toBe("Maya Raghunathan");
});
test("Mark all as seen is a link on the heading and a row in the palette", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await expect.poll(async () => (await rows(page)).some((row) => row.unseen)).toBe(true);
await page.locator(".group-head-action", { hasText: "Mark all as seen" }).click();
await expect.poll(async () => (await rows(page)).some((row) => row.unseen)).toBe(false);
await expect.poll(async () => (await toast(page))?.text).toBe("Marked everything as seen");
await page.keyboard.press("z");
await expect.poll(async () => (await rows(page)).some((row) => row.unseen)).toBe(true);
await page.keyboard.press("Meta+k");
const palette = await paletteRows(page);
expect(palette.find((row) => row.label === "Mark all as seen")?.group).toBe("Actions");
});
test("the palette lists the provider's labels, and Shift+L puts one on a selection", async ({
page,
}) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
await page.keyboard.press("Meta+k");
const palette = await paletteRows(page);
const labels = palette.filter((row) => row.group === "Labels").map((row) => row.label);
expect(labels).toEqual(["The flat", "School", "Travel"]);
// The provider's system labels are not among them. Every one is a place this app already has
// under a name of its own, and a palette that answered "spam" with a Labels row and an Other row
// is one that makes you choose between two spellings of the same place.
expect(labels).not.toContain("SPAM");
expect(labels).not.toContain("IMPORTANT");
// They are the provider's places, and a place has no key of its own.
expect(palette.find((row) => row.label === "Travel")?.keys).toEqual([]);
await page.keyboard.press("Escape");
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("x");
await page.keyboard.press("Shift+L");
await expect(page.locator('[role="dialog"]')).toHaveAttribute("aria-label", "Label");
await page.locator(".label-option", { hasText: "Travel" }).click();
await expect.poll(async () => (await toast(page))?.text).toBe("Labelled Travel");
// A label never shows on a row: the only way to see it is to go to the place it is.
await expect(page.locator(".row-note", { hasText: "Travel" })).toHaveCount(0);
await page.keyboard.press("Meta+k");
await page.locator(".palette-row", { hasText: "Travel" }).click();
await expect(page.locator(".list-title")).toHaveText("Travel");
await expect(page.locator(".row", { hasText: "Maya Raghunathan" })).toHaveCount(1);
});
test("the label picker waits for the labels rather than calling an empty list none", async ({
page,
}) => {
await slowLabels(page);
await openApp(page, { now: MIDDAY() });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("x");
await page.keyboard.press("Shift+L");
await expect(page.locator('[role="dialog"]')).toHaveAttribute("aria-label", "Label");
// The boot read failed and the picker's own read is out, so the list is empty and that is not
// the same thing as there being no labels.
await expect(page.locator('.label-none[data-state="loading"]')).toHaveText("Reading your labels");
await expect(page.locator(".label-none", { hasText: "No labels" })).toHaveCount(0);
// Three, which is what this account has of its own. The provider's system labels are not
// offered: putting `SPAM` on a thread is not a label you would ever have meant to pick.
await expect(page.locator(".label-option")).toHaveCount(3, { timeout: 5_000 });
await expect(page.locator(".label-none")).toHaveCount(0);
});
test("the label picker says when the labels could not be read, and offers to try again", async ({
page,
}) => {
await failCommands(page, ["labels_list"]);
await openApp(page, { now: MIDDAY() });
await listReady(page);
await focusRow(page, "Maya Raghunathan");
await page.keyboard.press("x");
await page.keyboard.press("Shift+L");
const failed = page.locator('.label-none[data-state="error"]');
await expect(failed).toContainText("Could not read your labels");
await expect(failed.locator(".button")).toHaveText("Try again");
await expect(page.locator(".label-none", { hasText: "No labels" })).toHaveCount(0);
});
test("v moves to a label in a label list, and means nothing anywhere else", async ({ page }) => {
await openApp(page, { now: MIDDAY() });
await listReady(page);
// In the Inbox `v` is the routing verb, which belongs to the next milestone: nothing owns it, so
// it does nothing and shows nothing.
await page.keyboard.press("j");
await page.keyboard.press("v");
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
await page.keyboard.press("Meta+k");
await page.locator(".palette-row", { hasText: "The flat" }).click();
await expect(page.locator(".list-title")).toHaveText("The flat");
await expect(page.locator(".row", { hasText: "Arun Kulkarni" })).toHaveCount(1);
await page.keyboard.press("j");
await page.keyboard.press("v");
await expect(page.locator('[role="dialog"]')).toHaveAttribute("aria-label", "Move to a label");
await page.locator(".label-option", { hasText: "School" }).click();
await expect.poll(async () => (await toast(page))?.text).toBe("Moved to School");
});
+13 -3
View File
@@ -1,14 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"noEmit": true
"noEmit": true,
"types": [
"node"
]
},
"include": [".", "../playwright.config.ts"]
"include": [
".",
"../playwright.config.ts"
]
}