/**
* Questionnaires and comment threads, without an agent.
*
* `DEV_QUESTIONS` and `DEV_COMMENTS` exist so that the whole question and
* passage path can be exercised by a room opening rather than by a model
* deciding to ask something. They are read from the environment on every room
* open, so this file runs against a second server — one with them on would put
* a question and a thread into every other suite's room.
*
* A comment needs prose to mark, and a new room seeds from nothing, so every
* test here writes the plan before anybody opens it.
*/
import { content, expect, test } from "./room";
import { writeFile } from "node:fs/promises";
import * as Path from "node:path";
import { scratch } from "./servers";
/** Long enough to be marked: the injector wants twenty characters. */
const PROSE = "Room state lives on disk as MDX beside the transcript.\n";
const TWO_BLOCKS = `${PROSE}\nA second block remains after the marked passage.\n`;
const LONG_PLAN = Array.from({ length: 80 }, (_, index) => `Paragraph ${index + 1}.`).join("\n\n");
const WIDGET = "01K0N4TR8K7JGM4R1J7PW4R8YJ";
const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2";
const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA";
const ANCHORED = `Anchored paragraph.
`;
const ANCHORED_DIGEST = "sha256:3ccc3e648811df8180799f8b012c6934bcf44a306f5eb8b8c9d2676b0493ccf4";
/** What the injector will quote: the first forty-eight characters. */
const QUOTED = "Room state lives on disk as MDX beside the trans";
function questionnaire(page: import("@playwright/test").Page) {
return page.locator('[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]');
}
function commentButton(page: import("@playwright/test").Page) {
return page.getByRole("button", { name: /Comment on/ });
}
async function rewriteFirstBlock(page: import("@playwright/test").Page, value: string) {
let block = content(page).locator("p").first();
await block.selectText();
await page.keyboard.type(value);
}
async function thread(page: import("@playwright/test").Page) {
await commentButton(page).click();
return page.getByRole("dialog", { name: "Comment thread" });
}
test("prose keeps Plan selected while questions arrive in Decisions", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
let plan = page.getByRole("button", { name: "Plan", exact: true });
let decisions = page.getByRole("button", { name: "Decisions", exact: true });
await expect(plan).toHaveAttribute("aria-pressed", "true");
await expect(decisions).toHaveAttribute("aria-pressed", "false");
await expect(decisions).toContainText("2");
await decisions.click();
await expect(questionnaire(page)).toHaveCount(1);
await expect(questionnaire(page).getByRole("heading", { name: "Storage" })).toBeVisible();
await expect(page.locator('[data-document-view="decisions"] [data-plan-sidecar-thread]'))
.toHaveCount(0);
});
test("a question-only document opens Decisions", async ({ page, room }) => {
await page.goto(`/r/${room}?as=ana`);
await expect(page.getByRole("button", { name: "Decisions", exact: true }))
.toHaveAttribute("aria-pressed", "true");
});
test("the waiting-question line selects Decisions", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
await page.locator("#pane-chat").getByRole("button", { name: "Answer" }).click();
await expect(page.getByRole("button", { name: "Decisions", exact: true }))
.toHaveAttribute("aria-pressed", "true");
await expect(questionnaire(page)).toBeInViewport();
});
test("switching views restores the plan scroll position", async ({ join, seed }) => {
await seed(LONG_PLAN);
let page = await join("ana");
let scroller = page.locator(".plan-document > div.h-full.min-h-0.overflow-auto");
await scroller.evaluate(element => {
element.scrollTop = 160;
element.dispatchEvent(new Event("scroll"));
});
await page.getByRole("button", { name: "Decisions", exact: true }).click();
await page.getByRole("button", { name: "Plan", exact: true }).click();
await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBe(160);
});
test("Show in plan focuses the addressed inline questionnaire", async ({ baseURL, join, room, seed }) => {
await seed(ANCHORED);
await writeFile(
Path.join(scratch(Number(new URL(baseURL!).port)), room, "state.json"),
JSON.stringify({
revision: 1,
questions: [{
id: WIDGET,
status: "answered",
resolver: "ana",
definition: {
questions: [{
id: QUESTION,
header: "Rollout",
question: "How should we deploy?",
multiple: false,
options: [{ id: OPTION, label: "Canary", description: "" }],
}],
},
answers: { [QUESTION]: "Canary" },
anchors: {
widget: WIDGET,
questions: {
[QUESTION]: {
anchors: [{ epoch: "stale", position: "", digest: ANCHORED_DIGEST }],
pending: false,
},
},
},
}],
}),
);
let page = await join("ana");
await page.getByRole("button", { name: "Decisions", exact: true }).click();
let card = questionnaire(page).filter({ hasText: "How should we deploy?" });
await card.getByRole("button", { name: /How should we deploy.*show in plan/ }).click();
await expect(page.getByRole("button", { name: "Plan", exact: true }))
.toHaveAttribute("aria-pressed", "true");
await expect(
page.locator(
`[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${WIDGET}"]`,
),
).toBeFocused();
});
test("answering both questions resolves the card and writes the decision", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
await page.getByRole("button", { name: "Decisions", exact: true }).click();
let card = questionnaire(page);
await card.getByRole("radio", { name: /On disk as MDX/ }).check();
await expect(card.getByRole("button", { name: "Submit" })).toHaveCount(0);
await card.getByRole("tab", { name: "Scope" }).click();
await card.getByRole("checkbox", { name: /Anchors/ }).check();
await card.getByRole("checkbox", { name: /Export/ }).check();
await card.getByRole("button", { name: "Submit" }).click();
await expect(card).toContainText("On disk as MDX");
await expect(card).toContainText("Anchors, Export");
await expect(card).toContainText("Answered by @ana");
await expect(card.getByRole("button", { name: "Submit" })).toHaveCount(0);
});
test("an unanswered question refuses to submit and says which", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
await page.getByRole("button", { name: "Decisions", exact: true }).click();
let card = questionnaire(page);
await card.getByRole("tab", { name: "Scope" }).click();
await card.getByRole("checkbox", { name: /Anchors/ }).check();
await card.getByRole("button", { name: "Submit" }).click();
await expect(card.getByRole("alert")).toHaveText(
"Every question needs an answer before submitting.",
);
await expect(card).not.toContainText("Answered by");
});
test("cancelling asks first", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
await page.getByRole("button", { name: "Decisions", exact: true }).click();
let card = questionnaire(page);
await card.getByRole("tab", { name: "Scope" }).click();
await card.getByRole("button", { name: "Cancel" }).click();
await expect(card).toContainText("Cancel without answering?");
await card.getByRole("button", { name: "Keep it" }).click();
await expect(card.getByRole("button", { name: "Submit" })).toBeVisible();
});
test("a marked passage has document chrome with a hover preview", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
let button = commentButton(page);
await expect(button).toBeVisible();
await button.hover();
await expect(page.getByRole("tooltip")).toContainText(QUOTED);
await button.focus();
await expect(page.getByRole("tooltip")).toBeVisible();
});
test("clicking a comment button pins its document card and preserves the related wash", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
let card = await thread(page);
await expect(card).toContainText("@dev");
await expect(card.getByPlaceholder("Reply…")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Comment thread" })).toHaveCount(0);
card = await thread(page);
await expect.poll(() => washed(page)).toBeGreaterThan(0);
await content(page).hover();
await expect.poll(() => washed(page)).toBeGreaterThan(0);
});
test("a live text edit preserves its document comment", async ({ join, seed }) => {
await seed(TWO_BLOCKS);
let page = await join("ana");
await rewriteFirstBlock(page, "The room has a new persistence rule.");
await expect(content(page)).toContainText("The room has a new persistence rule.");
await expect.poll(() => washed(page)).toBeGreaterThan(0);
await expect(commentButton(page)).toBeVisible();
});
test("a removed subject block moves its comment to document orphan chrome", async ({ join, seed }) => {
await seed(TWO_BLOCKS);
let page = await join("ana");
let first = content(page).locator("p").first();
await first.selectText();
await page.keyboard.press("Backspace");
await page.keyboard.press("Backspace");
let orphaned = page.getByRole("button", { name: "1 orphaned comments" });
await expect(orphaned).toBeVisible();
await orphaned.click();
await expect(page.getByRole("dialog", { name: "Orphaned comments" })).toContainText(QUOTED);
});
function washed(page: import("@playwright/test").Page): Promise {
return page.evaluate(() => CSS.highlights.get("plan-related")?.size ?? 0);
}
test("a reply joins the thread, and the quote counts it", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
let card = await thread(page);
await expect(card.getByText(/repl(y|ies)$/)).toHaveCount(0);
await card.getByPlaceholder("Reply…").fill("Still right, but say why.");
await card.getByRole("button", { name: "Reply" }).click();
await expect(card).toContainText("Still right, but say why.");
await expect(card).toContainText("@ana");
await expect(card.getByText("1 reply")).toBeVisible();
});
test("accepting asks twice, and says so in the transcript", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
let card = await thread(page);
await card.getByRole("button", { name: "Accept" }).click();
await expect(card.getByRole("button", { name: "Sure?" })).toBeVisible();
await card.getByRole("button", { name: "Sure?" }).click();
await expect(page.getByText(/accepted a comment on/)).toBeVisible();
await expect(
page.getByText("The agent is not running, so the plan has not been revised."),
).toBeVisible();
await expect(page.getByRole("dialog", { name: "Comment thread" })).toHaveCount(0);
await expect(commentButton(page)).toHaveCount(0);
await expect(content(page).getByRole("article", { name: "Decision" })).toContainText(QUOTED);
});
test("a dismissed thread removes its document button", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
let card = await thread(page);
await card.getByRole("button", { name: "Dismiss" }).click();
await card.getByRole("button", { name: "Sure?" }).click();
await expect(commentButton(page)).toHaveCount(0);
});