workbench.spec.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import { expect, test } from "@playwright/test";
  2. const frame = (event) => "data: " + JSON.stringify(event) + "\n\n";
  3. const response = (events) => ({
  4. status: 200,
  5. contentType: "text/event-stream",
  6. headers: { "X-Session-Id": "frontend-test", "X-Agent-Name": "main" },
  7. body: events.map(frame).join(""),
  8. });
  9. test("Vue history renders GFM and sanitizes HTML through the actual page", async ({
  10. page,
  11. }) => {
  12. await page.goto("/");
  13. await expect(page.locator("#result")).toHaveClass("pass");
  14. await expect(page.locator("#result")).toContainText("8项通过");
  15. });
  16. test("streamed tokens retain tool order and request identity", async ({
  17. page,
  18. }) => {
  19. let submitted;
  20. await page.route("**/api/chat", async (route) => {
  21. submitted = route.request().postData();
  22. await route.fulfill(
  23. response([
  24. { type: "token", content: "**之前**" },
  25. {
  26. type: "tool_call",
  27. tool: "test_tool",
  28. tool_call_id: "tool-1",
  29. args: { x: 1 },
  30. },
  31. {
  32. type: "tool_result",
  33. tool: "test_tool",
  34. tool_call_id: "tool-1",
  35. content: { ok: true },
  36. },
  37. { type: "token", content: "## 之后\n\n**末尾**" },
  38. { type: "done" },
  39. ]),
  40. );
  41. });
  42. await page.goto("/static/index.html");
  43. await page.getByLabel("输入消息").fill("测试流式渲染");
  44. await page.getByRole("button", { name: "发送消息", exact: true }).click();
  45. await expect(
  46. page.locator(".assistant-body .markdown").last().locator("h2"),
  47. ).toHaveText("之后");
  48. const tags = await page
  49. .locator(".assistant-body")
  50. .evaluate((node) => [...node.children].map((child) => child.className));
  51. expect(tags[0]).toBe("markdown");
  52. expect(tags[1]).toContain("tool-card");
  53. expect(tags[2]).toBe("markdown");
  54. await expect(page.locator(".tool-card")).not.toHaveAttribute("open");
  55. expect(submitted).toContain('name="request_id"');
  56. });
  57. test("batch approvals preserve all actions and the current interrupt token", async ({
  58. page,
  59. }) => {
  60. await page.route("**/api/chat", (route) =>
  61. route.fulfill(
  62. response([
  63. {
  64. type: "interrupt",
  65. tool_approval: {
  66. tool: "one",
  67. interrupt_token: "current-batch-token",
  68. actions: [
  69. { name: "one", args: { path: "a" } },
  70. { name: "two", args: { path: "b" } },
  71. ],
  72. },
  73. },
  74. ]),
  75. ),
  76. );
  77. let payload;
  78. await page.route("**/api/chat/resume", async (route) => {
  79. payload = route.request().postDataJSON();
  80. await route.fulfill(
  81. response([{ type: "token", content: "已批准" }, { type: "done" }]),
  82. );
  83. });
  84. await page.goto("/static/index.html");
  85. await page.getByLabel("输入消息").fill("需要审批");
  86. await page.getByRole("button", { name: "发送消息", exact: true }).click();
  87. const approve = page.getByRole("button", {
  88. name: "批准全部 2 项",
  89. exact: true,
  90. });
  91. await expect(approve).toBeEnabled();
  92. await expect(page.locator(".approval-card")).toContainText("one");
  93. await expect(page.locator(".approval-card")).toContainText("two");
  94. await approve.click();
  95. await expect(page.locator(".approval-card")).toContainText("已提交确认");
  96. expect(payload.interrupt_token).toBe("current-batch-token");
  97. expect(payload.approved).toBe(true);
  98. });
  99. test("ask-user answers keep the original card available when a resume request fails", async ({
  100. page,
  101. }) => {
  102. await page.route("**/api/chat", (route) =>
  103. route.fulfill(
  104. response([
  105. {
  106. type: "interrupt",
  107. interrupt_token: "question-token",
  108. ask_user: { questions: ["请选择区域", "需要什么格式"] },
  109. },
  110. ]),
  111. ),
  112. );
  113. let attempts = 0,
  114. payload;
  115. await page.route("**/api/chat/resume", async (route) => {
  116. payload = route.request().postDataJSON();
  117. attempts++;
  118. await route.fulfill(
  119. attempts === 1
  120. ? {
  121. status: 503,
  122. contentType: "application/json",
  123. body: JSON.stringify({ detail: { message: "临时不可用" } }),
  124. }
  125. : response([{ type: "done" }]),
  126. );
  127. });
  128. await page.goto("/static/index.html");
  129. await page.getByLabel("输入消息").fill("提问");
  130. await page.getByRole("button", { name: "发送消息", exact: true }).click();
  131. await expect(page.locator(".approval-card textarea")).toHaveCount(2);
  132. await page.locator(".approval-card textarea").nth(0).fill("一区");
  133. await page.locator(".approval-card textarea").nth(1).fill("表格");
  134. const submit = page.getByRole("button", { name: "提交回答", exact: true });
  135. await expect(submit).toBeEnabled();
  136. await submit.click();
  137. await expect(page.locator(".notice.error")).toContainText("临时不可用");
  138. await expect(submit).toBeEnabled();
  139. await submit.click();
  140. await expect(page.locator(".approval-card")).toContainText("已提交确认");
  141. expect(payload.answers).toEqual(["一区", "表格"]);
  142. expect(payload.interrupt_token).toBe("question-token");
  143. });
  144. test("stopping a stream keeps the partial Markdown reply", async ({ page }) => {
  145. await page.addInitScript(() => {
  146. const original = window.fetch;
  147. window.fetch = (url, options) =>
  148. url === "/api/chat"
  149. ? Promise.resolve(
  150. new Response(
  151. new ReadableStream({
  152. start(controller) {
  153. controller.enqueue(
  154. new TextEncoder().encode(
  155. 'data: {"type":"token","content":"**保留部分回复**"}\n\n',
  156. ),
  157. );
  158. options.signal.addEventListener("abort", () =>
  159. controller.error(new DOMException("Stopped", "AbortError")),
  160. );
  161. },
  162. }),
  163. {
  164. headers: {
  165. "Content-Type": "text/event-stream",
  166. "X-Session-Id": "frontend-test",
  167. },
  168. },
  169. ),
  170. )
  171. : original(url, options);
  172. });
  173. await page.goto("/static/index.html");
  174. await page.getByLabel("输入消息").fill("停止测试");
  175. await page.getByRole("button", { name: "发送消息", exact: true }).click();
  176. await expect(page.locator(".assistant-body strong").first()).toHaveText(
  177. "保留部分回复",
  178. );
  179. await page.getByRole("button", { name: "停止生成", exact: true }).click();
  180. await expect(page.locator(".assistant-body")).toContainText("已停止生成");
  181. await expect(page.locator(".assistant-body strong").first()).toHaveText(
  182. "保留部分回复",
  183. );
  184. });
  185. test("mobile navigation and report history fit the actual viewport", async ({
  186. page,
  187. }) => {
  188. await page.setViewportSize({ width: 390, height: 844 });
  189. await page.goto("/static/index.html");
  190. await page.getByRole("button", { name: "打开导航", exact: true }).click();
  191. await page
  192. .getByRole("button", { name: "Markdown历史测试", exact: true })
  193. .click();
  194. await expect(page.locator(".markdown h1")).toHaveText("Markdown 标题");
  195. expect(
  196. await page.evaluate(
  197. () => document.documentElement.scrollWidth > innerWidth,
  198. ),
  199. ).toBe(false);
  200. await page.getByRole("button", { name: "打开导航", exact: true }).click();
  201. await page.getByRole("tab", { name: /智能体测试/ }).click();
  202. await expect(page.locator(".lab-page-heading h2")).toBeVisible();
  203. expect(
  204. await page.evaluate(
  205. () => document.documentElement.scrollWidth > innerWidth,
  206. ),
  207. ).toBe(false);
  208. });
  209. const simulationDefaults = {
  210. mine_code: "mock-zy",
  211. mine_name: "陕西众源煤业集团有限公司",
  212. direction: "1_to_2",
  213. stopped_at: "2026-10-15T10:00:00+08:00",
  214. switch_interval_minutes: 6,
  215. before_flow_m3_min: 3800,
  216. after_flow_m3_min: 3610,
  217. switch_source: "simulation",
  218. flow_source: "simulation",
  219. switch_asset_id: null,
  220. flow_asset_id: null,
  221. };
  222. async function setupThreeInputLab(page) {
  223. const asset = {
  224. id: "sample-pdf",
  225. filename: "通风机性能测定报告.pdf",
  226. kind: "pdf",
  227. source: "sample",
  228. size: 11775998,
  229. url: "/pdf",
  230. preview_url: "/pdf",
  231. };
  232. await page.route("**/api/test-lab/catalog?*", (route) =>
  233. route.fulfill({
  234. json: {
  235. agents: [
  236. {
  237. name: "main_fan",
  238. display_name: "矿井主通风机研判智能体",
  239. presets: [],
  240. },
  241. ],
  242. assets: [asset],
  243. main_fan_simulation: simulationDefaults,
  244. },
  245. }),
  246. );
  247. await page.route("**/api/test-lab/runs?*", (route) =>
  248. route.fulfill({ json: { items: [] } }),
  249. );
  250. await page.goto("/static/index.html#/test");
  251. await expect(page.getByLabel("切换间隔(分钟)")).toHaveValue("6");
  252. }
  253. test("three input cards send adjustable inputs through the complete agent on every run", async ({
  254. page,
  255. }) => {
  256. await setupThreeInputLab(page);
  257. await expect(page.locator(".fan-input-card")).toHaveCount(3);
  258. await expect(page.locator(".preset-card")).toHaveCount(0);
  259. await expect(page.getByLabel("选择 通风机性能测定报告.pdf")).toBeChecked();
  260. await expect(page.locator(".execution-modes")).toHaveCount(0);
  261. await page.getByLabel("切换间隔(分钟)").fill("6.5");
  262. await page.getByLabel("切换后风量(m³/min)").fill("3344");
  263. await expect(
  264. page.locator('[data-testid="switch-input"] svg[role="img"]'),
  265. ).toHaveAttribute("aria-label", /6.5分钟/);
  266. await expect(page.locator('[data-testid="flow-input"]')).toContainText(
  267. "12.00%",
  268. );
  269. const submitted = [];
  270. await page.route("**/api/test-lab/runs?*", async (route) => {
  271. if (route.request().method() !== "POST")
  272. return route.fulfill({ json: { items: [] } });
  273. const body = route.request().postDataJSON();
  274. submitted.push(body);
  275. return route.fulfill({
  276. status: 202,
  277. json: {
  278. id: "run-" + submitted.length,
  279. label: body.label,
  280. agent_name: "main_fan",
  281. status: "completed",
  282. execution_mode: "agent",
  283. created_at: "2026-10-15 12:00:00",
  284. finished_at: "2026-10-15 12:00:01",
  285. request: { submitted: body, assets: [] },
  286. artifacts: [],
  287. stages: [],
  288. result: {
  289. result: "本次报告",
  290. platform_payload: { judgment: { resultCode: "WARNING" } },
  291. },
  292. },
  293. });
  294. });
  295. await page.getByRole("button", { name: /运行测试/ }).click();
  296. await expect(page.locator(".result-heading .result-pill")).toHaveText(
  297. "WARNING",
  298. );
  299. await expect(page.getByRole("button", { name: /运行测试/ })).toBeEnabled();
  300. await page.getByLabel("切换后风量(m³/min)").fill("3610");
  301. await page.getByRole("button", { name: /运行测试/ }).click();
  302. await expect.poll(() => submitted.length).toBe(2);
  303. expect(submitted[0].execution_mode).toBe("agent");
  304. expect(submitted[0].input).toEqual({});
  305. expect(submitted[0].asset_ids).toEqual(["sample-pdf"]);
  306. expect(submitted[0].main_fan_simulation.switch_interval_minutes).toBe(6.5);
  307. expect(submitted[0].main_fan_simulation.after_flow_m3_min).toBe(3344);
  308. expect(submitted[1].main_fan_simulation.after_flow_m3_min).toBe(3610);
  309. expect(submitted[0].request_id).not.toBe(submitted[1].request_id);
  310. });
  311. test("three input mobile layout requires selected JSON when switching sources", async ({
  312. page,
  313. }) => {
  314. await page.setViewportSize({ width: 390, height: 844 });
  315. await setupThreeInputLab(page);
  316. await page
  317. .locator('[data-testid="switch-input"]')
  318. .getByRole("button", { name: "选择 JSON", exact: true })
  319. .click();
  320. await expect(page.getByRole("button", { name: /运行测试/ })).toBeDisabled();
  321. await expect(page.locator(".run-note")).toContainText("请选择切换曲线JSON");
  322. await page
  323. .locator('[data-testid="switch-input"]')
  324. .getByRole("button", { name: "设置模拟曲线", exact: true })
  325. .click();
  326. await expect(page.getByRole("button", { name: /运行测试/ })).toBeEnabled();
  327. expect(
  328. await page.evaluate(
  329. () => document.documentElement.scrollWidth > innerWidth,
  330. ),
  331. ).toBe(false);
  332. });