mirror of
https://github.com/priyanshujain/margin-mail.git
synced 2026-10-02 19:17:05 +00:00
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:
1 parent
088ec9c6e4
commit
eb59cb5f8d
423 files changed
+93217
-251
No files matched your search
@@ -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") });
|
||||
});
|
||||
Reference in new issue
Block a user