feat: consolidate shared UI and harden browser authority for release
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: { language?: "en" | "de"; readOnly?: boolean; rejectFirst?: boolean; holdWrite?: Promise<void>; switchAccount?: boolean } = {}) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
const writes: { path: string; payload: Record<string, any> }[] = [];
|
||||
let detailReads = 0;
|
||||
let holdNextRead: Promise<void> | undefined;
|
||||
let heldReads = 0;
|
||||
const statuses = ["failed_temporary", "failed_permanent", "outcome_unknown", "sending", "sending", "queued", "smtp_accepted", "skipped"];
|
||||
const jobs = statuses.map((status, index) => ({
|
||||
id: `job-${index + 1}`, campaign_version_id: "report-version", entry_id: `entry-${index + 1}`, entry_index: index + 1,
|
||||
recipient_email: `primary-${index + 1}@example.test`, subject: `Report message ${index + 1}`,
|
||||
resolved_recipients: { to: [{ email: `primary-${index + 1}@example.test` }, { name: "Additional person", email: `additional-${index + 1}@example.test` }],
|
||||
cc: [{ email: `copy-${index + 1}@example.test` }], bcc: [{ email: `blind-${index + 1}@example.test` }] },
|
||||
build_status: "built", validation_status: status === "skipped" ? "excluded" : "ready", queue_status: status === "sending" ? "claimed" : "draft",
|
||||
send_status: status, imap_status: status === "smtp_accepted" ? "appended" : index === 0 ? "pending" : index === 1 ? "appending" : index === 2 ? "outcome_unknown" : "not_requested", attempt_count: ["not_queued", "queued", "skipped"].includes(status) ? 0 : 1,
|
||||
postbox_attempt_count: 0, print_attempt_count: 0,
|
||||
attachments: [], recovery: { smtp: { eligible: index === 3, revision: `claim-revision-${index + 1}`, reason: index === 3 ? "recoverable" : "live_claim" }, imap: { eligible: false, revision: "imap-revision", reason: "not_active" } }
|
||||
}));
|
||||
const version = { id: "report-version", campaign_id: "report-campaign", version_number: 1, edit_revision: 1, strong_etag: '"report-version:1"',
|
||||
workflow_state: "partially_completed", locked_at: "2026-09-07T10:00:00Z", raw_json: {}, editor_state: {} };
|
||||
const summary = () => ({ cards: { jobs_total: jobs.length, sent: jobs.filter(job => job.send_status === "smtp_accepted").length, failed: jobs.filter(job => job.send_status.startsWith("failed")).length },
|
||||
delivery: { background_workers_enabled: false, celery_enabled: false }, status_counts: {
|
||||
send: Object.fromEntries([...new Set(jobs.map(job => job.send_status))].map(status => [status, jobs.filter(job => job.send_status === status).length])),
|
||||
imap: Object.fromEntries([...new Set(jobs.map(job => job.imap_status))].map(status => [status, jobs.filter(job => job.imap_status === status).length]))
|
||||
} });
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "report-campaign", name: "Report fixture", current_version_id: "report-version", status: "partially_completed" },
|
||||
versions: [version], current_version: version, summary: summary(), deleted: [], full: true, has_more: false, watermark: "report-watermark"
|
||||
} });
|
||||
if (url.pathname.endsWith("/delivery-progress")) return route.fulfill({ json: {
|
||||
campaign_id: "report-campaign", version_id: "report-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: jobs.length,
|
||||
smtp: { total: 7, processed: 4, accepted: summary().cards.sent, active: 2, pending: 1, failed: summary().cards.failed, outcome_unknown: 1, excluded: 1, paused: 0, cancelled: 0 },
|
||||
imap: { total: 1, processed: 1, appended: 1, active: 0, pending: 0, failed: 0, outcome_unknown: 0, excluded: 7 }, status_counts: {}
|
||||
} });
|
||||
if (url.pathname.endsWith("/jobs")) {
|
||||
const search = (url.searchParams.get("q") ?? url.searchParams.get("filter_recipient") ?? "").toLowerCase();
|
||||
const matchesStatus = (column: "send" | "imap", status: string) => {
|
||||
const filter = url.searchParams.get(`filter_${column}`);
|
||||
return !filter?.startsWith("list:") || JSON.parse(filter.slice(5)).includes(status);
|
||||
};
|
||||
const selected = structuredClone(jobs.filter(job => (!search || JSON.stringify([job.resolved_recipients, job.recipient_email, job.entry_id, job.subject]).toLowerCase().includes(search)) &&
|
||||
matchesStatus("send", job.send_status) && matchesStatus("imap", job.imap_status)));
|
||||
const hold = holdNextRead;
|
||||
if (hold) { holdNextRead = undefined; heldReads++; await hold; }
|
||||
return route.fulfill({ json: { jobs: selected, page: 1, page_size: 50, total: selected.length, total_unfiltered: jobs.length, pages: 1, counts: { send: {}, imap: {} }, filtered_counts: {} } });
|
||||
}
|
||||
if (/\/jobs\/job-\d+$/.test(url.pathname)) {
|
||||
detailReads++;
|
||||
return route.fulfill({ json: { job: jobs.find(job => url.pathname.endsWith(job.id)), attempts: { smtp: [], imap: [] } } });
|
||||
}
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload });
|
||||
if (options.holdWrite) await options.holdWrite;
|
||||
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 409, json: { detail: "Fixture recovery changed. Reload evidence before retrying." } });
|
||||
if (/\/jobs\/(retry|send-unattempted)$/.test(url.pathname)) {
|
||||
expect(payload.run_inline).toBe(true); expect(payload.enqueue_celery).toBe(false);
|
||||
expect(payload.version_id).toBe("report-version");
|
||||
for (const id of payload.job_ids) {
|
||||
const job = jobs.find(row => row.id === id)!;
|
||||
expect(["failed_temporary", "failed_permanent", "not_queued", "queued"]).toContain(job.send_status);
|
||||
job.send_status = "smtp_accepted";
|
||||
}
|
||||
return route.fulfill({ json: { result: { selected_count: payload.job_ids.length, attempted_count: payload.job_ids.length,
|
||||
sent_count: payload.job_ids.length, failed_count: 0, outcome_unknown_count: 0, remaining_count: 0, run_inline: true } } });
|
||||
}
|
||||
const job = jobs.find(row => url.pathname.includes(`/jobs/${row.id}/`));
|
||||
if (url.pathname.endsWith("/recover-claim") && job) {
|
||||
expect(payload.expected_revision).toBe(job.recovery.smtp.revision); expect(payload.note.trim()).not.toBe("");
|
||||
job.send_status = "outcome_unknown"; job.recovery.smtp.eligible = false;
|
||||
return route.fulfill({ json: { result: { job_id: job.id } } });
|
||||
}
|
||||
if (url.pathname.endsWith("/resolve-outcome") && job) {
|
||||
expect(payload.note.trim()).not.toBe("");
|
||||
job.send_status = payload.decision === "smtp_accepted" ? "smtp_accepted" : "failed_temporary";
|
||||
return route.fulfill({ json: { result: { job_id: job.id } } });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-report&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.switchAccount ? "&switch-account" : ""}`);
|
||||
const table = page.getByRole("table", { name: "campaign-report-jobs-v2-report-campaign", exact: true });
|
||||
await expect(table.getByText("Report message 1", { exact: true })).toBeVisible();
|
||||
return { table, jobs, writes, errors, holdNextJobsRead(hold: Promise<void>) { holdNextRead = hold; }, get heldReads() { return heldReads; }, get detailReads() { return detailReads; } };
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"] as const) test(`${language}: Report shows every To/Cc/Bcc recipient and searches without per-row details`, async ({ page }) => {
|
||||
const fixture = await install(page, { language });
|
||||
await expect(fixture.table.getByText("Additional person <additional-1@example.test>", { exact: false })).toBeVisible();
|
||||
await expect(fixture.table.getByText("copy-1@example.test", { exact: true })).toBeVisible();
|
||||
await expect(fixture.table.getByText("blind-1@example.test", { exact: true })).toBeVisible();
|
||||
await expect(fixture.table.getByText(language === "de" ? "An:" : "To:", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.detailReads).toBe(0);
|
||||
await page.getByRole("textbox").first().fill("blind-2@example.test");
|
||||
await expect(fixture.table.getByText("Report message 2", { exact: true })).toBeVisible();
|
||||
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
|
||||
expect(fixture.detailReads).toBe(0); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("workerless Report retry executes one canonical request and displays real acceptance", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await expect(page.getByRole("button", { name: "Queue temporary failures for workers", exact: true })).toBeDisabled();
|
||||
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].path).toMatch(/\/jobs\/retry$/);
|
||||
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1"]);
|
||||
await expect(page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
|
||||
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
|
||||
await expect(page.getByText(/Delivery request finished: 1 attempted, 1 accepted, 0 failed/)).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(fixture.table.getByText("Report message 1", { exact: true })).toBeVisible();
|
||||
expect(fixture.jobs[0].send_status).toBe("smtp_accepted"); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("page retry and unattempted continuation exclude accepted, active and uncertain messages", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
expect(fixture.jobs[5].send_status).toBe("queued");
|
||||
await page.getByRole("button", { name: "Retry failed messages on this page now (2)", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1", "job-2"]);
|
||||
expect(fixture.writes[0].payload.include_permanent).toBe(true);
|
||||
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
|
||||
await page.getByRole("button", { name: "Send unattempted messages on this page now (1)", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(2);
|
||||
expect(fixture.writes[1].path).toMatch(/\/jobs\/send-unattempted$/);
|
||||
expect(fixture.writes[1].payload.job_ids).toEqual(["job-6"]);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("reconciliation requires evidence, retains failed input, and retries only explicitly", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectFirst: true });
|
||||
await fixture.table.getByRole("button", { name: /^(Not sent|Nicht gesendet)$/ }).and(page.locator(":enabled")).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: "Record message as not sent", exact: true })).toBeDisabled();
|
||||
await dialog.getByRole("textbox", { name: /^Evidence note/ }).fill("SMTP logs show no acceptance for this message ID.");
|
||||
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
|
||||
await expect(dialog.getByText("Fixture recovery changed. Reload evidence before retrying.", { exact: false })).toBeVisible();
|
||||
await expect(dialog.getByRole("textbox")).toHaveValue("SMTP logs show no acceptance for this message ID.");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.writes[1].payload.note).toBe("SMTP logs show no acceptance for this message ID.");
|
||||
expect(fixture.jobs[2].send_status).toBe("failed_temporary");
|
||||
expect(fixture.writes).toHaveLength(2); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("only a server-proven stale claim can be recovered, without implicit send or reconciliation", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await expect(fixture.table.getByText(/Processing is still active or its lease has not expired/)).toBeVisible();
|
||||
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled"))).toHaveCount(1);
|
||||
await fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled")).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox").fill("Previous process stopped; checked its expired lease and server logs.");
|
||||
await dialog.getByRole("button", { name: "Mark outcome for investigation", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.writes[0].path).toContain("/jobs/job-4/recover-claim");
|
||||
expect(fixture.writes[0].payload.expected_revision).toBe("claim-revision-4");
|
||||
expect(fixture.jobs[3].send_status).toBe("outcome_unknown"); expect(fixture.jobs[4].send_status).toBe("sending");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("pending recovery cannot duplicate or dismiss, and read-only Report cannot mutate", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { holdWrite: held });
|
||||
await fixture.table.getByRole("button", { name: /^(Accepted|Angenommen)$/ }).and(page.locator(":enabled")).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox").fill("Verified SMTP acceptance in the server log.");
|
||||
await dialog.getByRole("button", { name: "Record SMTP acceptance", exact: true }).dblclick();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible(); await expect(dialog.getByRole("textbox")).toBeDisabled();
|
||||
release(); await expect(dialog).toHaveCount(0);
|
||||
await page.goto("/?campaign-report&language=en&read-only");
|
||||
await expect(fixture.table.getByRole("button", { name: "Retry now", exact: true }).first()).toBeDisabled();
|
||||
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).first()).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("queued remainder can continue by row, but previous SMTP, Postbox or Print attempts cannot", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const continuations = fixture.table.getByRole("button", { name: "Send unattempted message now", exact: true }).and(page.locator(":enabled"));
|
||||
for (const field of ["attempt_count", "postbox_attempt_count", "print_attempt_count"] as const) {
|
||||
fixture.jobs[5][field] = 1;
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(continuations).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Send unattempted messages on this page now (0)", exact: true })).toBeDisabled();
|
||||
fixture.jobs[5][field] = 0;
|
||||
}
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(continuations).toHaveCount(1);
|
||||
await continuations.click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].payload.job_ids).toEqual(["job-6"]);
|
||||
expect(fixture.jobs[3].send_status).toBe("sending");
|
||||
expect(fixture.jobs[2].send_status).toBe("outcome_unknown");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("active, queued and incomplete IMAP counts drill down to the exact current states", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
for (const [label, count, ids] of [
|
||||
["Sending", 2, [4, 5]], ["Queued", 1, [6]], ["Pending", 1, [1]],
|
||||
["Copying to Sent", 1, [2]], ["Outcome uncertain", 1, [3]]
|
||||
] as const) {
|
||||
const shortcut = page.locator("dt").filter({ hasText: new RegExp(`^${label}$`) }).locator("..").getByRole("button");
|
||||
await expect(shortcut).toHaveText(String(count));
|
||||
await shortcut.click();
|
||||
await expect(shortcut).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(fixture.table.getByText(/^Report message \d+$/)).toHaveCount(ids.length);
|
||||
for (const id of ids) await expect(fixture.table.getByText(`Report message ${id}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("old report reads cannot restore rows after switching account context", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { switchAccount: true });
|
||||
fixture.holdNextJobsRead(held);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect.poll(() => fixture.heldReads).toBe(1);
|
||||
fixture.jobs[0].subject = "New account message";
|
||||
await page.getByRole("button", { name: "Switch fixture account" }).click();
|
||||
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
|
||||
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs"));
|
||||
release();
|
||||
await oldResponse;
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
|
||||
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an old recovery acknowledgment cannot reopen progress in another account context", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { switchAccount: true, holdWrite: held });
|
||||
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
// Simulate an externally changed authentication provider while the request is pending.
|
||||
await page.getByRole("button", { name: "Switch fixture account" }).evaluate(button => (button as HTMLButtonElement).click());
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs/retry"));
|
||||
release();
|
||||
await oldResponse;
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect.poll(() => fixture.jobs[0].send_status).toBe("smtp_accepted");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByText(/Delivery request finished:/)).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("long recipient names and addresses wrap without clipping their contents", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const email = `${"long-address-segment-".repeat(5)}@example.test`;
|
||||
fixture.jobs[0].resolved_recipients.bcc = [{ email }];
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
const cell = fixture.table.locator(".campaign-report-recipient-cell").filter({ hasText: email });
|
||||
await expect(cell.getByText(email, { exact: true })).toBeVisible();
|
||||
await expect.poll(() => cell.evaluate(node => ({ overflow: node.scrollWidth - node.clientWidth, height: node.getBoundingClientRect().height })))
|
||||
.toMatchObject({ overflow: 0 });
|
||||
expect(await cell.getByText(email, { exact: true }).evaluate(node => getComputedStyle(node).overflowWrap)).toBe("anywhere");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user