Files
margin-mail/tests/contacts.spec.ts
T
pj eb59cb5f8d 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.
2026-09-06 12:15:30 +05:30

269 lines
12 KiB
TypeScript

// 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") });
});