feat(ticket): align Web actions and instructions with intent-led work
This commit is contained in:
@@ -418,7 +418,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
display_name: "Embedded Runtime",
|
||||
built_in: true,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: false,
|
||||
supports_workdir_attachments: false,
|
||||
status: "active",
|
||||
diagnostics: [],
|
||||
}],
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
// Synthetic API fixture serving the production static UI; no Worker/Git execution.
|
||||
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||
import {
|
||||
fixtureDetail,
|
||||
fixturePage,
|
||||
} from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts";
|
||||
import type { TicketDetail } from "../../../web/workspace/src/lib/generated/ticket-api.ts";
|
||||
import { emptyLaunchOptions } from "../../../web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts";
|
||||
const port = Number(Deno.args[0]);
|
||||
const root = Deno.args[1];
|
||||
const legacy = Deno.args.includes("--legacy");
|
||||
const tickets = new Map<string, TicketDetail>();
|
||||
const bodies: unknown[] = [];
|
||||
function ticket(key: string) {
|
||||
if (!tickets.has(key)) {
|
||||
const value: TicketDetail = fixtureDetail(key);
|
||||
value.state = "planning";
|
||||
value.title = key === "T-2"
|
||||
? "Read-only investigation"
|
||||
: key === "T-3"
|
||||
? "Independent review before merge"
|
||||
: "Compare options without deciding";
|
||||
value.body = key === "T-2"
|
||||
? "Investigate using read-only sources; review is not required."
|
||||
: key === "T-3"
|
||||
? "Implement the change, then independent review before merge."
|
||||
: "Return options to the user. Do not draw a conclusion.";
|
||||
value.action_eligibility.can_queue = true;
|
||||
value.action_eligibility.queue_tickets = [key];
|
||||
value.action_eligibility.can_start_manual_worker = true;
|
||||
if (key === "T-2") {
|
||||
value.targets = [{ repository_key: "main", ref_selector: "develop", access: "read_only" }];
|
||||
}
|
||||
if (key === "T-3") {
|
||||
value.merge_requests = [{
|
||||
merge_request_id: "mr-1",
|
||||
repository_key: "main",
|
||||
state: "open",
|
||||
selector_from: "work/fixture",
|
||||
selector_to: "develop",
|
||||
review_status: "none",
|
||||
source_ref_observation: { status: "unavailable", code: "provider_unavailable" },
|
||||
current_subject_ref: null,
|
||||
review_subject_ref: null,
|
||||
review_excerpt: null,
|
||||
integration_evidence_error: null,
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
}];
|
||||
}
|
||||
tickets.set(key, value);
|
||||
}
|
||||
const value = structuredClone(tickets.get(key)!);
|
||||
if (legacy) {
|
||||
const wire = value as unknown as Record<string, unknown>;
|
||||
wire.current_coder = wire.current_worker;
|
||||
delete wire.current_worker;
|
||||
const actions = wire.action_eligibility as Record<string, unknown>;
|
||||
actions.can_start_manual_coder = actions.can_start_manual_worker;
|
||||
delete actions.can_start_manual_worker;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
if (url.pathname === "/fixture-state") return Response.json(bodies);
|
||||
if (url.pathname === "/api/workspaces") {
|
||||
return Response.json([{
|
||||
workspace_id: "tickets",
|
||||
display_name: "Shared investigation",
|
||||
owner_account_id: "owner",
|
||||
state: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
}]);
|
||||
}
|
||||
const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/);
|
||||
if (match) {
|
||||
const [, id, path] = match;
|
||||
if (path === "/workspace") {
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
display_name: "Shared investigation",
|
||||
record_authority: "fixture",
|
||||
schema_version: 1,
|
||||
auth: {
|
||||
Passkey: {
|
||||
rp_id: "127.0.0.1",
|
||||
origin: url.origin,
|
||||
public_base_url: url.origin,
|
||||
cookie_name: "fixture",
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
manage_repositories: false,
|
||||
manage_secrets: false,
|
||||
manage_runtimes: false,
|
||||
delete_workspace: false,
|
||||
},
|
||||
extension_points: {
|
||||
store: "fixture",
|
||||
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/repositories") {
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
items: [{
|
||||
repository_key: "main",
|
||||
default_selector: "develop",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
source: { kind: "local_path", uri: "/fixture/main" },
|
||||
source_revision: 1,
|
||||
source_fingerprint: "fixture",
|
||||
observed_status: "ready",
|
||||
record_authority: "fixture",
|
||||
}],
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
if (path === "/orchestrator") {
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
online: false,
|
||||
disposition: "offline",
|
||||
worker: null,
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
if (path === "/workers/launch-options") {
|
||||
const options = emptyLaunchOptions(id);
|
||||
options.default_profile = "builtin:ticket-worker";
|
||||
options.profiles = [{
|
||||
id: "builtin:ticket-worker",
|
||||
label: "Ticket Worker",
|
||||
description: "Intent-led work",
|
||||
feature_connections: { subjektiv: false },
|
||||
}, {
|
||||
id: "builtin:coder",
|
||||
label: "Coder",
|
||||
description: "Code recipe",
|
||||
feature_connections: { subjektiv: false },
|
||||
}];
|
||||
options.working_directories = [{
|
||||
working_directory_id: "read-only-sources",
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
grant_id: "fixture-grant",
|
||||
grant_permissions: { read: true, write: false, command: false },
|
||||
},
|
||||
materializer_kind: "client_hosted_external",
|
||||
status: "active",
|
||||
cleanliness: "unknown",
|
||||
}];
|
||||
if (legacy) {
|
||||
for (const runtime of options.runtimes) {
|
||||
const wire = runtime as unknown as Record<string, unknown>;
|
||||
wire.working_directory_required = wire.supports_workdir_attachments;
|
||||
delete wire.supports_workdir_attachments;
|
||||
}
|
||||
}
|
||||
return Response.json(options);
|
||||
}
|
||||
if (path === "/workers") {
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
items: [],
|
||||
diagnostics: [],
|
||||
limit: 100,
|
||||
source: "fixture",
|
||||
});
|
||||
}
|
||||
if (path === "/working-directories") {
|
||||
return Response.json({ workspace_id: id, items: [], diagnostics: [] });
|
||||
}
|
||||
if (path === "/protocol/ws" && request.headers.get("upgrade") === "websocket") {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
socket.onmessage = (event) => {
|
||||
const frame = JSON.parse(String(event.data));
|
||||
if (frame?.message?.method === "subscribe_events") {
|
||||
socket.send(JSON.stringify({
|
||||
protocol_version: 2,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
payload: {
|
||||
request_id: frame.message.params.request_id,
|
||||
subscription_id: "fixture-workers",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot: { topic: "workers", data: { workers: [] } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
return response;
|
||||
}
|
||||
if (path === "/tickets" && request.method === "POST") {
|
||||
const body = await request.json();
|
||||
bodies.push({ path, body });
|
||||
ticket("T-4");
|
||||
const created = tickets.get("T-4")!;
|
||||
created.title = body.title;
|
||||
created.body = body.body;
|
||||
created.targets = body.targets ?? [];
|
||||
return Response.json({
|
||||
id: created.id,
|
||||
resource_key: "T-4",
|
||||
slug: "fixture",
|
||||
status: "Open",
|
||||
});
|
||||
}
|
||||
if (path === "/tickets") {
|
||||
const items = ["T-1", "T-2", "T-3"].map(ticket).filter((t) =>
|
||||
!url.searchParams.has("states") ||
|
||||
url.searchParams.get("states")!.split(",").includes(t.state)
|
||||
);
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
items: items.map((t) => ({
|
||||
id: t.id,
|
||||
resource_key: t.resource_key,
|
||||
title: t.title,
|
||||
state: t.state,
|
||||
priority: t.priority,
|
||||
updated_at: t.updated_at,
|
||||
queued_at: t.queued_at,
|
||||
queued_by: t.queued_by,
|
||||
record_source: "fixture",
|
||||
workspace_action_priority: "background",
|
||||
})),
|
||||
invalid_records: [],
|
||||
record_authority: "fixture",
|
||||
limit: 30,
|
||||
page: fixturePage(items.length),
|
||||
});
|
||||
}
|
||||
const tm = path.match(/^\/tickets\/([^/]+)(.*)$/);
|
||||
if (tm) {
|
||||
const key = decodeURIComponent(tm[1]).match(/T-\d+/)?.[0] ?? "T-1";
|
||||
if (request.method !== "GET") {
|
||||
const body = await request.json();
|
||||
bodies.push({ path, body });
|
||||
if (url.searchParams.get("failure")) {
|
||||
return Response.json({ message: "Outcome unknown; refresh before retry" }, {
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!);
|
||||
if (body.expected_item_revision && body.expected_item_revision !== stored.item_revision) {
|
||||
return Response.json({ message: "Ticket revision conflict; refresh" }, { status: 409 });
|
||||
}
|
||||
if (tm[2] === "/state") {
|
||||
stored.state = body.state;
|
||||
stored.events.push({
|
||||
sequence: stored.events.length,
|
||||
kind: "state_changed",
|
||||
body: body.body,
|
||||
reason: body.reason,
|
||||
from: body.expected_state,
|
||||
to: body.state,
|
||||
references: [],
|
||||
attributes: {},
|
||||
event_ref: `event-${stored.events.length}`,
|
||||
});
|
||||
} else if (tm[2] === "/events") {
|
||||
stored.events.push({
|
||||
sequence: stored.events.length,
|
||||
kind: body.role,
|
||||
body: body.body,
|
||||
references: [],
|
||||
attributes: {},
|
||||
event_ref: `event-${stored.events.length}`,
|
||||
});
|
||||
} else if (tm[2] === "/close") {
|
||||
stored.state = "closed";
|
||||
stored.resolution = body.resolution;
|
||||
} else if (tm[2] === "/ready") stored.state = "ready";
|
||||
else if (tm[2] === "/queue") {
|
||||
stored.state = "queued";
|
||||
return Response.json({ requested_ticket: key, queued_tickets: [key] });
|
||||
} else if (request.method === "PATCH") {
|
||||
if (body.target) stored.targets = body.target.targets ?? [];
|
||||
if (body.title) stored.title = body.title;
|
||||
if (body.body) stored.body = body.body;
|
||||
}
|
||||
stored.item_revision += "x";
|
||||
stored.event_count = stored.events.length;
|
||||
}
|
||||
return Response.json(ticket(key));
|
||||
}
|
||||
return Response.json({ message: "Unavailable fixture resource" }, { status: 404 });
|
||||
}
|
||||
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
|
||||
if (relative.startsWith("..")) return new Response("not found", { status: 404 });
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".woff2": "font/woff2",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
let file = join(root, relative);
|
||||
try {
|
||||
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||
return new Response(await Deno.readFile(file), {
|
||||
headers: { "content-type": mime[extname(file)] ?? "application/octet-stream" },
|
||||
});
|
||||
} catch {
|
||||
return new Response(await Deno.readFile(join(root, "index.html")), {
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||
import { chromium } from "npm:playwright@1.59.1";
|
||||
import { loadScenario } from "../src/scenario.ts";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
|
||||
// Browser boundary over the production static build and synthetic loopback API.
|
||||
// No Backend, Worker, Repository, review or integration operation is executed.
|
||||
Deno.test("Ticket progress, comment results and reopen work without Git while real MR evidence stays separate", async () => {
|
||||
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();
|
||||
scenario.baseUrl = `http://127.0.0.1:${port}`;
|
||||
scenario.processes![0].args![4] = String(port);
|
||||
scenario.processes![0].readyUrl = `${scenario.baseUrl}/health`;
|
||||
const output = new URL("../../../target/web-ux/t720-browser/", import.meta.url).pathname;
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
scenarioPath,
|
||||
`${output}/logs`,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
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 unexpected: string[] = [];
|
||||
page.on("pageerror", (e) => unexpected.push(String(e)));
|
||||
await page.goto(`${scenario.baseUrl}/w/tickets/tickets`);
|
||||
const addTicket = page.getByRole("button", { name: "Add Ticket", exact: true });
|
||||
await addTicket.waitFor();
|
||||
const action = await addTicket.boundingBox();
|
||||
assert(action && action.x >= 0 && action.x + action.width <= width && action.y < 900);
|
||||
assertEquals(
|
||||
await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
false,
|
||||
);
|
||||
await addTicket.click();
|
||||
await page.locator(".ticket-create-form").waitFor();
|
||||
assertEquals(await page.locator(".ticket-target-row").count(), 0);
|
||||
assertEquals(
|
||||
await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
false,
|
||||
);
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`,
|
||||
);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
// Intent comes first at one-column widths, unlike the former controls-first layout.
|
||||
if (width < 1024) {
|
||||
const intent = await page.getByRole("heading", { name: "Intent", exact: true })
|
||||
.boundingBox();
|
||||
const progress = await page.getByRole("heading", { name: "Progress decision" })
|
||||
.boundingBox();
|
||||
assert(intent && progress && intent.y < progress.y);
|
||||
}
|
||||
const state = page.getByRole("combobox", { name: "State", exact: true });
|
||||
await state.waitFor();
|
||||
assertEquals(await state.locator("option").allTextContents(), [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
]);
|
||||
await state.selectOption("inprogress");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Investigating options directly",
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply state", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="inprogress"]').waitFor();
|
||||
await page.getByText("Record result or comment", { exact: true }).click();
|
||||
await page.getByLabel("Body", { exact: true }).fill(
|
||||
`Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`,
|
||||
);
|
||||
await page.getByRole("button", { name: "Append event" }).click();
|
||||
await page.getByText(
|
||||
`Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`,
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
await state.selectOption("done");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Requested comparison returned",
|
||||
);
|
||||
await page.getByLabel("Result, references, and remaining work (optional)").fill(
|
||||
"Result: timeline comment. [Reference](https://example.com/options). Remaining: user choice.",
|
||||
);
|
||||
await page.getByRole("button", { name: "Complete Ticket", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("heading", { name: "MR requirement evidence" }).count(),
|
||||
0,
|
||||
);
|
||||
assert(await page.getByText("No Merge Requests linked.", { exact: false }).isVisible());
|
||||
await state.selectOption("planning");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"User requests another comparison",
|
||||
);
|
||||
await page.getByRole("button", { name: "Reopen / apply state" }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="planning"]').waitFor();
|
||||
await page.getByRole("button", { name: "Request Orchestrator (queue)" }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="queued"]').waitFor();
|
||||
await page.locator(".ticket-close-card > summary").click();
|
||||
await page.getByLabel("Resolution").fill("Discussion returned; no code or MR required");
|
||||
await page.getByRole("button", { name: "Close ticket", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="closed"]').waitFor();
|
||||
await page.goto(`${scenario.baseUrl}/w/tickets/tickets/T-2-read-only-investigation`);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("combobox", { name: "Access", exact: true }).inputValue(),
|
||||
"read_only",
|
||||
);
|
||||
assert(await page.getByRole("button", { name: "Mark ready", exact: true }).isEnabled());
|
||||
await page.getByRole("button", { name: "Mark ready", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="ready"]').waitFor();
|
||||
assert(await page.getByRole("combobox", { name: "Repository", exact: true }).isEnabled());
|
||||
await page.getByRole("button", { name: "Save targets", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save targets", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/tickets/T-3-independent-review-before-merge`,
|
||||
);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
await state.selectOption("done");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Progress decision is separate from review",
|
||||
);
|
||||
await page.getByRole("button", { name: "Complete Ticket" }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor();
|
||||
assert(await page.getByText("main: open", { exact: true }).isVisible());
|
||||
assert(
|
||||
await page.getByText("Ticket completion is not MR approval or integration.", {
|
||||
exact: true,
|
||||
}).isVisible(),
|
||||
);
|
||||
assertEquals(await page.getByText("main: merged", { exact: true }).count(), 0);
|
||||
// Member normal operations do not enable owner configuration controls.
|
||||
assertEquals(await page.getByRole("link", { name: "Secrets", exact: true }).count(), 0);
|
||||
assertEquals(
|
||||
await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
false,
|
||||
);
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: `${output}/${colorScheme}-${width}-mr-done.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
assertEquals(unexpected, []);
|
||||
// Reset synthetic records for the next viewport using the fixture's ordinary state API.
|
||||
for (const key of ["T-1", "T-2", "T-3"]) {
|
||||
const detail =
|
||||
await (await context.request.get(`${scenario.baseUrl}/api/w/tickets/tickets/${key}`))
|
||||
.json();
|
||||
await context.request.post(`${scenario.baseUrl}/api/w/tickets/tickets/${key}/state`, {
|
||||
data: {
|
||||
state: "planning",
|
||||
reason: "Fixture reset",
|
||||
expected_state: detail.state,
|
||||
expected_item_revision: detail.item_revision,
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${scenario.baseUrl}/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 conclusion; review is unnecessary.",
|
||||
);
|
||||
await page.getByRole("button", { name: "Create Ticket", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "Repository-free investigation", exact: true })
|
||||
.waitFor();
|
||||
assert(await page.getByText("No repository targets.", { exact: true }).isVisible());
|
||||
// Generic remote launch: a natural request, not an inferred code/review pipeline.
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/workers/new?ticketId=T-4&initialInput=Return%20without%20conclusion.%20Review%20is%20unnecessary.`,
|
||||
);
|
||||
await page.locator(".worker-launch-form").waitFor();
|
||||
await page.getByRole("combobox", { name: "Runtime", exact: true }).selectOption("remote");
|
||||
const launches: Record<string, unknown>[] = [];
|
||||
await page.route("**/api/w/tickets/workers", async (route) => {
|
||||
launches.push(route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
status: 403,
|
||||
json: { error: "permission_denied", message: "Permission denied", diagnostics: [] },
|
||||
});
|
||||
});
|
||||
await page.getByRole("button", { name: "Start Worker", exact: true }).click();
|
||||
await page.getByText("permission_denied: Permission denied", { exact: true }).waitFor();
|
||||
assertEquals(launches.length, 1);
|
||||
assertEquals(launches[0].workdir_attachments, []);
|
||||
assertEquals(launches[0].profile, null);
|
||||
assertEquals(launches[0].initial_submit, [{
|
||||
kind: "text",
|
||||
content: "Return without conclusion. Review is unnecessary.",
|
||||
}]);
|
||||
assertEquals((launches[0].ticket_assignment as { ticket_id: string }).ticket_id, "T-4");
|
||||
await page.unroute("**/api/w/tickets/workers");
|
||||
for (
|
||||
const [status, message] of [[409, "Update conflict."], [403, "Permission denied."], [
|
||||
503,
|
||||
"Outcome unknown or request rejected.",
|
||||
]] as const
|
||||
) {
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`,
|
||||
);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
await page.route(
|
||||
"**/tickets/*/state",
|
||||
(route) => route.fulfill({ status, json: { error: "failure", message } }),
|
||||
);
|
||||
await page.getByRole("combobox", { name: "State", exact: true }).selectOption("done");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Keep this draft on rejection",
|
||||
);
|
||||
await page.getByRole("button", { name: "Complete Ticket" }).click();
|
||||
await page.getByRole("alert").getByText(message, { exact: false }).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Reason", { exact: true }).first().inputValue(),
|
||||
"Keep this draft on rejection",
|
||||
);
|
||||
assert(await page.locator('.ticket-detail-kicker [data-status="planning"]').isVisible());
|
||||
assert(await page.getByRole("button", { name: "Refresh Ticket status" }).isEnabled());
|
||||
await page.unroute("**/tickets/*/state");
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
const requests = await (await fetch(`${scenario.baseUrl}/fixture-state`)).json();
|
||||
assert(
|
||||
requests.some((r: { body: { body?: string } }) =>
|
||||
r.body.body?.includes("Remaining: user choice")
|
||||
),
|
||||
);
|
||||
assert(!requests.some((r: { path: string }) => /merge|review|workers/.test(r.path)));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "tickets-dark",
|
||||
"title": "Flexible Ticket progress and optional resources",
|
||||
"baseUrl": "http://127.0.0.1:15289",
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "member",
|
||||
"label": "Workspace member, not settings owner",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "options",
|
||||
"label": "Return options without conclusion",
|
||||
"path": "/w/tickets/tickets/T-1",
|
||||
"goal": "Read intent, record results and progress without a repository",
|
||||
"dataState": "No repository or MR",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "read-only",
|
||||
"label": "Investigation without review",
|
||||
"path": "/w/tickets/tickets/T-2",
|
||||
"goal": "Use read-only resources without requiring write or MR",
|
||||
"dataState": "Read-only target",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"label": "Independent review before merge",
|
||||
"path": "/w/tickets/tickets/T-3",
|
||||
"goal": "Separate progress from actual MR review and integration",
|
||||
"dataState": "Open MR, unavailable source observation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "launch",
|
||||
"label": "Natural request with optional recipe and resources",
|
||||
"path": "/w/tickets/workers/new?ticketId=T-1&ticketTitle=Compare%20options%20without%20deciding&initialInput=Return%20options%20without%20a%20conclusion.%20Review%20is%20unnecessary.",
|
||||
"goal": "Choose the general Ticket worker, no mandatory repository or Flow",
|
||||
"dataState": "Member with embedded and remote Runtimes, read-only external grant available but not claimed",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".worker-launch-form"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket launch",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Create work from intent with optional resources, not a fixed delivery pipeline",
|
||||
"dataState": "Three lightweight progress lanes, member normal operations",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Progress lanes",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "create",
|
||||
"label": "Optional resources",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Add Ticket\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-create-form"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "ticket-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/ticket_fixture_server.ts",
|
||||
"15289",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15289/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "tickets",
|
||||
"title": "Flexible Ticket progress and optional resources",
|
||||
"baseUrl": "http://127.0.0.1:15289",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "member",
|
||||
"label": "Workspace member, not settings owner",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "options",
|
||||
"label": "Return options without conclusion",
|
||||
"path": "/w/tickets/tickets/T-1",
|
||||
"goal": "Read intent, record results and progress without a repository",
|
||||
"dataState": "No repository or MR",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "read-only",
|
||||
"label": "Investigation without review",
|
||||
"path": "/w/tickets/tickets/T-2",
|
||||
"goal": "Use read-only resources without requiring write or MR",
|
||||
"dataState": "Read-only target",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"label": "Independent review before merge",
|
||||
"path": "/w/tickets/tickets/T-3",
|
||||
"goal": "Separate progress from actual MR review and integration",
|
||||
"dataState": "Open MR, unavailable source observation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket detail",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "launch",
|
||||
"label": "Natural request with optional recipe and resources",
|
||||
"path": "/w/tickets/workers/new?ticketId=T-1&ticketTitle=Compare%20options%20without%20deciding&initialInput=Return%20options%20without%20a%20conclusion.%20Review%20is%20unnecessary.",
|
||||
"goal": "Choose the general Ticket worker, no mandatory repository or Flow",
|
||||
"dataState": "Member with embedded and remote Runtimes, read-only external grant available but not claimed",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".worker-launch-form"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Ticket launch",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Create work from intent with optional resources, not a fixed delivery pipeline",
|
||||
"dataState": "Three lightweight progress lanes, member normal operations",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Progress lanes",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "create",
|
||||
"label": "Optional resources",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Add Ticket\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-create-form"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "ticket-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/ticket_fixture_server.ts",
|
||||
"15289",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15289/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export type RunningProcess = {
|
||||
};
|
||||
|
||||
async function appendOutput(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
stream: ReadableStream<BufferSource>,
|
||||
destination: string,
|
||||
secrets: string[],
|
||||
): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user