Files
govoplan-core/webui/conformance/tests/mail-toolbar.spec.ts
T

425 lines
22 KiB
TypeScript

import { expect, test, type Page, type Route } from "@playwright/test";
function deferred() {
let release!: () => void;
const promise = new Promise<void>(resolve => { release = resolve; });
return { promise, release };
}
function profile(id: string, protocol = "imap") {
return {
id, name: `${id} mailbox`, is_active: true, scope_type: "tenant",
...(protocol === "imap" ? { imap: { enabled: true, host: "imap.example.test", port: 993, security: "ssl", folder_mappings: { inbox: "INBOX" } } }
: { servers: [{ id: `${id}-jmap`, protocol: "jmap", is_active: true, is_default: true, config: { session_url: "https://jmap.example.test/session" } }] })
};
}
async function mockMailbox(page: Page) {
const state = {
profiles: [profile("Alpha"), profile("Beta")],
reads: [] as URL[], writes: [] as string[], errors: [] as string[],
failProfiles: false, failBootstrap: false, failDetail: false,
intercept: null as null | ((url: URL, route: Route) => Promise<boolean>),
releasedReads: 0
};
page.on("pageerror", error => state.errors.push(error.message));
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request();
const url = new URL(request.url());
if (request.method() !== "GET") {
state.writes.push(url.pathname);
return route.fulfill({ status: 405, json: { detail: "Fixture forbids mailbox writes" } });
}
state.reads.push(url);
if (state.intercept && await state.intercept(url, route)) return;
let body: unknown = {};
if (url.pathname === "/api/v1/mail/profiles") {
if (state.failProfiles) return route.fulfill({ status: 503, json: { detail: "Mailbox profile refresh unavailable" } });
body = { profiles: state.profiles };
} else if (url.pathname.endsWith("/mailbox/bootstrap")) {
if (state.failBootstrap) return route.fulfill({ status: 503, json: { detail: "Mailbox refresh unavailable" } });
body = bootstrap(url);
} else if (url.pathname.endsWith("/mailbox/folders")) body = folderCatalogue();
else if (url.pathname.endsWith("/mailbox/messages")) body = messageIndex(url);
else if (/\/mailbox\/messages\/[^/]+$/.test(url.pathname)) {
if (state.failDetail) return route.fulfill({ status: 503, json: { detail: "Message preview refresh unavailable" } });
body = messageDetail(url);
} else if (url.pathname.endsWith("/address-write-targets")) body = { available: false, targets: [] };
return route.fulfill({ json: body });
});
return state;
}
function folderCatalogue() {
return { ok: true, folders: [{ name: "INBOX", flags: [] }, { name: "Archive/2026", flags: [] }] };
}
function messageIndex(url: URL) {
const id = url.pathname.split("/")[5];
const folder = url.searchParams.get("folder") || "INBOX";
const offset = Number(url.searchParams.get("offset") ?? 0);
const limit = Number(url.searchParams.get("limit") ?? 10);
return {
profile_id: id, folder, total_count: 24, offset, limit,
next_cursor: "next-fixture-cursor", from_cache: false,
messages: Array.from({ length: Math.max(0, Math.min(limit, 24 - offset)) }, (_, index) => ({
uid: String(offset + index + 1), folder, subject: `${id} message ${offset + index + 1}`,
from_header: "Fixture sender <fixture@example.test>", to_header: "Reader <reader@example.test>",
flags: [], size_bytes: 1024, date: "2026-09-01T12:00:00Z"
}))
};
}
function bootstrap(url: URL) {
const messages = messageIndex(url);
return { profile_id: messages.profile_id, folder: messages.folder, folders: folderCatalogue(), messages };
}
function messageDetail(url: URL) {
const parts = url.pathname.split("/");
const uid = parts[parts.length - 1];
const summary = messageIndex(new URL(url.href.replace(/offset=[^&]*/, "offset=0"))).messages[0];
return { message: { ...summary, uid, subject: `${url.pathname.split("/")[5]} message ${uid}`, body_text: `Fixture body ${url.pathname.split("/")[5]} ${uid}`, headers: {}, attachments: [] } };
}
const workspaceBar = (page: Page) => page.locator('[data-workspace-action-scope="workspace"]');
const reload = (page: Page) => workspaceBar(page).locator('[data-page-action-slot="reload"] button');
const mailboxRows = (page: Page) => page.locator(".mailbox-message-row");
const mainMailboxReads = (reads: URL[]) => reads.filter(url => /\/mail\/profiles(?:$|\/[^/]+\/mailbox\/)/.test(url.pathname));
test("Mail has one persistent right-aligned Reload, including empty profiles, and recovers when a profile becomes available", async ({ page }) => {
const state = await mockMailbox(page);
state.profiles = [];
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(reload(page)).toBeEnabled();
await expect(workspaceBar(page)).toHaveCount(1);
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Mailbox tools", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Refresh (available profiles|folders only|messages only)/ })).toHaveCount(0);
const [barBox, reloadBox] = await Promise.all([workspaceBar(page).boundingBox(), reload(page).boundingBox()]);
expect(reloadBox!.x).toBeGreaterThan(barBox!.x + barBox!.width / 2);
expect(barBox!.x + barBox!.width - reloadBox!.x - reloadBox!.width).toBeLessThanOrEqual(20);
expect(barBox!.y).toBeLessThan(20);
state.profiles = [profile("Alpha")];
await reload(page).click();
await expect(mailboxRows(page)).toHaveCount(10);
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Alpha");
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail Reload coherently refreshes catalogue, current IMAP page and selected preview without resetting expansion", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90);
await page.getByRole("button", { name: "Next page", exact: true }).click();
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible();
const group = page.locator(".explorer-tree-node-wrap").filter({ has: page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ }) });
const toggle = group.locator(":scope > .explorer-tree-toggle");
await toggle.click();
const before = state.reads.length;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
const reads = mainMailboxReads(state.reads.slice(before));
expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages/11"]);
expect(reads[1].searchParams.get("offset")).toBe("10");
expect(reads[1].searchParams.get("refresh")).toBe("true");
expect(reads[1].searchParams.get("folder")).toBe("INBOX");
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail refresh failures preserve loaded index and preview, disclose failure, and leave recovery available", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
for (const failure of ["failProfiles", "failBootstrap", "failDetail"] as const) {
state[failure] = true;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed");
await expect(mailboxRows(page)).toHaveCount(10);
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
state[failure] = false;
await reload(page).click();
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "current");
await expect(reload(page)).toBeEnabled();
}
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail grouping Reload refreshes only profiles and folders, preserving synthetic selection and collapsed state", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
const group = page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ });
await expect(group).toBeEnabled();
await group.click();
const before = state.reads.length;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(group).toHaveAttribute("aria-current", "true");
await expect(page.locator(".explorer-tree-node-wrap").filter({ has: group }).locator(":scope > .explorer-tree-toggle")).toHaveAttribute("aria-expanded", "false");
await expect(mailboxRows(page)).toHaveCount(0);
expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/folders"]);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail Escape while Reload is pending does not reopen the dismissed preview", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
const held = deferred();
let started = false;
state.intercept = async (url, route) => {
if (url.pathname.endsWith("/mailbox/bootstrap") && url.searchParams.get("refresh")) {
started = true;
await held.promise;
await route.fulfill({ json: bootstrap(url) });
return true;
}
return false;
};
const before = state.reads.length;
await reload(page).click();
await expect.poll(() => started).toBe(true);
await page.keyboard.press("Escape");
held.release();
await expect(reload(page)).toBeEnabled();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toHaveCount(0);
await expect(page.locator(".mailbox-message-row.is-selected")).toHaveCount(0);
expect(state.reads.slice(before).filter(url => /\/mailbox\/messages\/[^/]+$/.test(url.pathname))).toHaveLength(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail failed pagination keeps retained rows labelled with their committed page and size", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
state.intercept = async (url, route) => {
if (url.pathname.endsWith("/mailbox/messages")) {
await route.fulfill({ status: 503, json: { detail: "Mailbox page unavailable" } });
return true;
}
return false;
};
const before = state.reads.length;
await page.getByRole("button", { name: "Next page", exact: true }).click();
await expect(reload(page)).toBeEnabled();
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed");
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(1);
await page.getByRole("combobox", { name: "Rows per page" }).selectOption("25");
await expect(reload(page)).toBeEnabled();
await expect(page.getByRole("combobox", { name: "Rows per page" })).toHaveValue("10");
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
await expect(mailboxRows(page)).toHaveCount(10);
expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(2);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail Reload reauthorizes profile availability and never keeps a removed account selected", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
state.profiles = [profile("Beta")];
await reload(page).click();
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Beta");
await expect(page.getByRole("option", { name: "Alpha mailbox" })).toHaveCount(0);
state.profiles = [];
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled();
await expect(mailboxRows(page)).toHaveCount(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail JMAP Reload restarts its cursor chain but retains the server-side search", async ({ page }) => {
const state = await mockMailbox(page);
state.profiles = [profile("Alpha", "jmap")];
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await page.getByPlaceholder("Search messages").fill("message");
await expect.poll(() => state.reads.some(url => url.searchParams.get("q") === "message")).toBe(true);
await expect(reload(page)).toBeEnabled();
await page.getByRole("button", { name: "Next page", exact: true }).click();
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
const before = state.reads.length;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(page.getByPlaceholder("Search messages")).toHaveValue("message");
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
const reads = mainMailboxReads(state.reads.slice(before));
expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages"]);
expect(reads[2].searchParams.get("protocol")).toBe("jmap");
expect(reads[2].searchParams.get("q")).toBe("message");
expect(reads[2].searchParams.has("cursor")).toBe(false);
expect(reads[2].searchParams.get("offset")).toBe("0");
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail profile switches discard late bootstrap and preview responses instead of showing a previous account", async ({ page }) => {
const state = await mockMailbox(page);
const heldBootstrap = deferred();
let started = false;
state.intercept = async (url, route) => {
if (url.pathname === "/api/v1/mail/profiles/Alpha/mailbox/bootstrap") {
started = true;
await heldBootstrap.promise;
await route.fulfill({ json: bootstrap(url) });
state.releasedReads += 1;
return true;
}
return false;
};
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect.poll(() => started).toBe(true);
await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Beta");
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
heldBootstrap.release();
await expect.poll(() => state.releasedReads).toBeGreaterThan(0);
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
const heldPreview = deferred();
let previewStarted = false;
state.intercept = async (url, route) => {
if (url.pathname === "/api/v1/mail/profiles/Beta/mailbox/messages/1") {
previewStarted = true;
await heldPreview.promise;
await route.fulfill({ json: messageDetail(url) });
state.releasedReads += 1;
return true;
}
return false;
};
await mailboxRows(page).first().click();
await expect.poll(() => previewStarted).toBe(true);
await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Alpha");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
const released = state.releasedReads;
heldPreview.release();
await expect.poll(() => state.releasedReads).toBeGreaterThan(released);
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
await expect(page.getByText("Fixture body Beta 1", { exact: true })).toHaveCount(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail tools keeps advanced reads scoped, bounce permission explained, and Escape leaves selection intact", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
const tools = page.getByRole("button", { name: "Mailbox tools", exact: true });
await tools.click();
const dialog = page.getByRole("dialog", { name: "Mailbox tools" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("button", { name: "Bounce status", exact: true })).toBeDisabled();
for (let index = 0; index < 10; index += 1) {
await page.keyboard.press("Tab");
expect(await dialog.evaluate(element => element.contains(document.activeElement))).toBe(true);
}
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(tools).toBeFocused();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
for (const [action, expectedPath] of [
["Refresh available profiles", "/api/v1/mail/profiles"],
["Refresh folders only", "/api/v1/mail/profiles/Alpha/mailbox/folders"],
["Refresh messages only", "/api/v1/mail/profiles/Alpha/mailbox/messages"]
]) {
await tools.click();
const before = state.reads.length;
await dialog.getByRole("button", { name: action, exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(reload(page)).toBeEnabled();
expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual([expectedPath]);
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
}
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail authority switch invalidates a previous tenant's pending profile catalogue", async ({ page }) => {
const state = await mockMailbox(page);
const held = deferred();
let started = false;
state.intercept = async (url, route) => {
if (url.pathname === "/api/v1/mail/profiles" && !started) {
started = true;
await held.promise;
await route.fulfill({ json: { profiles: [profile("PreviousTenant")] } });
state.releasedReads += 1;
return true;
}
return false;
};
state.profiles = [profile("CurrentTenant")];
await page.goto("/?mail-toolbar&language=en&theme=light&switch-tenant");
await expect.poll(() => started).toBe(true);
await page.getByRole("button", { name: "Switch fixture tenant" }).click();
await expect(mailboxRows(page).first()).toContainText("CurrentTenant message 1");
held.release();
await expect.poll(() => state.releasedReads).toBeGreaterThan(0);
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("CurrentTenant");
await expect(page.getByRole("option", { name: "PreviousTenant mailbox" })).toHaveCount(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail narrow German workspace keeps its sole Reload visible and the grouped tools dialog within the viewport", async ({ page }) => {
const state = await mockMailbox(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?mail-toolbar&language=de&theme=light&bounce-allowed");
await expect(reload(page)).toBeEnabled();
await expect(reload(page)).toHaveText(/Neu laden/);
await expect(page.locator(".mailbox-message-head")).not.toBeVisible();
expect(await mailboxRows(page).first().evaluate(element => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1);
expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90);
await expect(workspaceBar(page).locator('[data-page-action-slot="reload"]')).toHaveCount(1);
const bounds = await reload(page).boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
await mailboxRows(page).first().click();
const preview = page.getByText("Fixture body Alpha 1", { exact: true });
await expect(preview).toBeVisible();
await preview.scrollIntoViewIfNeeded();
await expect(preview).toBeInViewport();
await expect(reload(page)).toBeVisible();
expect((await reload(page).boundingBox())!.y).toBe(bounds!.y);
await page.getByRole("button", { name: "Postfachwerkzeuge", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Postfachwerkzeuge" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("button", { name: "Zustellrückläufer", exact: true })).toBeEnabled();
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});