import { expect, test } from "@playwright/test"; import { fileURLToPath } from "node:url"; import { readdir, readFile } from "node:fs/promises"; import { questionViewContractsBinding } from "./question-view-contracts.fixture"; import type { Page } from "@playwright/test"; let script: string; let stylesheet: string; test.beforeAll(async () => { if (typeof Bun === "undefined") throw new Error("Run Playwright through bun --bun"); let entry = fileURLToPath( new URL("../../packages/editor/src/widgets/questionnaire.tsx", import.meta.url), ); let result = await Bun.build({ entrypoints: [entry], target: "browser", format: "iife", plugins: [{ name: "actual-question-view-contracts-binding", setup(build) { build.onLoad({ filter: /\/widgets\/questionnaire\.tsx$/ }, async args => ({ loader: "tsx", contents: await Bun.file(args.path).text() + "\n" + questionViewContractsBinding, })); }, }], }); expect(result.success, JSON.stringify(result.logs)).toBe(true); let outputs = result.outputs.filter(output => output.path.endsWith(".js")); expect(outputs).toHaveLength(1); script = await outputs[0]!.text(); let assets = fileURLToPath(new URL("../../apps/web/dist/assets/", import.meta.url)); let files = await readdir(assets); let names = files.filter(name => /^index-.*\.css$/.test(name)); let editor = files.filter(name => /^plan-editor-.*\.css$/.test(name)); expect(names).toHaveLength(1); expect(editor).toHaveLength(1); stylesheet = (await Promise.all([names[0]!, editor[0]!].map(name => readFile(assets + name, "utf8") ))).join("\n"); expect(stylesheet).toContain(".question-choice-row"); expect(stylesheet).toContain(".question-input"); }); async function load( page: Page, mode: | "open" | "empty" | "linked" | "unlinked" | "discarded-single" | "discarded-multiple" | "legacy-custom" | "readonly-shared" | "readonly-sidebar", ) { let errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); let url = "https://question-view-contracts.invalid/"; await page.route( "**/*", route => route.request().url() === url && route.request().isNavigationRequest() ? route.fulfill({ contentType: "text/html", body: '
', }) : route.abort(), ); await page.goto(url); expect(await page.evaluate(() => ({ mode: document.compatMode, secure: window.isSecureContext }))) .toEqual({ mode: "CSS1Compat", secure: true }); await page.addStyleTag({ content: stylesheet }); await page.addScriptTag({ content: script }); await page.evaluate(mode => window.questionViewContractsFixture.mount(mode), mode); if (mode === "discarded-single" || mode === "discarded-multiple") { await expect(page.getByText(/^Discarded by @ben —/)).toBeVisible(); } else if (mode !== "readonly-shared" && mode !== "readonly-sidebar") { await expect(page.getByRole("heading", { name: /^What auth system should we use\?/, level: 4 })) .toBeVisible(); } return errors; } test("the current open card combines its header, human choice, people and actions", async ({ page }) => { let errors = await load(page, "open"); let card = page.getByRole("article", { name: "Decision", exact: true }); await expect(card.getByTitle("Decision", { exact: true })).toBeVisible(); await expect( card.getByRole("heading", { name: "What auth system should we use?", level: 4, exact: true }), ).toBeVisible(); let prompt = card.getByRole("heading", { name: "What auth system should we use?", exact: true }); await expect(prompt).toBeVisible(); expect(await prompt.evaluate(element => element.tagName)).toBe("H4"); let choices = card.getByRole("group", { name: "Auth", exact: true }); expect(await choices.evaluate(element => element.tagName)).toBe("FIELDSET"); await expect(choices.getByRole("radio")).toHaveCount(2); await expect(choices.getByRole("radio", { name: "GitHub Apps", exact: true })).toBeChecked(); await expect(choices.getByRole("radio", { name: "Auth0", exact: true })).not.toBeChecked(); await expect(card.getByText("from chat", { exact: true })).toHaveCount(0); await expect(card.getByRole("group", { name: "In this decision: ana, bea", exact: true })) .toBeVisible(); await expect(card.getByRole("radio", { name: "Write a custom answer", exact: true })) .toHaveCount(0); await expect(card.getByRole("textbox", { name: "Custom answer for Auth", exact: true })) .toHaveCount(0); await expect(card.getByRole("button", { name: "Add an option", exact: true })).toBeEnabled(); let save = card.getByRole("button", { name: "Save", exact: true }); await expect(save).toBeEnabled(); await choices.getByRole("radio", { name: "Auth0", exact: true }).check(); expect((await page.evaluate(() => window.questionViewContractsFixture.snapshot())).draft.choice) .toBe("a"); await save.click(); await card.getByRole("button", { name: "Discard", exact: true }).click(); await expect(card.getByText("Discard this decision?", { exact: true })).toBeVisible(); expect((await page.evaluate(() => window.questionViewContractsFixture.snapshot())).discarded) .toBe(0); await card.getByRole("button", { name: "Discard", exact: true }).click(); expect(await page.evaluate(() => window.questionViewContractsFixture.snapshot())).toMatchObject({ submitted: 1, discarded: 1, }); await card.getByRole("button", { name: "Keep it", exact: true }).click(); await expect(card.getByText("Discard this decision?", { exact: true })).toHaveCount(0); await card.getByRole("button", { name: "Add an option", exact: true }).click(); await expect(card.getByRole("textbox", { name: "New option", exact: true })).toBeFocused(); expect(errors).toEqual([]); }); test("Save requires a choice while an existing custom draft remains readable", async ({ page }) => { let errors = await load(page, "empty"); let save = page.getByRole("button", { name: "Save", exact: true }); await expect(save).toBeDisabled(); await expect(page.getByRole("radio", { name: "GitHub Apps", exact: true })).not.toBeChecked(); await expect(page.getByRole("radio", { name: "Write a custom answer", exact: true })).toHaveCount( 0, ); await expect(page.getByRole("textbox", { name: /Custom answer/ })).toHaveCount(0); await page.getByRole("radio", { name: "GitHub Apps", exact: true }).check(); await expect(save).toBeEnabled(); await save.click(); expect((await page.evaluate(() => window.questionViewContractsFixture.snapshot())).submitted) .toBe(1); await page.evaluate(() => window.questionViewContractsFixture.mount("legacy-custom")); await expect(page.getByRole("radio", { name: "Another auth approach", exact: true })) .toBeChecked(); await expect(save).toBeEnabled(); await page.getByRole("radio", { name: "Auth0", exact: true }).check(); await expect(page.getByRole("radio", { name: "Another auth approach", exact: true })).toHaveCount( 0, ); expect((await page.evaluate(() => window.questionViewContractsFixture.snapshot())).draft) .toMatchObject({ mode: "choices", choice: "a", custom: "Another auth approach" }); expect(errors).toEqual([]); }); test("Related preserves separate heading and counted name and forwards parent-section events", async ({ page }) => { let errors = await load(page, "linked"); let related = page.getByRole("button", { name: "What auth system should we use? — show in document, 2 places", exact: true, }); await expect(related).toBeVisible(); await expect(related).toHaveAttribute("data-ace-question-id", "q"); await expect(related.getByRole("heading")).toHaveCount(0); expect( await page.getByRole("heading", { name: /^What auth system should we use\?/ }).evaluate( element => element.closest("button") === null, ), ).toBe(true); await page.mouse.move(0, 0); await page.evaluate(() => window.questionViewContractsFixture.resetEvents()); await related.hover(); expect((await page.evaluate(() => window.questionViewContractsFixture.snapshot())).events) .toEqual([{ kind: "enter", question: "q" }]); await page.mouse.move(0, 0); expect((await page.evaluate(() => window.questionViewContractsFixture.snapshot())).events) .toEqual([{ kind: "enter", question: "q" }, { kind: "leave", question: "q" }]); await page.evaluate(() => window.questionViewContractsFixture.resetEvents()); await related.focus(); await page.getByRole("button", { name: "Outside card", exact: true }).focus(); expect((await page.evaluate(() => window.questionViewContractsFixture.snapshot())).events) .toEqual([{ kind: "enter", question: "q" }, { kind: "leave", question: "q" }]); await page.evaluate(() => window.questionViewContractsFixture.resetEvents()); await related.click(); expect((await page.evaluate(() => window.questionViewContractsFixture.snapshot())).events .filter(event => event.kind === "select")).toEqual([{ kind: "select", question: "q" }]); expect(errors).toEqual([]); }); test("an unlinked prompt remains inert prose with no advertised Related destination", async ({ page }) => { let errors = await load(page, "unlinked"); await expect(page.getByRole("button", { name: /show in document/ })).toHaveCount(0); let prompt = page.getByRole("heading", { name: "What auth system should we use?", exact: true }); expect( await prompt.evaluate(element => ({ tag: element.tagName, linked: !!element.closest("button"), })), ) .toEqual({ tag: "H4", linked: false }); await prompt.click(); expect((await page.evaluate(() => window.questionViewContractsFixture.snapshot())).events .filter(event => event.kind === "select")).toEqual([]); expect(errors).toEqual([]); }); test("a discarded single card attributes ben and names its prompt exactly once", async ({ page }) => { let errors = await load(page, "discarded-single"); let card = page.getByRole("article", { name: "Decision", exact: true }); await expect( card.getByText("Discarded by @ben — What auth system should we use?", { exact: true }), ) .toBeVisible(); let visible = await card.innerText(); expect(visible.match(/What auth system should we use\?/g)).toHaveLength(1); expect(visible.match(/Discarded by @ben/g)).toHaveLength(1); await expect(card.getByText(/Cancelled|Answered by/)).toHaveCount(0); await expect(card.getByRole("radio")).toHaveCount(0); await expect(card.getByRole("button", { name: "Save", exact: true })).toHaveCount(0); await expect(card.getByRole("button", { name: "Discard", exact: true })).toHaveCount(0); expect(errors).toEqual([]); }); test("a discarded multi-card names every original question and ben exactly once", async ({ page }) => { let errors = await load(page, "discarded-multiple"); let card = page.getByRole("article", { name: "Decision", exact: true }); await expect(card.getByText( "Discarded by @ben — What auth system should we use?, What belongs in the first cut?", { exact: true }, )).toBeVisible(); let visible = await card.innerText(); expect(visible.match(/What auth system should we use\?/g)).toHaveLength(1); expect(visible.match(/What belongs in the first cut\?/g)).toHaveLength(1); expect(visible.match(/Discarded by @ben/g)).toHaveLength(1); await expect(card.getByText(/Cancelled|Answered by/)).toHaveCount(0); await expect(card.getByRole("radio")).toHaveCount(0); await expect(card.getByRole("button", { name: "Save", exact: true })).toHaveCount(0); await expect(card.getByRole("button", { name: "Discard", exact: true })).toHaveCount(0); expect(errors).toEqual([]); }); for (let surface of ["chat", "sidebar"] as const) { test(`readonly document and ${surface} keep shared drafts disconnected`, async ({ page }) => { let errors = await load(page, surface === "chat" ? "readonly-shared" : "readonly-sidebar"); await page.waitForTimeout(100); expect(await page.evaluate(() => window.questionViewContractsFixture.sharing())).toEqual({ opens: 0, mutations: 0, errors: [], }); let card = page.getByRole("region", { name: "Document", exact: true }).getByRole("article", { name: "Decision", exact: true, }); await expect( card.getByRole("heading", { name: "What auth system should we use?", exact: true }), ).toBeVisible(); await expect(card.getByRole("radio")).toHaveCount(2); for (let label of ["Auth0", "GitHub Apps from chat"]) { await expect(card.getByRole("radio", { name: label, exact: true })).toBeDisabled(); } await expect(card.getByRole("radio", { name: "GitHub Apps from chat", exact: true })) .toBeChecked(); await expect(card.getByRole("button", { name: "Save", exact: true })).toHaveCount(0); await expect(card.getByRole("button", { name: "Add an option", exact: true })).toHaveCount(0); if (surface === "chat") { await expect(page.getByRole("button", { name: "Save decision", exact: true })).toHaveCount(0); await expect(page.getByText("Suggested: GitHub Apps", { exact: true })).toBeVisible(); } else { let sidebar = page.getByRole("region", { name: "Decisions", exact: true }); await expect(sidebar.getByRole("radio", { name: "GitHub Apps from chat", exact: true })) .toBeChecked(); await expect(sidebar.getByRole("radio", { name: "GitHub Apps from chat", exact: true })) .toBeDisabled(); } expect(errors).toEqual([]); }); }