import { expect, test, type Page } from "@playwright/test"; async function install(page: Page, recipientData = false, fieldCount = 0) { const errors: string[] = []; page.on("pageerror", error => { errors.push(error.message); console.error("Attachment fixture:", error.message); }); let revision = 1; const zip = { enabled: true, archives: [{ id: "zip-1", name: "recipient.zip", method: "zip_standard", password_enabled: true }] }; let raw = { campaign: { name: "Fixture" }, template: { subject: "Fixture", text: "" }, server: {}, ...(recipientData ? { recipients: { allow_individual_to: true }, fields: Array.from({ length: fieldCount }, (_, index) => ({ name: `field_${index + 1}`, label: `Field ${index + 1}`, type: "string", can_override: true })), entries: { defaults: {}, inline: [ { id: "recipient-1", name: "Fixture recipient", email: "recipient@example.test", channel_policy: "mail", print_target: { target: "Dispatch fixture", channel: "internal_mail" } } ] } } : {}), attachments: { base_paths: [{ id: "source-1", name: "Source", path: ".", source: "managed:user:user-1", allow_individual: true }], global: [], zip } }; const writes: Record[] = []; const mutations: string[] = []; const version = () => ({ id: "version-attachments", campaign_id: "campaign-attachments", version_number: 1, edit_revision: revision, strong_etag: `"version-attachments:${revision}"`, editor_state: {}, current_flow: "manual", current_step: "files", workflow_state: "editing", is_complete: false, updated_at: "2026-09-07T10:00:00Z", raw_json: raw }); await page.route((url) => url.pathname.startsWith("/api/"), async route => { const request = route.request(); const url = new URL(request.url()); if (request.method() !== "GET") mutations.push(`${request.method()} ${url.pathname}`); if (request.method() === "GET") { if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: { campaign: { id: "campaign-attachments", name: "Fixture", current_version_id: "version-attachments", status: "draft" }, versions: [version()], current_version: version(), summary: null, deleted: [], full: true, has_more: false, watermark: `w${revision}` } }); if (url.pathname === "/api/v1/files/spaces") return route.fulfill({ json: { spaces: [{ id: "space-1", label: "My files", space_type: "managed", owner_type: "user", owner_id: "user-1" }] } }); if (url.pathname === "/api/v1/files/folders") return route.fulfill({ json: { folders: [], next_cursor: null } }); if (url.pathname === "/api/v1/files") return route.fulfill({ json: { files: [], next_cursor: null } }); if (url.pathname === "/api/v1/files/delta") return route.fulfill({ json: { files: [], folders: [{ id: "folder-1", owner_type: "user", owner_id: "user-1", path: "letters", name: "letters" }], deleted: [], full: true, has_more: false, watermark: "files-w1" } }); if (url.pathname.endsWith("/versions/version-attachments")) return route.fulfill({ json: version() }); if (url.pathname.endsWith("/archive-encryption-policy")) return route.fulfill({ json: { available: true, allowed_password_encryption_methods: ["aes"], allowed_password_delivery_channels: ["separate_mail"], policy_hash: "fixture", source_path: [], diagnostics: [], reason: "Legacy ZipCrypto is blocked by policy.", legacy_label: "Legacy ZipCrypto" } }); return route.fulfill({ json: {} }); } if (request.method() === "POST" && url.pathname.endsWith("/autosave")) { const body = request.postDataJSON(); writes.push(body); raw = body.campaign_json; revision++; return route.fulfill({ json: version() }); } return route.abort(); }); await page.goto(`/?campaign-attachments${recipientData ? "&recipient-data" : ""}`); if (recipientData) await expect(page.locator('.recipient-profiles-table-surface .data-grid-body-cell[data-column-id="recipients"]')).toContainText("recipient@example.test"); else await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled(); return { writes, mutations, errors, zip }; } test("actual Files chooser opens repeatedly from attachment path by click and keyboard", async ({ page }) => { const fixture = await install(page); const input = page.locator("#campaign-attachment-sources .chooser-display-input"); for (const action of ["click", "Enter", "Space", "click"]) { if (action === "click") await input.click(); else { await input.focus(); await page.keyboard.press(action); } await expect(page.getByRole("dialog")).toBeVisible(); await expect(page.getByRole("dialog").getByRole("button", { name: /Use.*folder|Select.*folder/i })).toBeEnabled(); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog")).toHaveCount(0); } expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("temporarily unavailable Files capability does not silently turn managed paths into text edits", async ({ page }) => { const fixture = await install(page); await page.getByRole("button", { name: "Toggle Files capability" }).click(); await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeDisabled(); await expect(page.getByText(/The Files browser is currently unavailable/).first()).toBeVisible(); await page.getByRole("button", { name: "Toggle Files capability" }).click(); await page.locator("#campaign-attachment-sources .chooser-display-input").click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape"); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("attachment source corrections save without changing or reauthorizing legacy ZIP configuration", async ({ page }) => { const fixture = await install(page); await page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]').fill("Updated source name"); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect.poll(() => fixture.writes.length).toBe(1); expect(fixture.writes[0].campaign_json.attachments.zip).toEqual(fixture.zip); expect(fixture.writes[0].campaign_json.attachments.base_paths[0].name).toBe("Updated source name"); await page.reload(); await expect(page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]')).toHaveValue("Updated source name"); expect(fixture.errors).toEqual([]); }); test("global attachment labels grow across the fixed source column without changing campaign data", async ({ page }) => { const fixture = await install(page); const card = page.locator("#campaign-global-attachments"); const label = card.locator('.data-grid-header-cell[data-column-id="label"]'); const basePath = card.locator('.data-grid-header-cell[data-column-id="base_path"]'); const pattern = card.locator('.data-grid-header-cell[data-column-id="file_filter"]'); const measured = (column: typeof label) => column.evaluate((element) => element.getBoundingClientRect().width); await expect(label).toBeVisible(); const handle = label.getByRole("separator"); await handle.scrollIntoViewIfNeeded(); const initialLabel = await measured(label); const initialBasePath = await measured(basePath); const initialPattern = await measured(pattern); const bounds = (await handle.boundingBox())!; await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await page.mouse.down(); await page.mouse.move(bounds.x + bounds.width / 2 + 80, bounds.y + bounds.height / 2, { steps: 6 }); await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 80, 0); await page.mouse.up(); await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 80, 0); await expect.poll(() => measured(basePath)).toBeCloseTo(initialBasePath, 0); await expect.poll(() => measured(pattern)).toBeCloseTo(initialPattern, 0); await handle.press("Shift+ArrowRight"); await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 120, 0); await page.reload(); await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 120, 0); expect(fixture.writes).toHaveLength(0); expect(fixture.mutations).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("recipient and delivery columns grow past the old caps across fixed neighbors and keep personal widths", async ({ page }) => { await page.setViewportSize({ width: 2048, height: 1100 }); const fixture = await install(page, true); const grid = page.locator(".recipient-profiles-table-surface"); await expect(grid.locator('.data-grid-body-cell[data-column-id="delivery"]')).toContainText("Internal mail: Dispatch fixture"); const column = (id: string) => grid.locator(`.data-grid-header-cell[data-column-id="${id}"]`); const measured = (id: string) => column(id).evaluate((element) => element.getBoundingClientRect().width); const fixedWidths = { active: await measured("active"), attachments: await measured("attachments") }; const expectedWidths: Record = {}; for (const [id, oldMaximum] of [["recipients", 640], ["delivery", 480]] as const) { const handle = column(id).getByRole("separator"); await grid.locator(".data-grid-scroll-region").evaluate((element, columnId) => { const header = element.querySelector(`.data-grid-header-cell[data-column-id="${columnId}"]`)!; element.scrollLeft = Math.max(0, header.offsetLeft - element.clientWidth / 4); }, id); await handle.scrollIntoViewIfNeeded(); const initial = await measured(id); const target = Math.max(oldMaximum + 80, initial + 80); const bounds = (await handle.boundingBox())!; await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await page.mouse.down(); await page.mouse.move(bounds.x + bounds.width / 2 + target - initial, bounds.y + bounds.height / 2, { steps: 6 }); await expect.poll(() => measured(id)).toBeCloseTo(target, 0); await page.mouse.up(); await expect.poll(() => measured(id)).toBeCloseTo(target, 0); await handle.press("Shift+ArrowRight"); expectedWidths[id] = target + 40; await expect.poll(() => measured(id)).toBeCloseTo(expectedWidths[id], 0); for (const fixed of ["active", "attachments"] as const) await expect.poll(() => measured(fixed)).toBeCloseTo(fixedWidths[fixed], 0); } expect(await grid.locator(".data-grid-scroll-region").evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true); await page.reload(); for (const id of ["recipients", "delivery"]) await expect.poll(() => measured(id)).toBeCloseTo(expectedWidths[id], 0); await expect(grid.locator('.data-grid-body-cell[data-column-id="delivery"]')).toContainText("Internal mail: Dispatch fixture"); for (const fixed of ["active", "attachments"] as const) await expect.poll(() => measured(fixed)).toBeCloseTo(fixedWidths[fixed], 0); expect(fixture.writes).toHaveLength(0); expect(fixture.mutations).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("ultrawide recipient grids keep balanced initial widths and resize in both directions across fixed columns", async ({ page }) => { await page.setViewportSize({ width: 3085, height: 1200 }); const fixture = await install(page, true, 3); const grid = page.locator(".recipient-profiles-table-surface"); const region = grid.locator(".data-grid-scroll-region"); const column = (id: string) => grid.locator(`.data-grid-header-cell[data-column-id="${id}"]`); const width = (id: string) => column(id).evaluate(element => element.getBoundingClientRect().width); await expect.poll(() => width("recipients")).toBeLessThanOrEqual(640.01); await expect.poll(() => width("delivery")).toBeLessThanOrEqual(480.01); const fixed = { active: await width("active"), attachments: await width("attachments") }; const ready = async (id: string) => { const handle = column(id).getByRole("separator"); await handle.scrollIntoViewIfNeeded(); return handle; }; const keyResize = async (id: string, grow: boolean, count = 1) => { const handle = await ready(id); for (let step = 0; step < count; step += 1) await handle.press(grow ? "Shift+ArrowRight" : "Shift+ArrowLeft"); }; // All right-hand field columns must be adjustable beyond their old 360px // presentation caps, and shrinking must not silently snap back afterwards. for (const id of ["field-field_1", "field-field_2", "field-field_3"]) { const initial = await width(id); await keyResize(id, true, 6); await expect.poll(() => width(id)).toBeCloseTo(initial + 240, 0); await keyResize(id, false, 2); await expect.poll(() => width(id)).toBeCloseTo(initial + 160, 0); } const recipientStart = await width("recipients"); await keyResize("recipients", true, 10); await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 400, 0); const recipientHandle = await ready("recipients"); const bounds = (await recipientHandle.boundingBox())!; await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await page.mouse.down(); await page.mouse.move(bounds.x + bounds.width / 2 - 160, bounds.y + bounds.height / 2, { steps: 8 }); await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 240, 0); await page.mouse.up(); await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 240, 0); const deliveryStart = await width("delivery"); await keyResize("delivery", true, 3); await keyResize("delivery", false, 2); await expect.poll(() => width("delivery")).toBeCloseTo(deliveryStart + 40, 0); const savedWidths = await Promise.all(["recipients", "delivery", "field-field_1", "field-field_2", "field-field_3"].map(width)); await page.reload(); for (const [index, id] of ["recipients", "delivery", "field-field_1", "field-field_2", "field-field_3"].entries()) { await expect.poll(() => width(id)).toBeCloseTo(savedWidths[index], 0); } await (await ready("delivery")).focus(); const deliveryBounds = (await column("delivery").boundingBox())!; const viewport = (await region.boundingBox())!; expect(deliveryBounds.x + deliveryBounds.width).toBeGreaterThan(viewport.x); expect(deliveryBounds.x).toBeLessThan(viewport.x + viewport.width); for (const id of ["active", "attachments"] as const) await expect.poll(() => width(id)).toBeCloseTo(fixed[id], 0); expect(fixture.mutations).toHaveLength(0); expect(fixture.errors).toEqual([]); });