feat: replace Ticket kanban with query-based list

This commit is contained in:
2026-10-13 19:08:51 +09:00
parent 12d1588c05
commit a82e70047a
13 changed files with 1137 additions and 632 deletions
+286 -115
View File
@@ -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,
);
}
+74 -23
View File
@@ -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": [
+74 -23
View File
@@ -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": [