feat: consolidate shared UI and harden browser authority for release
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: { rejectSave?: boolean; rejectReload?: boolean; conflict?: boolean; hold?: Promise<void>; holdFirstRead?: Promise<void> } = {}) {
|
||||
page.on("pageerror", (error) => { console.error("Campaign saving fixture:", error.message); });
|
||||
let revision = 1;
|
||||
let subject = "Original subject";
|
||||
let rejected = false;
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
let reads = 0;
|
||||
const version = () => ({ id: "version-a", campaign_id: "campaign-a", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"version-a:${revision}"`, editor_state: {},
|
||||
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", raw_json: { campaign: { name: "Original" }, template: { subject, text: "" }, server: {} } });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) {
|
||||
const body = route.request().postDataJSON();
|
||||
writes.push(body);
|
||||
if (options.hold) await options.hold;
|
||||
if (options.rejectSave && !rejected) {
|
||||
rejected = true;
|
||||
return route.fulfill({ status: 422, json: { detail: "Fixture policy denied this save" } });
|
||||
}
|
||||
if ((options.conflict && !rejected) || body.base_revision !== revision) {
|
||||
rejected = true;
|
||||
if (options.conflict) { revision = 2; subject = "Other editor's subject"; }
|
||||
return route.fulfill({ status: 409, json: { detail: { code: "revision_conflict", resource: { type: "campaign_version", id: "version-a" }, current_revision: revision, submitted_base_revision: body.base_revision, retryable: true } } });
|
||||
}
|
||||
subject = body.campaign_json.template.subject;
|
||||
revision += 1;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
if (route.request().method() === "GET" && route.request().url().includes("/versions/version-a")) {
|
||||
reads++;
|
||||
const snapshot = version();
|
||||
if (reads === 1 && options.holdFirstRead) await options.holdFirstRead;
|
||||
return options.rejectReload ? route.fulfill({ status: 503, json: { detail: "Fixture refresh unavailable" } }) : route.fulfill({ json: snapshot });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto("/?campaign-saving&language=en");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Original subject");
|
||||
return { writes, reads: () => reads, subject: () => subject };
|
||||
}
|
||||
|
||||
test("a rejected campaign save retains its draft and retries only on explicit request", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectSave: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Unsaved work");
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-error")).toContainText("Fixture policy denied");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Unsaved work");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-result")).toHaveText("true");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.subject()).toBe("Unsaved work");
|
||||
});
|
||||
|
||||
test("duplicate save requests coalesce and newer typing survives the older acknowledgement", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await install(page, { hold: new Promise<void>((resolve) => { release = resolve; }) });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Submitted work");
|
||||
await page.getByRole("button", { name: "Request save twice", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await expect(page.getByRole("button", { name: "Save draft", exact: true })).toBeDisabled();
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer unsaved work");
|
||||
release();
|
||||
await expect(page.getByTestId("save-busy")).toHaveText("false");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByTestId("save-status")).toContainText("newer changes are still unsaved");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer unsaved work");
|
||||
expect(fixture.subject()).toBe("Submitted work");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog", { name: "Concurrent changes" })).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Use my change", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Apply resolution", exact: true }).click();
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.subject()).toBe("Newer unsaved work");
|
||||
});
|
||||
|
||||
test("failed follow-up refresh never turns an acknowledged save into save failed", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectReload: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Committed work");
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-result")).toHaveText("true");
|
||||
await expect(page.getByTestId("save-status")).toContainText("Saved");
|
||||
await expect(page.getByTestId("save-error")).toContainText("was saved");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("cancelling a revision conflict ends saving and keeps the local draft", async ({ page }) => {
|
||||
const fixture = await install(page, { conflict: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("My conflicting work");
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
const dialog = page.getByRole("alertdialog", { name: "Concurrent changes" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(page.getByTestId("save-busy")).toHaveText("false");
|
||||
await expect(page.getByTestId("save-status")).toContainText("Save cancelled");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("My conflicting work");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("background version refresh and failed discard do not silently clear unsaved work", async ({ page }) => {
|
||||
await install(page, { rejectReload: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Keep this draft");
|
||||
await page.getByRole("button", { name: "Background refresh", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
|
||||
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-error")).toContainText("Fixture refresh unavailable");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
|
||||
});
|
||||
|
||||
test("new typing during a discard refresh is retained instead of being replaced by the late response", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Earlier draft");
|
||||
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
|
||||
await expect.poll(fixture.reads).toBe(1);
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer draft");
|
||||
release();
|
||||
await expect(page.getByTestId("save-error")).toContainText("Discard cancelled");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer draft");
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a late discard response cannot overwrite an acknowledged save", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Saved work");
|
||||
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
|
||||
await expect.poll(fixture.reads).toBe(1);
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-result")).toHaveText("true");
|
||||
release();
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Saved work");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.subject()).toBe("Saved work");
|
||||
});
|
||||
Reference in New Issue
Block a user