import { createServer, type ServerResponse } from "node:http"; import { expect, test, type Route } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { responseFixtureWriter } from "./support/responses-fixture"; import { expectThemeColor } from "./support/theme-colors"; for (const width of [1440, 375, 320]) { test(`steers and queues without stopping, and edits unconsumed messages at ${width}px`, async ({ page }, testInfo) => { const pending = new Map(); const admitted: Array<{ id: string; text: string; delivery: string }> = []; let response: ServerResponse | undefined; let encode = responseFixtureWriter(); let heldWithdrawal: Route | undefined; let holdWithdrawal = false; const send = (chunk: Record) => response!.write(encode(chunk)); const server = createServer((_request, output) => { response = output; encode = responseFixtureWriter(); output.writeHead(200, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache", "Content-Type": "text/event-stream" }); send({ type: "start", messageId: "input-answer" }); send({ type: "data-progress", data: { session_id: "input-session", title: "正在核对压力", status: "running" } }); send({ type: "text-start", id: "answer" }); send({ type: "text-delta", id: "answer", delta: "已开始核对压力。" }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("missing fixture address"); try { await page.setViewportSize({ width, height: 900 }); await page.emulateMedia({ reducedMotion: "reduce" }); await mockWorkbenchContext(page); await mockAgentApi(page); let primaryRequests = 0; let abortRequests = 0; await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => { const request = route.request(); if (request.method() === "OPTIONS") { await route.fallback(); return; } const path = new URL(request.url()).pathname; const headers = { "Access-Control-Allow-Origin": "*" }; if (path.endsWith("/responses")) { const body = request.postDataJSON(); if (!body.tjwater?.delivery) { primaryRequests++; await route.continue({ url: `http://127.0.0.1:${address.port}/responses` }); } else { const item = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" }; admitted.push(item); pending.set(item.id, item); await route.fulfill({ status: 202, headers, json: item }); } return; } if (path.endsWith("/inputs")) { await route.fulfill({ headers, json: { items: [...pending.values()] } }); return; } if (path.includes("/inputs/") && request.method() === "DELETE") { if (holdWithdrawal) { heldWithdrawal = route; return; } const id = path.split("/").at(-1)!; const item = pending.get(id); if (!item) { await route.fulfill({ status: 409, headers, json: { message: "消息已开始处理,无法退回编辑。" } }); return; } pending.delete(id); await route.fulfill({ headers, json: { ...item, status: "cancelled" } }); return; } if (path.includes("/inputs/") && request.method() === "PATCH") { const item = pending.get(path.split("/").at(-1)!); if (!item) { await route.fulfill({ status: 409, headers, json: { message: "Consumed" } }); return; } item.delivery = request.postDataJSON().delivery; await route.fulfill({ headers, json: item }); return; } if (path.endsWith("/abort")) { abortRequests++; pending.clear(); response?.end(); await route.fulfill({ headers, json: { aborted: true } }); return; } await route.fallback(); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click(); const composer = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }); await composer.fill("核对压力变化"); await page.getByRole("button", { name: "发送 Agent 指令" }).click(); const deliveryMode = page.getByRole("button", { name: "运行中消息发送方式" }); await expect(deliveryMode).toHaveText("引导"); await deliveryMode.focus(); await page.keyboard.press("ArrowDown"); await expect(page.getByRole("menuitemradio", { name: "引导当前任务" })).toHaveAttribute("aria-checked", "true"); const queueOption = page.getByRole("menuitemradio", { name: "排队处理" }); await queueOption.hover(); await expectThemeColor(queueOption, "background-color", "--action-selection-soft-hover"); await expectThemeColor(queueOption, "border-color", "--action-border-hover"); await page.getByRole("menu").screenshot({ path: testInfo.outputPath("delivery-menu.png"), animations: "disabled" }); await page.keyboard.press("Escape"); await expect(deliveryMode).toBeFocused(); await expect(deliveryMode).toHaveText("引导"); await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible(); const actionSlot = await page.getByRole("button", { name: "停止 Agent 指令" }).boundingBox(); await composer.fill("补充当前任务"); await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toHaveCount(0); const sendSlot = await page.getByRole("button", { name: "引导当前 Agent 任务" }).boundingBox(); expect(sendSlot).toEqual(actionSlot); await page.getByRole("button", { name: "引导当前 Agent 任务" }).click(); const queue = page.getByRole("list", { name: "待处理消息" }); const pendingRow = queue.getByRole("listitem").filter({ hasText: "补充当前任务" }); const pendingMode = pendingRow.getByRole("button", { name: "消息操作:补充当前任务" }); await expect(pendingRow).toContainText("引导"); await expect(pendingRow.getByRole("button")).toHaveCount(1); await pendingMode.click(); await page.getByRole("menuitemradio", { name: "排队处理" }).click(); await expect(pendingRow).toContainText("排队"); await pendingMode.click(); await expect(page.getByRole("menuitemradio", { name: "排队处理" })).toHaveAttribute("aria-checked", "true"); const steerOption = page.getByRole("menuitemradio", { name: "引导当前任务" }); await steerOption.hover(); await expectThemeColor(steerOption, "background-color", "--action-selection-soft-hover"); await page.screenshot({ path: testInfo.outputPath("pending-delivery-menu.png"), animations: "disabled" }); await steerOption.click(); await expect(pendingRow).toContainText("引导"); await expect(pendingMode).toBeFocused(); await deliveryMode.click(); await page.getByRole("menuitemradio", { name: "排队处理" }).click(); await composer.fill("下一轮核对流量"); await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click(); await expect(queue.getByRole("listitem").filter({ hasText: "下一轮核对流量" })).toContainText("排队"); expect(admitted.map((item) => item.delivery)).toEqual(["steer", "queue"]); const longInput = "下一轮请检查所有压力测点,并说明每个异常的证据及时间范围。\n请逐一列出测点名称、压力变化、读数时刻和关联管段,保留缺失数据的说明,避免把未核实的推测写成结论。\n保留这段完整的补充内容,末尾核对流量。"; await composer.fill(longInput); await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click(); const longRow = queue.getByRole("listitem").filter({ hasText: longInput }); await expect(longRow).toBeVisible(); await expect(longRow.locator("p")).toHaveText(longInput); const expandMessage = longRow.getByRole("button", { name: "展开全文" }); await expect(expandMessage).toBeVisible(); await expect(expandMessage).toHaveAttribute("aria-expanded", "false"); await expect(page.locator("form").getByRole("list", { name: "待处理消息" })).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("pending-messages-compact.png"), animations: "disabled" }); await expandMessage.click(); await expect(longRow.getByRole("button", { name: "收起全文" })).toHaveAttribute("aria-expanded", "true"); expect(await longRow.locator("p").evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true); expect(await longRow.locator("p").evaluate((node) => node.scrollHeight <= node.clientHeight)).toBe(true); await page.screenshot({ path: testInfo.outputPath("pending-message-content.png"), animations: "disabled" }); await longRow.getByRole("button", { name: /^消息操作:/ }).focus(); await page.keyboard.press("ArrowDown"); holdWithdrawal = true; await page.getByRole("menuitem", { name: "编辑消息" }).click(); await expect.poll(() => Boolean(heldWithdrawal)).toBe(true); await composer.fill("正在输入的新草稿"); const withdrawnId = new URL(heldWithdrawal!.request().url()).pathname.split("/").at(-1)!; const withdrawn = pending.get(withdrawnId)!; pending.delete(withdrawnId); await heldWithdrawal!.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { ...withdrawn, status: "cancelled" } }); heldWithdrawal = undefined; holdWithdrawal = false; await expect(composer).toHaveValue(`正在输入的新草稿\n\n${longInput}`); await expect(composer).toBeFocused(); await expect(queue.getByRole("listitem")).toHaveCount(2); expect(admitted).toHaveLength(3); await composer.fill("编辑后重新发送的消息"); await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click(); const editedRow = queue.getByRole("listitem").filter({ hasText: "编辑后重新发送的消息" }); await editedRow.getByRole("button", { name: /^消息操作:/ }).click(); await page.getByRole("menuitem", { name: "关闭排队" }).click(); await expect(editedRow).toHaveCount(0); await expect(composer).toHaveValue(""); // Consumption can win the race while the edit menu is open. await composer.fill("保留当前草稿"); const consumedRow = queue.getByRole("listitem").filter({ hasText: "补充当前任务" }); await consumedRow.getByRole("button", { name: /^消息操作:/ }).click(); holdWithdrawal = true; await page.getByRole("menuitem", { name: "编辑消息" }).click(); await expect.poll(() => Boolean(heldWithdrawal)).toBe(true); const consumed = pending.get(admitted[0].id)!; pending.delete(consumed.id); send({ type: "data-user_input", data: { ...consumed, session_id: "input-session", status: "delivered" } }); await heldWithdrawal!.fulfill({ status: 409, headers: { "Access-Control-Allow-Origin": "*" }, json: { message: "消息已开始处理,无法退回编辑。" } }); heldWithdrawal = undefined; holdWithdrawal = false; await expect(page.getByText("无法退回编辑", { exact: true })).toBeVisible(); await expect(composer).toHaveValue("保留当前草稿"); expect(primaryRequests).toBe(1); expect(abortRequests).toBe(0); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); for (const item of pending.values()) { send({ type: "data-user_input", data: { ...item, session_id: "input-session", status: "delivered" } }); } pending.clear(); await expect(page.getByRole("list", { name: "待处理消息" })).toHaveCount(0); send({ type: "text-delta", id: "answer", delta: "已结合补充信息完成核对。" }); send({ type: "text-end", id: "answer" }); send({ type: "finish" }); response!.end(); await expect(page.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible(); await expect(page.getByText("补充当前任务", { exact: true })).toBeVisible(); await expect(page.getByText("下一轮核对流量", { exact: true })).toBeVisible(); await composer.fill("继续核对压力"); await page.getByRole("button", { name: "发送 Agent 指令" }).click(); await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible(); await composer.fill("等待处理的后续问题"); await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click(); await expect(page.getByRole("button", { name: "消息操作:等待处理的后续问题" })).toBeVisible(); await page.setViewportSize({ width, height: 640 }); await expect.poll(async () => { const stopBox = await page.getByRole("button", { name: "停止 Agent 指令" }).boundingBox(); return stopBox!.y + stopBox!.height; }).toBeLessThanOrEqual(640); await page.screenshot({ path: testInfo.outputPath("pending-message-short-window.png"), animations: "disabled" }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.getByRole("button", { name: "停止 Agent 指令" }).click(); await expect(page.getByRole("list", { name: "待处理消息" })).toHaveCount(0); expect(abortRequests).toBe(1); } finally { response?.end(); server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); } }); }