feat: replace Ticket kanban with query-based list
This commit is contained in:
@@ -8,8 +8,7 @@ import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
async function withFixture(
|
||||
run: (browser: Browser, baseUrl: string) => Promise<void>,
|
||||
) {
|
||||
const scenarioPath =
|
||||
new URL("../scenarios/tickets.json", import.meta.url).pathname;
|
||||
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;
|
||||
@@ -17,9 +16,8 @@ async function withFixture(
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
scenario.processes![0].args![4] = String(port);
|
||||
scenario.processes![0].readyUrl = `${baseUrl}/health`;
|
||||
const logs =
|
||||
new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url)
|
||||
.pathname;
|
||||
const logs = new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url)
|
||||
.pathname;
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
scenarioPath,
|
||||
@@ -57,65 +55,58 @@ function queryResponse(
|
||||
);
|
||||
}
|
||||
|
||||
Deno.test("Ticket filters and Orchestrator handling query the server beyond loaded pages and preserve filters on pagination", async () => {
|
||||
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("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-lane[data-state="open"] .ticket-card').first()
|
||||
.waitFor();
|
||||
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.locator('.ticket-lane[data-state="open"] .ticket-card')
|
||||
.count(),
|
||||
30,
|
||||
await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(),
|
||||
"state:open",
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("checkbox", { name: "UI", exact: true }).check();
|
||||
await page.getByRole("combobox", {
|
||||
name: "Assigned principal",
|
||||
exact: true,
|
||||
}).selectOption("orchestrator");
|
||||
const filtered = queryResponse(
|
||||
page,
|
||||
(body) =>
|
||||
(body.tag_ids as string[])?.includes("tag-ui") &&
|
||||
(body.assigned_principal as { kind: string })?.kind ===
|
||||
"workspace_agent" &&
|
||||
(body.states as string[]).includes("open"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
const response = await filtered;
|
||||
assertEquals(response.request().postDataJSON(), {
|
||||
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",
|
||||
},
|
||||
assigned_principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
handling_status: null,
|
||||
sort: "updated_desc",
|
||||
limit: 30,
|
||||
cursor: null,
|
||||
});
|
||||
await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.locator('.ticket-lane[data-state="open"] .ticket-card')
|
||||
.count(),
|
||||
2,
|
||||
);
|
||||
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?case=open&principal=orchestrator&tag=tag-investigation`,
|
||||
`${base}/w/tickets-pages/tickets?q=${
|
||||
encodeURIComponent("state:open assignee:orchestrator tag:tag-investigation")
|
||||
}`,
|
||||
);
|
||||
await page.getByRole("button", { name: "Load more open tickets" })
|
||||
.waitFor();
|
||||
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 open tickets" }).click();
|
||||
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"]);
|
||||
@@ -123,9 +114,22 @@ Deno.test("Ticket filters and Orchestrator handling query the server beyond load
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
});
|
||||
await page.locator(".ticket-card-id").filter({ hasText: /^T-40$/ })
|
||||
.waitFor();
|
||||
assertEquals(await page.locator(".ticket-card").count(), 39);
|
||||
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(
|
||||
@@ -139,19 +143,15 @@ Deno.test("Ticket filters and Orchestrator handling query the server beyond load
|
||||
agent_key: "workspace-orchestrator",
|
||||
});
|
||||
assertEquals(handlingRequest.states, []);
|
||||
await page.locator(".orchestrator-assignment").filter({ hasText: "T-40" })
|
||||
.waitFor();
|
||||
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");
|
||||
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 page.getByRole("button", { name: "Apply filters", exact: true }).click();
|
||||
await waiting;
|
||||
await page.locator(".orchestrator-assignment").filter({
|
||||
hasText: "Awaiting the user's policy choice",
|
||||
@@ -161,55 +161,233 @@ Deno.test("Ticket filters and Orchestrator handling query the server beyond load
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("current close-reason filtering sends canonical known/custom reasons and never closes cases", async () => {
|
||||
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.getByText("Close reason filter", { exact: true }).click();
|
||||
await page.getByRole("combobox", {
|
||||
name: "Current close reason",
|
||||
exact: true,
|
||||
}).selectOption("completed");
|
||||
await page.getByRole("combobox", { name: "Case", exact: true })
|
||||
.selectOption("closed");
|
||||
const response = queryResponse(
|
||||
page,
|
||||
(body) => body.closure_reason === "completed",
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
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 page.locator('.ticket-lane[data-state="open"]').waitFor({
|
||||
state: "hidden",
|
||||
});
|
||||
assertEquals(await page.locator(".ticket-card").count(), 1);
|
||||
assertEquals(await page.locator(".ticket-card-id").textContent(), "T-4");
|
||||
await page.getByRole("combobox", {
|
||||
name: "Current close reason",
|
||||
exact: true,
|
||||
}).selectOption("other");
|
||||
await page.getByLabel("Close reason text", { exact: true }).fill(
|
||||
"Superseded plan",
|
||||
);
|
||||
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",
|
||||
(body) => (body.closure_reason as { other: string })?.other === "Superseded plan",
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
await search(page, 'reason:"Superseded plan"');
|
||||
assertEquals((await custom).request().postDataJSON().closure_reason, {
|
||||
other: "Superseded plan",
|
||||
});
|
||||
await page.getByText("No tickets", { exact: true }).waitFor();
|
||||
assertEquals(
|
||||
(await detail(base, "T-4")).current_closure.reason,
|
||||
"completed",
|
||||
);
|
||||
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")),
|
||||
);
|
||||
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 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,9 +545,7 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an
|
||||
);
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
assert(
|
||||
!requests.some((r: { path: string }) =>
|
||||
/\/(queue|ready|state)$/.test(r.path)
|
||||
),
|
||||
!requests.some((r: { path: string }) => /\/(queue|ready|state)$/.test(r.path)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -473,9 +649,7 @@ Deno.test("repository-free creation and canonical tag create/attach/rename/delet
|
||||
]);
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
assert(
|
||||
!requests.some((row: { path: string }) =>
|
||||
/assignment|\/case$/.test(row.path)
|
||||
),
|
||||
!requests.some((row: { path: string }) => /assignment|\/case$/.test(row.path)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -501,8 +675,7 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
const saved =
|
||||
(await detail(base, "T-1")).assignment_tracking[0].assignment_id;
|
||||
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")
|
||||
@@ -552,8 +725,7 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi
|
||||
},
|
||||
);
|
||||
assert(replacement.ok);
|
||||
expectedAssignment =
|
||||
(await replacement.json()).assignment.assignment_id;
|
||||
expectedAssignment = (await replacement.json()).assignment.assignment_id;
|
||||
}
|
||||
await page.getByRole("button", {
|
||||
name: "Request again (new assignment)",
|
||||
@@ -592,9 +764,8 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi
|
||||
|
||||
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 =
|
||||
new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url)
|
||||
.pathname;
|
||||
const output = new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url)
|
||||
.pathname;
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
@@ -609,12 +780,12 @@ Deno.test("case and handling matrix renders without overflow in both themes and
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
for (
|
||||
const [workspace, route, ready] of [
|
||||
["tickets", "tickets", ".ticket-card"],
|
||||
["tickets", "tickets?q=", ".ticket-list-row"],
|
||||
["tickets", "orchestrator", ".orchestrator-assignment"],
|
||||
["tickets-long", "tickets", ".ticket-card"],
|
||||
["tickets-long", "tickets?q=", ".ticket-list-row"],
|
||||
["tickets-empty", "orchestrator", ".orchestrator-assignments"],
|
||||
["tickets-unknown", "orchestrator", ".orchestrator-assignment"],
|
||||
["tickets-error", "tickets", ".ticket-lane-page-error"],
|
||||
["tickets-error", "tickets", "[role=alert]"],
|
||||
["tickets", "tickets/T-1", ".ticket-detail-header"],
|
||||
["tickets", "tickets/T-2", ".ticket-worker-observation strong"],
|
||||
["tickets", "tickets/T-3", ".ticket-worker-observation strong"],
|
||||
@@ -651,10 +822,10 @@ Deno.test("case and handling matrix renders without overflow in both themes and
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Retry", exact: true })
|
||||
.count(),
|
||||
2,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByText("No tickets", { exact: true }).count(),
|
||||
await page.getByText("No tickets match this query.", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,11 +141,11 @@
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Server-filter open and closed cases by canonical tags and current principal",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"goal": "Search one server-ordered Ticket list with typed qualifiers; create without a repository",
|
||||
"dataState": "Default open cases; closed reason search and optional creation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
"selector": ".ticket-list-row"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -169,30 +169,27 @@
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "reason-filter",
|
||||
"label": "Current close reason versus historical reason search",
|
||||
"id": "reason-query",
|
||||
"label": "Closed cases with a current completed reason",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-panel-controls button:has-text(\"Cancel\")"
|
||||
},
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "input[aria-label=\"Search tickets\"]",
|
||||
"value": "state:closed reason:completed"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-reason-filter summary"
|
||||
"selector": "form[aria-label=\"Search tickets\"] button:has-text(\"Search\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
@@ -265,13 +262,13 @@
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"path": "/w/tickets-long/tickets",
|
||||
"path": "/w/tickets-long/tickets?q=",
|
||||
"label": "Long list titles and handling",
|
||||
"goal": "Long list titles and handling",
|
||||
"dataState": "Long titles and multiline results",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
"selector": ".ticket-list-row"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -285,7 +282,7 @@
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"selector": ".ticket-list-rows > li:last-child .ticket-list-row",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
@@ -316,10 +313,10 @@
|
||||
"path": "/w/tickets-error/tickets",
|
||||
"label": "Query failure recovery",
|
||||
"goal": "Query failure recovery",
|
||||
"dataState": "503 query, scoped errors and retry",
|
||||
"dataState": "503 query, single list error and retry",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-lane-page-error"
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -364,6 +361,60 @@
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mixed",
|
||||
"path": "/w/tickets/tickets?q=",
|
||||
"label": "Explicit empty query includes both case states",
|
||||
"goal": "Scan open and closed cases in one server-ordered list",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Mixed-state flat list",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list-empty",
|
||||
"path": "/w/tickets-empty/tickets?q=",
|
||||
"label": "No matching Tickets",
|
||||
"goal": "Distinguish an empty list from a failed read",
|
||||
"dataState": "Empty response",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list :text-is(\"No tickets match this query.\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Empty Ticket list",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "invalid-query",
|
||||
"path": "/w/tickets/tickets?q=state%3Aclose",
|
||||
"label": "Invalid recognized state qualifier",
|
||||
"goal": "Correct a query without issuing a broad fallback read",
|
||||
"dataState": "Rejected close alias",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Query validation error",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
|
||||
@@ -141,11 +141,11 @@
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Server-filter open and closed cases by canonical tags and current principal",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"goal": "Search one server-ordered Ticket list with typed qualifiers; create without a repository",
|
||||
"dataState": "Default open cases; closed reason search and optional creation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
"selector": ".ticket-list-row"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -169,30 +169,27 @@
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "reason-filter",
|
||||
"label": "Current close reason versus historical reason search",
|
||||
"id": "reason-query",
|
||||
"label": "Closed cases with a current completed reason",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-panel-controls button:has-text(\"Cancel\")"
|
||||
},
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "input[aria-label=\"Search tickets\"]",
|
||||
"value": "state:closed reason:completed"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-reason-filter summary"
|
||||
"selector": "form[aria-label=\"Search tickets\"] button:has-text(\"Search\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
@@ -265,13 +262,13 @@
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"path": "/w/tickets-long/tickets",
|
||||
"path": "/w/tickets-long/tickets?q=",
|
||||
"label": "Long list titles and handling",
|
||||
"goal": "Long list titles and handling",
|
||||
"dataState": "Long titles and multiline results",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
"selector": ".ticket-list-row"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -285,7 +282,7 @@
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"selector": ".ticket-list-rows > li:last-child .ticket-list-row",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
@@ -316,10 +313,10 @@
|
||||
"path": "/w/tickets-error/tickets",
|
||||
"label": "Query failure recovery",
|
||||
"goal": "Query failure recovery",
|
||||
"dataState": "503 query, scoped errors and retry",
|
||||
"dataState": "503 query, single list error and retry",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-lane-page-error"
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -364,6 +361,60 @@
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mixed",
|
||||
"path": "/w/tickets/tickets?q=",
|
||||
"label": "Explicit empty query includes both case states",
|
||||
"goal": "Scan open and closed cases in one server-ordered list",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Mixed-state flat list",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list-empty",
|
||||
"path": "/w/tickets-empty/tickets?q=",
|
||||
"label": "No matching Tickets",
|
||||
"goal": "Distinguish an empty list from a failed read",
|
||||
"dataState": "Empty response",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list :text-is(\"No tickets match this query.\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Empty Ticket list",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "invalid-query",
|
||||
"path": "/w/tickets/tickets?q=state%3Aclose",
|
||||
"label": "Invalid recognized state qualifier",
|
||||
"goal": "Correct a query without issuing a broad fallback read",
|
||||
"dataState": "Rejected close alias",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Query validation error",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
|
||||
Reference in New Issue
Block a user