Files
yoi/tools/web-ux/browser-tests/ticket_shell_test.ts

1353 lines
65 KiB
TypeScript

import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { join } from "@std/path";
import { type Browser, chromium, type Page } from "npm:playwright@1.59.1";
import { loadScenario } from "../src/scenario.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
function artifactPath(path: string): string {
return join(Deno.env.get("WEB_UX_TEST_ARTIFACTS") ?? new URL("../../../target/web-ux", import.meta.url).pathname, "t743-browser", path);
}
// Explicit browser boundary: production static build + loopback API fixture.
// These tests prove UI/request semantics, not real Backend delivery or model judgment.
async function withFixture(
run: (browser: Browser, baseUrl: string) => Promise<void>,
) {
const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname;
const scenario = await loadScenario(scenarioPath);
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
const baseUrl = `http://127.0.0.1:${port}`;
scenario.processes![0].args![4] = String(port);
scenario.processes![0].readyUrl = `${baseUrl}/health`;
const logs = artifactPath(`${port}/logs`);
const processes = await startOwnedProcesses(
scenario.processes!,
scenarioPath,
logs,
[],
);
let browser: Browser | null = null;
try {
browser = await chromium.launch({ headless: true });
await run(browser, baseUrl);
} finally {
await browser?.close();
await stopOwnedProcesses(processes);
}
}
async function detail(base: string, key: string) {
return await (await fetch(`${base}/api/w/tickets/tickets/${key}`)).json();
}
async function control(base: string, body: unknown) {
const response = await fetch(`${base}/fixture-control`, {
method: "POST",
body: JSON.stringify(body),
});
assert(response.ok);
await response.json();
}
function queryResponse(
page: Page,
predicate: (body: Record<string, unknown>) => boolean,
) {
return page.waitForResponse((response) =>
response.url().endsWith("/tickets/query") &&
response.request().method() === "POST" &&
predicate(response.request().postDataJSON())
);
}
async function search(page: Page, value: string) {
await page.getByRole("searchbox", { name: "Search tickets", exact: true }).fill(value);
await page.getByRole("button", { name: "Search", exact: true }).click();
await page.waitForURL((url) => url.searchParams.get("q") === value);
}
function ticketKey(page: Page, key: string) {
return page.locator(".ticket-list-key").filter({ hasText: new RegExp(`^${key}$`) });
}
async function queryRequests(base: string) {
const requests = await (await fetch(`${base}/fixture-state`)).json();
return requests.filter((row: { path: string }) => row.path === "/tickets/query");
}
Deno.test("Ticket parser failures stay local, retain Workspace navigation, and recover on retry", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
const wire = await detail(base, "T-1");
delete wire.initial_post;
let invalid = true;
await page.route("**/api/w/tickets/tickets/T-1", async (route) => {
if (invalid) await route.fulfill({ status: 200, json: wire });
else await route.continue();
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.getByRole("alert").filter({ hasText: "Ticket detail.initial_post must be an object" }).waitFor();
assertEquals(await page.getByText("The selected Workspace cannot be opened", { exact: true }).count(), 0);
assertEquals(await page.getByText("Workspace unavailable", { exact: true }).count(), 0);
assert(await page.getByRole("navigation", { name: "Current workspace location" }).isVisible());
invalid = false;
await page.getByRole("button", { name: "Retry", exact: true }).click();
await page.locator(".ticket-detail-header").waitFor();
assertEquals(await page.getByRole("alert").filter({ hasText: "initial_post must be an object" }).count(), 0);
await page.close();
});
});
Deno.test("Workspace HTTP failure retains its real status and recovers without recreating the Workspace", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
let unavailable = true;
await page.route("**/api/w/tickets/workspace", async (route) => {
if (unavailable) await route.fulfill({ status: 503, body: "private server failure" });
else await route.continue();
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.getByRole("heading", { name: "Workspace details could not be loaded" }).waitFor();
assert(await page.locator(".workspace-page [role=alert]").filter({ hasText: "HTTP 503" }).isVisible());
assertEquals(await page.getByText("The selected Workspace cannot be opened", { exact: true }).count(), 0);
assertEquals(await page.getByText("private server failure", { exact: true }).count(), 0);
unavailable = false;
await page.getByRole("button", { name: "Retry", exact: true }).click();
await page.locator(".ticket-detail-header").waitFor();
await page.close();
});
});
Deno.test("single Ticket query searches beyond loaded rows and preserves qualifiers on pagination", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets-pages/tickets`);
await page.locator(".ticket-list-row").first().waitFor();
assertEquals(await page.locator("a.ticket-list-row").count(), 30);
assertEquals(await ticketKey(page, "T-35").count(), 0);
assertEquals(
await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(),
"state:open",
);
assertEquals(await page.getByRole("form", { name: "Search tickets" }).count(), 1);
assertEquals(await page.locator(".ticket-kanban, .ticket-lane").count(), 0);
assertEquals(await page.getByRole("combobox").count(), 0);
assertEquals(await page.getByRole("checkbox").count(), 0);
assertEquals((await queryRequests(base)).length, 1);
const filtered = queryResponse(page, (body) => (body.tag_ids as string[])?.includes("tag-ui"));
await search(page, 'state:open tag:"UI" assignee:orchestrator');
assertEquals((await filtered).request().postDataJSON(), {
query: null,
states: ["open"],
tag_ids: ["tag-ui"],
assigned_principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
handling_status: null,
sort: "updated_desc",
limit: 30,
cursor: null,
});
await ticketKey(page, "T-35").waitFor();
assertEquals(await page.locator(".ticket-list-row").count(), 2);
assertEquals((await queryRequests(base)).length, 2);
await page.goto(
`${base}/w/tickets-pages/tickets?q=${
encodeURIComponent("state:open assignee:orchestrator tag:tag-investigation")
}`,
);
await page.getByRole("button", { name: "Load more tickets", exact: true }).waitFor();
const next = queryResponse(page, (body) => body.cursor === "30");
await page.getByRole("button", { name: "Load more tickets", exact: true }).click();
const nextRequest = (await next).request().postDataJSON();
assertEquals(nextRequest.tag_ids, ["tag-investigation"]);
assertEquals(nextRequest.states, ["open"]);
assertEquals(nextRequest.assigned_principal, {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
});
await ticketKey(page, "T-40").waitFor();
assertEquals(await page.locator(".ticket-list-row").count(), 39);
assertEquals(new Set(await page.locator(".ticket-list-key").allTextContents()).size, 39);
const finalRow = page.locator("a.ticket-list-row").filter({ has: ticketKey(page, "T-40") });
const href = await finalRow.getAttribute("href");
assert(href);
await finalRow.focus();
await finalRow.press("Enter");
await page.waitForURL((url) => url.pathname === new URL(href, base).pathname);
await page.locator(".ticket-detail-header").waitFor();
});
});
Deno.test("Orchestrator retains handling filters and assignment pagination independently of Ticket search", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets-pages/orchestrator?handling=done`);
await page.getByRole("button", { name: "Load more assignments" }).waitFor();
const moreHandling = queryResponse(
page,
(body) => body.cursor === "30" && body.handling_status === "done",
);
await page.getByRole("button", { name: "Load more assignments" }).click();
const handlingRequest = (await moreHandling).request().postDataJSON();
assertEquals(handlingRequest.assigned_principal, {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
});
assertEquals(handlingRequest.states, []);
await page.locator(".orchestrator-assignment").filter({ hasText: "T-40" }).waitFor();
assertEquals(await page.locator(".orchestrator-assignment").count(), 38);
await page.getByRole("combobox", { name: "Handling (self-reported)", exact: true })
.selectOption("waiting");
const waiting = queryResponse(
page,
(body) => body.handling_status === "waiting" && body.cursor === null,
);
await page.getByRole("button", { name: "Apply filters", exact: true }).click();
await waiting;
await page.locator(".orchestrator-assignment").filter({
hasText: "Awaiting the user's policy choice",
}).waitFor();
assertEquals(await page.locator(".orchestrator-assignment").count(), 1);
assertEquals(await page.getByRole("link", { name: /T-1 / }).count(), 0);
});
});
Deno.test("query URL and back-forward restore text, states and a fresh first page", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets-pages/tickets`);
await page.locator(".ticket-list-row").first().waitFor();
const all = queryResponse(page, (body) => (body.states as string[])?.length === 0);
await search(page, "");
assertEquals((await all).request().postDataJSON().query, null);
assertEquals(new URL(page.url()).searchParams.get("q"), "");
await ticketKey(page, "T-4").waitFor();
const more = queryResponse(page, (body) => body.cursor === "30");
await page.getByRole("button", { name: "Load more tickets", exact: true }).click();
await more;
await ticketKey(page, "T-40").waitFor();
const closed = queryResponse(page, (body) => (body.states as string[])?.[0] === "closed");
await search(page, "state:closed");
assertEquals((await closed).request().postDataJSON().cursor, null);
await ticketKey(page, "T-4").waitFor();
assertEquals(await page.locator(".ticket-list-row").count(), 1);
assertEquals(new URL(page.url()).searchParams.get("q"), "state:closed");
const back = queryResponse(
page,
(body) => (body.states as string[])?.length === 0 && body.cursor === null,
);
await page.goBack();
await back;
await ticketKey(page, "T-1").waitFor();
assertEquals(await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(), "");
assertEquals(await page.locator(".ticket-list-row").count(), 30);
const forward = queryResponse(
page,
(body) => (body.states as string[])?.[0] === "closed" && body.cursor === null,
);
await page.goForward();
await forward;
await ticketKey(page, "T-4").waitFor();
assertEquals(
await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(),
"state:closed",
);
assertEquals(await page.locator(".ticket-list-row").count(), 1);
const text = queryResponse(page, (body) => body.query === "investigation");
await search(page, "investigation");
assertEquals((await text).request().postDataJSON().states, []);
await ticketKey(page, "T-2").waitFor();
await ticketKey(page, "T-4").waitFor();
const stateAll = queryResponse(
page,
(body) => body.query === null && (body.states as string[])?.length === 0,
);
await search(page, "state:all");
await stateAll;
await ticketKey(page, "T-1").waitFor();
assertEquals(await page.locator(".ticket-list-row").count(), 30);
});
});
Deno.test("quoted free text containing tag syntax does not depend on the tag catalog", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
let tagRequests = 0;
await page.route(/\/ticket-tags(?:\?|$)/, async (route) => {
tagRequests++;
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ message: "Tag catalog unavailable" }),
});
});
const response = queryResponse(page, (body) => body.query === "text tag:Ready");
await page.goto(`${base}/w/tickets/tickets?q=${encodeURIComponent('"text tag:Ready"')}`);
const body = (await response).request().postDataJSON();
assertEquals(body.query, "text tag:Ready");
assertEquals(body.tag_ids, []);
assertEquals(body.states, []);
await page.getByText("No tickets match this query.", { exact: true }).waitFor();
assertEquals(tagRequests, 0);
assertEquals(await page.getByRole("alert").count(), 0);
assertEquals((await queryRequests(base)).length, 1);
// 障害routeがquery string付きの実際のタグ取得も捕捉することを確認する。
await search(page, "tag:Ready");
await page.getByRole("alert").waitFor();
assertEquals(tagRequests, 1);
assertEquals((await queryRequests(base)).length, 1);
});
});
Deno.test("recognized invalid qualifiers show an error without issuing a broad Ticket query", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets`);
await page.locator(".ticket-list-row").first().waitFor();
const before = (await queryRequests(base)).length;
for (
const value of [
"state:close",
"state:waiting",
"assignee:user/",
"assignee:worker/embedded",
"tag:",
'reason:"',
]
) {
await search(page, value);
await page.waitForURL((url) => url.searchParams.get("q") === value);
await page.getByRole("alert").first().waitFor();
assertEquals((await queryRequests(base)).length, before);
assertEquals(
await page.getByRole("button", { name: "Load more tickets", exact: true }).count(),
0,
);
}
const recovered = queryResponse(page, (body) => (body.states as string[])?.[0] === "open");
await search(page, "state:open");
await recovered;
await ticketKey(page, "T-1").waitFor();
assertEquals(await page.getByRole("alert").count(), 0);
});
});
Deno.test("current close-reason query sends canonical known/custom reasons and never closes cases", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets`);
await page.locator(".ticket-list-row").first().waitFor();
const response = queryResponse(page, (body) => body.closure_reason === "completed");
await search(page, "state:closed reason:completed");
assertEquals((await response).request().postDataJSON().states, ["closed"]);
await ticketKey(page, "T-4").waitFor();
assertEquals(await page.locator(".ticket-list-row").count(), 1);
const custom = queryResponse(
page,
(body) => (body.closure_reason as { other: string })?.other === "Superseded plan",
);
await search(page, 'reason:"Superseded plan"');
assertEquals((await custom).request().postDataJSON().closure_reason, {
other: "Superseded plan",
});
await page.getByText("No tickets match this query.", { exact: true }).waitFor();
assertEquals((await detail(base, "T-4")).current_closure.reason, "completed");
const requests = await (await fetch(`${base}/fixture-state`)).json();
assert(!requests.some((row: { path: string }) => row.path.endsWith("/case")));
});
});
Deno.test("assignee qualifiers send exact user and runtime-worker identities, not Worker liveness", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets`);
await page.locator(".ticket-list-row").first().waitFor();
for (
const [value, principal] of [
["assignee:user/alice", { kind: "user", account_id: "alice" }],
["assignee:worker/embedded/running-worker", {
kind: "worker",
runtime_id: "embedded",
worker_id: "running-worker",
}],
] as const
) {
const response = queryResponse(
page,
(body) => JSON.stringify(body.assigned_principal) === JSON.stringify(principal),
);
await search(page, value);
assertEquals((await response).request().postDataJSON().assigned_principal, principal);
// All fixture delegations are to the Workspace agent. T-3's Running
// Worker observation must not masquerade as an assigned principal.
await page.getByText("No tickets match this query.", { exact: true }).waitFor();
assertEquals(await page.locator(".ticket-list-row").count(), 0);
}
});
});
async function assertThreadLayout(page: Page) {
const main = page.getByRole("main", { name: "Ticket thread", exact: true });
const rail = page.getByRole("complementary", { name: "Ticket details" });
assertEquals(await main.locator(":scope > *").count(), 1);
assertEquals(await main.locator(".ticket-thread > *").evaluateAll((elements) =>
elements.map((element) => element.className)
), ["ticket-timeline", "ticket-control-form ticket-thread-composer"]);
assertEquals(await main.locator(".ticket-timeline > :first-child").getAttribute("class"), "ticket-thread-event");
assertEquals(await main.locator(".ticket-thread-event[data-initial=true] header strong").textContent(), "Mika");
assert((await main.locator(".ticket-thread-event[data-initial=true] time").textContent())?.trim());
assertEquals(await main.getByRole("heading", { name: /^(Intent|Timeline)$/ }).count(), 0);
assertEquals(await main.locator(".ticket-timeline > *").evaluateAll((elements) =>
elements.every((element, index) => index === 0
? element.matches("article.ticket-thread-event[data-initial=true]")
: index % 2 === 1 ? element.matches(".bevel-line") : element.matches("article.ticket-thread-event"))
), true);
assert(await main.locator(".ticket-thread-composer").evaluate((element) =>
element.previousElementSibling?.matches(".ticket-timeline") &&
element.firstElementChild?.matches(".bevel-line")
));
const metadata = ".ticket-handling, .ticket-tags, .ticket-relations, .ticket-orchestrator-worker, .ticket-worker-observation, .ticket-worker-card, .ticket-resources";
assertEquals(await main.locator(metadata).count(), 0);
assertEquals(await rail.locator(metadata).count(), 7);
assertEquals(await page.locator(".ticket-delivery").count(), 0);
assertEquals(await page.getByText("Delivery and execution", { exact: true }).count(), 0);
assertEquals(await rail.locator(".ticket-thread-event[data-initial=true], .ticket-thread-event, .ticket-thread-composer").count(), 0);
for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resource-editor"]) {
const details = rail.locator(selector);
assertEquals(await details.evaluate((element) => element.tagName), "DETAILS");
assertEquals(await details.getAttribute("open"), null);
assert(await details.locator(":scope > summary").isVisible());
}
assertEquals(await rail.locator(".ticket-worker-card > summary").textContent(), "Role assignments and manual Workers");
assert(await rail.getByRole("heading", { name: "Repository resources", exact: true }).isVisible());
assert(await rail.locator(".ticket-resource-summary").isVisible());
assertEquals(await rail.locator(":scope > section").first().getAttribute("aria-label"), "Repository resources");
assert(await rail.locator(".ticket-resources").evaluate((element) =>
element.getBoundingClientRect().bottom <= element.parentElement!.querySelector(".ticket-handling")!.getBoundingClientRect().top
));
assertEquals(await rail.locator(".ticket-resource-editor > summary").textContent(), "Edit resources");
const evidence = rail.locator("details").filter({ has: page.locator("summary", { hasText: /^MR requirement evidence$/ }) });
if (await evidence.count()) assertEquals(await evidence.getAttribute("open"), null);
}
async function assertThreadDividers(page: Page) {
const lines = page.locator(".ticket-timeline > .bevel-line, .ticket-thread-composer > .bevel-line");
assertEquals(await lines.count(), await page.locator(".ticket-thread-event").count());
for (const line of await lines.all()) {
await line.scrollIntoViewIfNeeded();
assert(await line.isVisible());
assert(await line.evaluate((element) => {
const view = element.ownerDocument.defaultView!;
const style = view.getComputedStyle(element);
const bounds = element.getBoundingClientRect();
const content = element.nextElementSibling!.getBoundingClientRect();
return element.getAttribute("aria-hidden") === "true" &&
style.borderTopStyle === "solid" && parseFloat(style.borderTopWidth) > 0 &&
style.borderTopColor !== "rgba(0, 0, 0, 0)" && style.opacity !== "0" &&
Math.abs(bounds.left - content.left) < 1 && Math.abs(bounds.right - content.right) < 1;
}));
}
}
async function boundedGate(promise: Promise<void>) {
let timer: number | undefined;
try {
await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Pagination response gate timed out")), 30_000);
}),
]);
} finally {
clearTimeout(timer);
}
}
Deno.test("late old-query pagination cannot append rows after query navigation", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets-pages/tickets`);
await page.getByRole("button", { name: "Load more tickets", exact: true }).waitFor();
const held = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const finished = Promise.withResolvers<void>();
await page.route("**/tickets/query", async (route) => {
if (route.request().postDataJSON().cursor !== "30") return await route.continue();
const response = await route.fetch();
held.resolve();
await release.promise;
await route.fulfill({ response });
finished.resolve();
});
try {
await page.getByRole("button", { name: "Load more tickets", exact: true }).click();
await boundedGate(held.promise);
const changed = queryResponse(
page,
(body) => (body.states as string[])?.[0] === "closed" && body.cursor === null,
);
await search(page, "state:closed");
await changed;
await ticketKey(page, "T-4").waitFor();
release.resolve();
await boundedGate(finished.promise);
await page.evaluate(
"new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
);
assertEquals(await page.locator(".ticket-list-key").allTextContents(), ["T-4"]);
assertEquals(
await page.getByRole("button", { name: "Load more tickets", exact: true }).count(),
0,
);
} finally {
release.resolve();
}
});
});
Deno.test("waiting answers retain a delegation, done reissue retains results, and reasoned close/reopen leaves handling separate", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets/T-2`);
await page.getByText("Self-reported handling: waiting", { exact: true })
.waitFor();
const before = await detail(base, "T-2");
const row = before.assignment_tracking[0];
await page.getByLabel("Comment", { exact: true }).fill(
"Ordinary retained discussion, not an answer input",
);
const eventResponse = page.waitForResponse((response) =>
response.url().endsWith("/events") &&
response.request().method() === "POST"
);
await page.getByRole("button", { name: "Add comment", exact: true })
.click();
assertEquals((await eventResponse).request().postDataJSON(), {
role: "comment",
body: "Ordinary retained discussion, not an answer input",
});
await page.getByText("Ordinary retained discussion, not an answer input", {
exact: true,
}).waitFor();
assertEquals((await detail(base, "T-2")).assignment_tracking[0], row);
await page.getByLabel("Answer / continue this handling", { exact: true })
.fill("Choose read-only comparison; keep the same delegation");
const answerResponse = page.waitForResponse((response) =>
response.url().endsWith("/assignment-answer") &&
response.request().method() === "POST"
);
await page.getByRole("button", {
name: "Send answer to this assignment",
exact: true,
}).click();
const answer = (await answerResponse).request().postDataJSON();
assertEquals(answer.assignment_id, row.assignment_id);
assertEquals(answer.expected_last_event_id, row.last_event_id);
assertEquals(
answer.body,
"Choose read-only comparison; keep the same delegation",
);
assert(typeof answer.operation_key === "string");
assertEquals(Object.keys(answer).toSorted(), [
"assignment_id",
"body",
"expected_last_event_id",
"operation_key",
]);
await page.getByRole("status").filter({
hasText: "Answer saved to assignment",
}).waitFor();
const after = await detail(base, "T-2");
assertEquals(after.assignment_tracking[0].assignment_id, row.assignment_id);
assertEquals(after.assignment_tracking[0].tracking.status, "waiting");
assertEquals(after.events.at(-1).kind, "assignment_answer");
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.getByText("Self-reported handling: done", { exact: true })
.waitFor();
const original = (await detail(base, "T-1")).assignment_tracking[0];
const reissueResponse = page.waitForResponse((response) =>
response.url().endsWith("/assignments/orchestrator") &&
response.request().method() === "PUT"
);
await page.getByRole("button", {
name: "Request again (new assignment)",
exact: true,
}).click();
const request = (await reissueResponse).request().postDataJSON();
assertEquals(request.expected_assignment_id, original.assignment_id);
assertEquals(request.principal, {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
});
assert(typeof request.operation_id === "string");
await page.getByText("Self-reported handling: waiting", { exact: true })
.waitFor();
const reissued = await detail(base, "T-1");
assert(
reissued.assignment_tracking[0].assignment_id !== original.assignment_id,
);
assertEquals(reissued.state, "open");
await page.getByText("Past assignment and release history", { exact: true })
.click();
await page.getByLabel("Assignment identity", { exact: true }).fill(
original.assignment_id,
);
await page.getByRole("button", { name: "Read assignment", exact: true })
.click();
await page.getByText(/Previous result: Compared options/).waitFor();
await page.getByText(/replaced by fixture-owner/).waitFor();
await page.goto(`${base}/w/tickets/tickets/T-4`);
await page.getByText("Self-reported handling: done", { exact: true })
.waitFor();
await page.locator(".ticket-thread-composer").getByLabel("Comment", {
exact: true,
}).fill("Reconsider the case without delegating again");
const reopenResponse = page.waitForResponse((response) =>
response.url().endsWith("/case") && response.request().method() === "POST"
);
await page.getByRole("button", { name: "Reopen ticket", exact: true })
.click();
const reopen = (await reopenResponse).request().postDataJSON();
assertEquals(reopen.action, { action: "reopen" });
assertEquals(reopen.expected_state, "closed");
assertEquals(reopen.expected_closure_operation_key, "close-T-4");
await page.getByRole("button", { name: "Comment and close", exact: true })
.waitFor();
const reopened = await detail(base, "T-4");
assertEquals(reopened.state, "open");
assertEquals(reopened.assignment_tracking[0].tracking.status, "done");
assertEquals(reopened.closure_history.length, 2);
await page.locator(".ticket-thread-composer").getByLabel("Comment", {
exact: true,
}).fill("The full requested investigation is delivered");
await page.locator(".ticket-close-options > summary").click();
await page.locator(".ticket-thread-composer").getByRole("combobox", {
name: "Closure reason", exact: true,
}).selectOption("completed");
const closeResponse = page.waitForResponse((response) =>
response.url().endsWith("/case") && response.request().method() === "POST"
);
await page.getByRole("button", { name: "Comment and close", exact: true })
.click();
const close = (await closeResponse).request().postDataJSON();
assertEquals(close.action, { action: "close", reason: "completed" });
assertEquals(close.expected_state, "open");
await page.getByRole("button", { name: "Reopen ticket", exact: true })
.waitFor();
const closed = await detail(base, "T-4");
assertEquals(closed.current_closure.reason, "completed");
assertEquals(closed.tags, reopened.tags);
assertEquals(
closed.assignment_tracking[0].assignment_id,
reopened.assignment_tracking[0].assignment_id,
);
await page.getByText("Past assignment and release history", { exact: true })
.click();
await page.getByLabel("Assignment identity", { exact: true }).fill(
"released-T-4",
);
await page.getByRole("button", { name: "Read assignment", exact: true })
.click();
await page.getByText(/unassigned by fixture-owner/).waitFor();
await page.getByText(
"Previous result: Delivered the full requested investigation",
{
exact: true,
},
).waitFor();
assert(
await page.getByText("Self-reported handling: done", { exact: true })
.isVisible(),
);
const requests = await (await fetch(`${base}/fixture-state`)).json();
assert(!requests.some((r: { path: string }) => r.path.includes("assignment-diagnostics")));
assertEquals(await page.locator(".ticket-delivery").count(), 0);
assertEquals(await page.getByText("Delivery and execution", { exact: true }).count(), 0);
assert(
!requests.some((r: { path: string }) => /\/(queue|ready|state)$/.test(r.path)),
);
});
});
Deno.test("timeline footer comments and closes with one case request, renders the comment once and still accepts closed-case comments", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets/T-1`);
const composer = page.locator(".ticket-thread-composer");
await composer.waitFor();
assertEquals(await page.locator("aside .ticket-thread-composer").count(), 0);
assertEquals(await page.locator(".ticket-case-card").count(), 0);
assert(await composer.evaluate((element) => {
const timeline = element.parentElement?.querySelector(".ticket-timeline");
return !!timeline && !!element.closest("main") &&
!!(timeline.compareDocumentPosition(element) & 4);
}));
const comment = composer.getByLabel("Comment", { exact: true });
const add = composer.getByRole("button", { name: "Add comment", exact: true });
const close = composer.getByRole("button", { name: "Comment and close", exact: true });
assert(await add.isDisabled());
assert(await close.isDisabled());
await comment.fill(" \n ");
assert(await add.isDisabled());
assert(await close.isDisabled());
const text = "The options are recorded; close this discussion without choosing one.";
await comment.fill(text);
assert(await add.isEnabled());
assert(await close.isEnabled());
const before = await detail(base, "T-1");
const response = page.waitForResponse((response) =>
response.url().endsWith("/case") && response.request().method() === "POST"
);
await close.click();
const request = (await response).request().postDataJSON();
assertEquals(request.explanation, text);
assertEquals(request.action, { action: "close", reason: "unknown" });
assertEquals(request.expected_state, "open");
assertEquals(request.expected_content_digest, before.content_digest);
assertEquals(request.expected_closure_operation_key, before.closure_operation_key);
assert(typeof request.operation_key === "string" && request.operation_key.length > 0);
await page.locator(".ticket-detail-header").getByText("Case: closed", {
exact: true,
}).waitFor();
const rendered = page.locator(".ticket-timeline").getByText(text, { exact: true });
await rendered.waitFor();
assertEquals(await rendered.count(), 1);
const closed = await detail(base, "T-1");
assertEquals(closed.state, "closed");
assertEquals(closed.current_closure.explanation, text);
assertEquals(closed.events.length, before.events.length + 1);
assertEquals(closed.events.at(-1).kind, "close");
assertEquals(closed.events.at(-1).body, text);
assertEquals(closed.events.at(-1).reason, text);
const requests = await (await fetch(`${base}/fixture-state`)).json();
assertEquals(requests.filter((row: { path: string }) => row.path.endsWith("/case")).length, 1);
assertEquals(requests.filter((row: { path: string }) => row.path.endsWith("/events")).length, 0);
assertEquals(await comment.inputValue(), "");
assert(await add.isDisabled());
const reopen = composer.getByRole("button", { name: "Reopen ticket", exact: true });
assert(await reopen.isDisabled());
await comment.fill("A retained comment after closure does not reopen the case.");
const eventResponse = page.waitForResponse((response) =>
response.url().endsWith("/events") && response.request().method() === "POST"
);
await add.click();
assertEquals((await eventResponse).request().postDataJSON(), {
role: "comment",
body: "A retained comment after closure does not reopen the case.",
});
await page.locator(".ticket-timeline").getByText(
"A retained comment after closure does not reopen the case.",
{ exact: true },
).waitFor();
assertEquals((await detail(base, "T-1")).state, "closed");
});
});
Deno.test("ambiguous comment-and-close retries exact case JSON without a duplicate timeline event", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets/T-1`);
const composer = page.locator(".ticket-thread-composer");
await composer.waitFor();
const text = "Retain this close comment exactly once, even if the response is lost.";
const before = await detail(base, "T-1");
await composer.getByLabel("Comment", { exact: true }).fill(text);
await control(base, { failure: "unknown_saved" });
await composer.getByRole("button", { name: "Comment and close", exact: true }).click();
await page.getByRole("alert").filter({ hasText: /Outcome unknown/ }).waitFor();
assertEquals(await composer.getByRole("button", {
name: "Refresh and reconcile case", exact: true,
}).count(), 1);
await composer.getByRole("button", { name: "Retry exact case decision", exact: true }).click();
await page.locator(".ticket-detail-header").getByText("Case: closed", {
exact: true,
}).waitFor();
const rendered = page.locator(".ticket-timeline").getByText(text, { exact: true });
await rendered.waitFor();
assertEquals(await rendered.count(), 1);
const requests = await (await fetch(`${base}/fixture-state`)).json();
const attempts = requests.filter((row: { path: string }) => row.path.endsWith("/case"));
assertEquals(attempts.length, 2);
assertEquals(attempts[0].body, attempts[1].body);
assertEquals(requests.filter((row: { path: string }) => row.path.endsWith("/events")).length, 0);
const closed = await detail(base, "T-1");
assertEquals(closed.events.length, before.events.length + 1);
assertEquals(closed.closure_history.length, before.closure_history.length + 1);
});
});
Deno.test("repository-free creation and canonical tag create/attach/rename/delete do not close or delegate the case", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets`);
await page.getByRole("button", { name: "Add Ticket", exact: true }).click();
await page.getByLabel("Title", { exact: true }).fill(
"Repository-free investigation",
);
await page.getByLabel("Body", { exact: true }).fill(
"Compare options and return without deciding",
);
const createResponse = page.waitForResponse((response) =>
response.url().endsWith("/tickets") &&
response.request().method() === "POST"
);
await page.getByRole("button", { name: "Create Ticket", exact: true })
.click();
const create = (await createResponse).request().postDataJSON();
assertEquals(create.targets, []);
assertEquals(create.workflow_state, "open");
assertEquals(create.title, "Repository-free investigation");
await page.getByRole("heading", {
name: "Repository-free investigation",
exact: true,
}).waitFor();
await page.getByText("No current Orchestrator assignment.", { exact: true })
.waitFor();
await page.getByText("Manage Workspace tags", { exact: true }).click();
await page.getByLabel("Tag name", { exact: true }).fill("Comparisons");
const tagCreate = page.waitForResponse((response) =>
response.url().endsWith("/ticket-tags") &&
response.request().method() === "POST"
);
await page.getByRole("button", { name: "Create tag", exact: true }).click();
const tagResult = await tagCreate;
const tag = await tagResult.json();
assertEquals(tagResult.request().postDataJSON().action, {
kind: "create",
name: "Comparisons",
});
await page.getByText("Edit classification", { exact: true }).click();
await page.getByRole("checkbox", { name: "Comparisons", exact: true })
.check();
await page.getByRole("checkbox", { name: "Investigation", exact: true })
.check();
const attach = page.waitForResponse((response) =>
response.url().endsWith("/tags") && response.request().method() === "PUT"
);
await page.getByRole("button", { name: "Save classification", exact: true })
.click();
const attachRequest = (await attach).request().postDataJSON();
assertEquals(attachRequest.expected_tag_ids, []);
assertEquals(
attachRequest.tag_ids.toSorted(),
[tag.id, "tag-investigation"].toSorted(),
);
await page.locator(".ticket-tag-chips").getByText("Comparisons", {
exact: true,
}).waitFor();
await page.getByRole("combobox", { name: "Existing tag", exact: true })
.selectOption(tag.id);
await page.getByLabel("Tag name", { exact: true }).fill("Options");
const renamed = page.waitForResponse((response) =>
response.url().endsWith("/ticket-tags") &&
response.request().method() === "POST"
);
await page.getByRole("button", { name: "Rename tag", exact: true }).click();
const rename = (await renamed).request().postDataJSON();
assertEquals(rename.action, {
kind: "rename",
tag_id: tag.id,
expected_last_operation_key: tag.last_operation_key,
name: "Options",
});
await page.locator(".ticket-tag-chips").getByText("Options", {
exact: true,
}).waitFor();
const deleted = page.waitForResponse((response) =>
response.url().endsWith("/ticket-tags") &&
response.request().method() === "POST"
);
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Delete tag", exact: true }).click();
assertEquals((await deleted).request().postDataJSON().action, {
kind: "delete",
tag_id: tag.id,
expected_last_operation_key: rename.operation_key,
});
await page.locator(".ticket-tag-chips").getByText("Options", {
exact: true,
}).waitFor({ state: "hidden" });
const ticket = await detail(base, "T-41");
assertEquals(ticket.state, "open");
assertEquals(ticket.assignment_tracking, []);
assertEquals(ticket.tags.map((tag: { id: string }) => tag.id), [
"tag-investigation",
]);
const requests = await (await fetch(`${base}/fixture-state`)).json();
assert(
!requests.some((row: { path: string }) => /assignment|\/case$/.test(row.path)),
);
});
});
Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permission denials never claim success", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.getByRole("button", {
name: "Request again (new assignment)",
exact: true,
}).waitFor();
await control(base, { failure: "unknown_saved" });
await page.getByRole("button", {
name: "Request again (new assignment)",
exact: true,
}).click();
await page.getByRole("alert").filter({ hasText: "Outcome unknown" })
.waitFor();
assertEquals(
await page.getByRole("status").filter({
hasText: "Assignment operation saved",
}).count(),
0,
);
const saved = (await detail(base, "T-1")).assignment_tracking[0].assignment_id;
const beforeRetry = await (await fetch(`${base}/fixture-state`)).json();
const first = beforeRetry.filter((item: { path: string }) =>
item.path.endsWith("/assignments/orchestrator")
);
assertEquals(first.length, 1);
await page.getByRole("button", {
name: "Retry exact operation",
exact: true,
}).click();
await page.getByRole("status").filter({
hasText: "Assignment operation saved",
}).waitFor();
const requests = await (await fetch(`${base}/fixture-state`)).json();
const assignmentRequests = requests.filter((item: { path: string }) =>
item.path.endsWith("/assignments/orchestrator")
);
assertEquals(assignmentRequests.length, 2);
assertEquals(assignmentRequests[0].body, assignmentRequests[1].body);
assertEquals(
(await detail(base, "T-1")).assignment_tracking[0].assignment_id,
saved,
);
for (const failure of ["stale", "denied", "unknown_unsaved"] as const) {
await control(base, {
reset: true,
failure: failure === "stale" ? "none" : failure,
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.getByRole("button", {
name: "Request again (new assignment)",
exact: true,
}).waitFor();
let expectedAssignment = "assignment-T-1";
if (failure === "stale") {
const replacement = await fetch(
`${base}/api/w/tickets/tickets/internal-T-1/assignments/orchestrator`,
{
method: "PUT",
body: JSON.stringify({
operation_id: "concurrent-replacement",
expected_assignment_id: "assignment-T-1",
principal: {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
},
}),
},
);
assert(replacement.ok);
expectedAssignment = (await replacement.json()).assignment.assignment_id;
}
await page.getByRole("button", {
name: "Request again (new assignment)",
exact: true,
}).click();
await page.getByRole("alert").filter({
hasText: failure === "stale"
? "Stale observation"
: failure === "denied"
? "Permission denied"
: "Outcome unknown",
}).waitFor();
assertEquals(
(await detail(base, "T-1")).assignment_tracking[0].assignment_id,
expectedAssignment,
);
assertEquals(
await page.getByRole("status").filter({
hasText: "Assignment operation saved",
}).count(),
0,
);
const attempts = await (await fetch(`${base}/fixture-state`)).json();
assertEquals(
attempts.filter((
r: { path: string; body: { operation_id?: string } },
) =>
r.path.endsWith("/assignments/orchestrator") &&
r.body.operation_id !== "concurrent-replacement"
).length,
1,
);
}
});
});
Deno.test("initial and later posts use the same editor and preserve authorship while title edit remains metadata-only", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.locator(".ticket-detail-header").waitFor();
const composer = page.getByRole("form", { name: "Add a comment" });
await composer.getByLabel("Comment", { exact: true }).fill("A later discussion post");
await composer.getByRole("button", { name: "Add comment", exact: true }).click();
await page.locator(".ticket-timeline").getByText("A later discussion post", { exact: true }).waitFor();
for (const name of ["Initial post", "Discussion post"]) {
const article = page.getByRole("article", { name, exact: true });
const originalTime = await article.locator("time").first().getAttribute("datetime");
const originalAuthor = await article.locator("header strong").textContent();
await article.getByRole("button", { name: "Edit post", exact: true }).click();
await article.getByLabel("Post body", { exact: true }).fill(`Revised ${name}`);
await article.getByRole("button", { name: "Save post", exact: true }).click();
await article.getByText(`Revised ${name}`, { exact: true }).waitFor();
assertEquals(await article.locator("time").first().getAttribute("datetime"), originalTime);
assertEquals(await article.locator("header strong").textContent(), originalAuthor);
assert((await article.locator(".ticket-post-edited").textContent())?.includes("by Rin"));
assertEquals(await page.locator(".ticket-timeline article").count(), 2);
}
const rail = page.getByRole("complementary", { name: "Ticket details" });
await rail.getByRole("button", { name: "Edit title", exact: true }).click();
assertEquals(await rail.getByLabel("Post body", { exact: true }).count(), 0);
await rail.getByLabel("Title", { exact: true }).fill("A clearer Ticket title");
await rail.getByRole("button", { name: "Save title", exact: true }).click();
await page.getByRole("heading", { name: "A clearer Ticket title", exact: true }).waitFor();
const requests = await (await fetch(`${base}/fixture-state`)).json();
const edits = requests.filter((row: { path: string; method?: string }) => row.method === "PATCH" && row.path.includes("/posts/"));
assertEquals(edits.length, 2);
for (const edit of edits) assertEquals(Object.keys(edit.body).sort(), ["body", "expected_body_digest"]);
const metadata = requests.findLast((row: { body?: { title?: string } }) => row.body?.title === "A clearer Ticket title");
assertEquals(metadata.body, { title: "A clearer Ticket title" });
assertEquals(await page.locator(".ticket-timeline article").count(), 2);
await page.close();
});
});
Deno.test("canonical thread renders long initial post and chronological multiline events once with content-aligned dividers", async () => {
await withFixture(async (browser, base) => {
const output = artifactPath("thread");
await Deno.mkdir(output, { recursive: true });
const paragraphs = Array.from({ length: 8 }, (_, index) =>
`Initial post paragraph ${index + 1}: ` + "The requested investigation preserves context before any discussion. ".repeat(5)
);
const events = ["Initial findings", "Follow-up decision", "Final investigation result"].map((heading, index) => ({
is_initial: false, edited_at: null, edited_by: null, body_digest: "d".repeat(64), body_truncated: false, editable: index !== 1,
sequence: index + 1,
kind: index === 1 ? "decision" : "comment",
heading,
author: `Thread author ${index + 1}`,
at: `2026-10-13T0${index + 1}:00:00Z`,
body: `${heading} first paragraph.\n\n${heading} second paragraph with enough detail to wrap on narrow screens.\n\n- ${heading} evidence one\n- ${heading} evidence two`,
reason: null,
from: null,
to: null,
references: [],
attributes: {},
event_ref: `thread-fixture-${index + 1}`,
}));
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme, reducedMotion: "reduce" });
await page.route("**/api/w/tickets/tickets/T-1", async (route) => {
const response = await route.fetch();
const ticket = await response.json();
await route.fulfill({ response, json: {
...ticket,
body: "Compatibility body must not render",
initial_post: { ...ticket.initial_post, body: paragraphs.join("\n\n"), at: "2026-10-12T12:00:00Z" },
created_at: "2026-10-12T12:00:00Z",
updated_at: events.at(-1)!.at,
events,
event_count: events.length,
} });
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.locator(".ticket-detail-header").waitFor();
await assertThreadLayout(page);
const timeline = page.locator(".ticket-timeline");
assertEquals(await timeline.locator("article header strong").allTextContents(), ["Mika", ...events.map((event) => event.author)]);
const expectedDates = ["2026-10-12T12:00:00Z", ...events.map((event) => event.at)];
assertEquals(await timeline.locator("article header time").allTextContents(), await page.evaluate((dates) => dates.map((date) => new Date(date).toLocaleString()), expectedDates));
for (const paragraph of paragraphs) assertEquals(await timeline.getByText(paragraph, { exact: true }).count(), 1);
for (const event of events) {
const article = timeline.locator(".ticket-thread-event").filter({ has: page.locator("header strong", { hasText: event.author }) });
assertEquals(await article.count(), 1);
assertEquals(await article.getByText(`${event.heading} first paragraph.`, { exact: true }).count(), 1);
assertEquals(await article.getByText(`${event.heading} second paragraph with enough detail to wrap on narrow screens.`, { exact: true }).count(), 1);
assertEquals(await article.locator("li").count(), 2);
}
await assertThreadDividers(page);
assertEquals(await page.evaluate("document.documentElement.scrollWidth > innerWidth"), false);
await page.screenshot({ path: `${output}/${colorScheme}-${width}-full.png`, fullPage: true });
await page.locator(".ticket-thread-event").first().scrollIntoViewIfNeeded();
await page.screenshot({ path: `${output}/${colorScheme}-${width}-events.png` });
await page.locator(".ticket-thread-composer").scrollIntoViewIfNeeded();
await page.screenshot({ path: `${output}/${colorScheme}-${width}-composer.png` });
await timeline.locator("article").first().getByRole("button", { name: "Edit post", exact: true }).click();
const description = timeline.locator(".ticket-thread-event[data-initial=true]");
await description.locator(".ticket-editor").waitFor();
assertEquals(await description.getByLabel("Post body", { exact: true }).inputValue(), paragraphs.join("\n\n"));
assertEquals(await page.locator("aside .ticket-editor").count(), 0);
await page.getByRole("button", { name: "Cancel post edit", exact: true }).click();
await description.getByText(paragraphs[0], { exact: true }).waitFor();
await page.close();
}
}
});
});
Deno.test("Repository summary stays visible while edits remain explicit and refresh after saving", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
const wire = await detail(base, "T-1");
wire.targets = [{ repository_key: "main", ref_selector: null, access: "read_only" }];
const writes: unknown[] = [];
await page.route("**/api/w/tickets/tickets/*", async (route) => {
const reference = decodeURIComponent(new URL(route.request().url()).pathname.split("/").at(-1)!);
if (reference !== "T-1" && reference !== wire.id) return await route.continue();
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON();
writes.push(body);
wire.targets = body.target.action === "clear" ? [] : body.target.targets;
}
await route.fulfill({ status: 200, json: wire });
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
const resources = page.getByRole("region", { name: "Repository resources", exact: true });
await resources.getByText("Ref not specified", { exact: true }).waitFor();
const summary = resources.locator(".ticket-resource-editor > summary");
await summary.focus();
await summary.press("Enter");
await resources.getByLabel("Ref selector", { exact: true }).fill("feature/sidebar");
await resources.getByRole("combobox", { name: /^Access/ }).selectOption("read_write");
assertEquals(writes, []);
assertEquals(await resources.locator(".ticket-resource-summary code").textContent(), "Ref not specified");
await resources.getByRole("button", { name: "Save targets", exact: true }).click();
await resources.locator(".ticket-resource-summary code").filter({ hasText: "feature/sidebar" }).waitFor();
assertEquals(writes, [{ target: { action: "set", targets: [{ repository_key: "main", ref_selector: "feature/sidebar", access: "read_write" }] } }]);
assertEquals(await resources.locator(".ticket-resource-access").textContent(), "Read/write");
await resources.getByRole("button", { name: "Remove target", exact: true }).click();
await resources.getByRole("button", { name: "Save targets", exact: true }).click();
await resources.locator(".ticket-resource-summary").getByText("No repository targets.", { exact: true }).waitFor();
assertEquals(writes[1], { target: { action: "clear" } });
});
});
Deno.test("Ticket metadata stays in the rail with flat links, wrapping labels and keyboard navigation", async () => {
await withFixture(async (browser, base) => {
const output = artifactPath("metadata");
await Deno.mkdir(output, { recursive: true });
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme });
await page.route("**/api/w/tickets/tickets/T-1*", async (route) => {
const response = await route.fetch();
const body = await response.json();
if (body.resource_key === "T-1" && body.relations) {
body.tags[0].name = "Classification-with-a-long-name-".repeat(4);
body.targets = [
{ repository_key: "Repository-with-a-long-name-".repeat(4), ref_selector: "feature/long-selector-".repeat(6), access: "read_write" },
{ repository_key: "docs", ref_selector: null, access: "read_only" },
];
body.relations.outgoing = [{ at: "2026-01-01T00:00:00Z", author: "fixture-owner", kind: "related", target: "internal-T-2", target_resource_key: "T-2", ticket_id: body.id, note: "Related investigation with a long note. ".repeat(5) }];
}
await route.fulfill({ response, json: body });
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
const rail = page.getByRole("complementary", { name: "Ticket details" });
const link = rail.getByRole("link", { name: /T-2/ });
await link.waitFor();
assertEquals(await link.getAttribute("href"), "/w/tickets/tickets/T-2");
const resources = rail.getByRole("region", { name: "Repository resources", exact: true });
assertEquals(await resources.locator(".ticket-resource-summary strong").allTextContents(), ["Repository-with-a-long-name-".repeat(4), "docs"]);
assertEquals(await resources.locator(".ticket-resource-summary code").allTextContents(), ["feature/long-selector-".repeat(6), "Ref not specified"]);
assertEquals(await resources.locator(".ticket-resource-access").allTextContents(), ["Read/write", "Read only"]);
assert(await resources.locator(".ticket-resource-summary").evaluate((element) => element.scrollWidth <= element.clientWidth));
assertEquals(await resources.locator(".ticket-resource-editor").getAttribute("open"), null);
await resources.screenshot({ path: `${output}/resources-${colorScheme}-${width}.png` });
assertEquals(await page.locator(".ticket-detail-main .ticket-tags, .ticket-detail-main .ticket-relations").count(), 0);
assertEquals(await page.evaluate("document.documentElement.scrollWidth > innerWidth"), false);
assert(await rail.locator(".ticket-tag-chip, .ticket-relations-list a").evaluateAll((elements) => elements.every((element) => {
const style = element.ownerDocument.defaultView!.getComputedStyle(element);
return style.borderTopWidth === "0px" && style.borderRadius === "0px" && style.backgroundColor === "rgba(0, 0, 0, 0)";
})));
await link.focus();
assert(await link.evaluate((element) => element === element.ownerDocument.activeElement));
assert(await link.evaluate((element) => element.ownerDocument.defaultView!.getComputedStyle(element).outlineStyle !== "none"));
await page.screenshot({ path: `${output}/${colorScheme}-${width}.png` });
await link.press("Enter");
await page.waitForURL(/\/tickets\/T-2/);
await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor();
await page.close();
}
}
});
});
Deno.test("case and handling matrix renders without overflow in both themes and empty/error/unknown states stay distinct", async () => {
await withFixture(async (browser, base) => {
const output = artifactPath("matrix");
await Deno.mkdir(output, { recursive: true });
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
for (
const [workspace, route, ready] of [
["tickets", "tickets?q=", ".ticket-list-row"],
["tickets", "orchestrator", ".orchestrator-assignment"],
["tickets-long", "tickets?q=", ".ticket-list-row"],
["tickets-empty", "orchestrator", ".orchestrator-assignments"],
["tickets-unknown", "orchestrator", ".orchestrator-assignment"],
["tickets-error", "tickets", "[role=alert]"],
["tickets", "tickets/T-1", ".ticket-detail-header"],
["tickets", "tickets/T-2", ".ticket-detail-header"],
["tickets", "tickets/T-3", ".ticket-detail-header"],
["tickets", "tickets/T-4", ".ticket-detail-header"],
[
"tickets-readonly",
"tickets/T-2",
".ticket-detail-header",
],
[
"tickets-running",
"tickets/T-2",
".ticket-detail-header",
],
]
) {
await page.goto(`${base}/w/${workspace}/${route}`);
await page.locator(ready).first().waitFor();
assertEquals(
await page.evaluate(
"document.documentElement.scrollWidth > innerWidth",
),
false,
);
if (route.startsWith("tickets/")) {
await assertThreadLayout(page);
await assertThreadDividers(page);
const rail = page.getByRole("complementary", { name: "Ticket details" });
assertEquals(await rail.locator(".ticket-tags").count(), 1);
assertEquals(await rail.getByRole("heading", { name: "Relations", exact: true }).count(), 1);
assertEquals(await page.locator(".ticket-detail-main .ticket-tags, .ticket-detail-main .ticket-relations").count(), 0);
const sections = page.locator(".ticket-detail-page .ticket-detail-section, .ticket-detail-page .ticket-control-card");
assert(await sections.evaluateAll((elements) => elements.every((element) => {
const style = element.ownerDocument.defaultView!.getComputedStyle(element);
return [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth, style.borderRadius].every((value) => value === "0px") && style.backgroundColor === "rgba(0, 0, 0, 0)";
})));
const mainBounds = await page.locator(".ticket-detail-main").boundingBox();
const railBounds = await rail.boundingBox();
assert(mainBounds && railBounds);
if (width === 1440) assert(railBounds.x >= mainBounds.x + mainBounds.width);
else assert(railBounds.y >= mainBounds.y + mainBounds.height);
}
if (workspace === "tickets-empty") {
assert(
await page.getByText(
"No current assignments match these filters.",
{ exact: true },
).isVisible(),
);
}
if (workspace === "tickets-error") {
assertEquals(
await page.getByRole("button", { name: "Retry", exact: true })
.count(),
1,
);
assertEquals(
await page.getByText("No tickets match this query.", { exact: true }).count(),
0,
);
}
if (workspace === "tickets-unknown") {
assertEquals(
await page.getByText("Handling: Unknown (not reported)", {
exact: true,
}).count(),
4,
);
}
if (["tickets/T-2", "tickets/T-3"].includes(route)) {
await page.locator("aside .ticket-worker-observation > summary").click();
await page.locator("aside .ticket-worker-observation strong").waitFor();
}
if (route === "tickets/T-2") {
assert(
await page.getByText("Self-reported handling: waiting", {
exact: true,
}).isVisible(),
);
assertEquals(
await page.locator(".ticket-worker-observation strong")
.textContent(),
"Stopped",
);
if (workspace === "tickets-readonly") {
assertEquals(
await page.getByRole("button", {
name: "Replace assignment",
exact: true,
}).count(),
0,
);
}
if (workspace === "tickets-running") {
await page.locator("aside .ticket-orchestrator-worker > summary").click();
await page.locator("aside .ticket-orchestrator-worker strong").waitFor();
assert(
await page.locator(".ticket-orchestrator-worker").getByText(
"Running",
{ exact: true },
).isVisible(),
);
}
}
if (route === "tickets/T-3") {
assert(
await page.getByText("Self-reported handling: inprogress", {
exact: true,
}).isVisible(),
);
assertEquals(
await page.locator(".ticket-worker-observation strong")
.textContent(),
"Running",
);
assert(
await page.getByText("main: open", { exact: true }).isVisible(),
);
}
await page.screenshot({
path: `${output}/${colorScheme}-${width}-${workspace}-${
route.replaceAll("/", "-")
}.png`,
fullPage: true,
});
if (workspace === "tickets" && ["tickets/T-1", "tickets/T-4"].includes(route)) {
const composer = page.locator(".ticket-thread-composer");
await composer.waitFor();
assertEquals(await page.locator("aside .ticket-thread-composer").count(), 0);
await composer.scrollIntoViewIfNeeded();
await page.screenshot({
path: `${output}/${colorScheme}-${width}-${route.replaceAll("/", "-")}-footer.png`,
});
}
}
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
});
});