Files
margin-mail/tests/help.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

164 lines
6.2 KiB
TypeScript

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