Make English the repository working language (#5)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e80cde0ea6
commit
0eaaa54e25
136 files changed
+1515
-1352
No files matched your search
@@ -1,13 +1,14 @@
|
||||
# Web E2E(Playwright)
|
||||
# Web E2E (Playwright)
|
||||
|
||||
浏览器端到端:对话(thinking + 工具审批 + 工具执行 + 二轮答复)、图标化统计(成本折算 /
|
||||
复制回复)、轨迹观测(分 Task 时间线 + 图例 + 悬停联动高亮)、Workspace 文件预览(HTML
|
||||
sandbox 渲染、路径默认隐藏)。LLM 由 `mock-llm.mjs`(mock Anthropic Messages SSE)驱动,
|
||||
不联网。
|
||||
Browser end-to-end tests: chat (thinking + tool approval + tool execution + second-turn
|
||||
reply), the iconified stats line (cost conversion / copy reply), Traces (per-Task timeline +
|
||||
legend + hover-linked highlighting), Workspace file preview (sandboxed HTML rendering, path
|
||||
hidden by default). The LLM is driven by `mock-llm.mjs` (a mock Anthropic Messages SSE
|
||||
endpoint) — no network access.
|
||||
|
||||
```sh
|
||||
pnpm --filter @prismshadow/penguin-web test:e2e # 构建 + 起服务 + 跑用例
|
||||
SKIP_BUILD=1 pnpm --filter @prismshadow/penguin-web test:e2e # 跳过构建
|
||||
pnpm --filter @prismshadow/penguin-web test:e2e # build + start the server + run the tests
|
||||
SKIP_BUILD=1 pnpm --filter @prismshadow/penguin-web test:e2e # skip the build
|
||||
```
|
||||
|
||||
首次需 `npx playwright install chromium`。
|
||||
The first run requires `npx playwright install chromium`.
|
||||
@@ -52,7 +52,7 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
// Approval-mode is a custom dropdown (button), NOT a native <select>.
|
||||
await expect(page.locator("select")).toHaveCount(0);
|
||||
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
// Tool name is shown on the collapsed tool row + in the pending-approval block.
|
||||
@@ -84,7 +84,7 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
await page.getByRole("button", { name: "允许" }).click();
|
||||
|
||||
// Final assistant answer (turn 2 from mock).
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
|
||||
// Regression: after entering a session and rendering messages, the page must not overflow
|
||||
// horizontally (previously, with the Files dock panel closed, the sr-only upload input was
|
||||
@@ -124,12 +124,12 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
|
||||
// Hovering the REPLY ITSELF must reveal it — the reply and its stats line share a `group` wrapper
|
||||
// for exactly this. Requiring a hover on the number row would make the footer undiscoverable.
|
||||
await page.getByText("命令已执行完成,结果符合预期。").first().hover();
|
||||
await page.getByText("Command finished; the result looks as expected.").first().hover();
|
||||
await expect(statsLine).toHaveCSS("opacity", "1");
|
||||
await expect(statsLine.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible();
|
||||
await copyBtn.click();
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toContain("命令已执行完成");
|
||||
expect(clip).toContain("Command finished");
|
||||
|
||||
// The AI reply's time + copy live on the stats line (it sits right under the reply and IS its
|
||||
// footer) — the assistant message must NOT render a second footer, or one spot grows two copy
|
||||
@@ -149,17 +149,17 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
).not.toBeNull();
|
||||
|
||||
// Hover the user bubble → its footer fades in (human-readable time + copy).
|
||||
await page.getByText("帮我配置 @theme").first().hover();
|
||||
await page.getByText("Help me set up @theme").first().hover();
|
||||
await expect(meta).toHaveCSS("opacity", "1");
|
||||
await expect(meta.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible();
|
||||
await msgCopy.click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("帮我配置 @theme");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Help me set up @theme");
|
||||
|
||||
// --- traces --- (scope to <main>; the global sidebar also lists the session title)
|
||||
await page.goto(`${BASE}/traces`);
|
||||
const main = page.locator("main");
|
||||
// Auto-generated title should appear in the traces tree (agents default-expanded).
|
||||
const titleNode = main.getByText("配置 Tailwind 主题").first();
|
||||
const titleNode = main.getByText("Configure Tailwind theme").first();
|
||||
await expect(titleNode).toBeVisible();
|
||||
await titleNode.click();
|
||||
await expect(main.getByText("轨迹观测").first()).toBeVisible();
|
||||
@@ -236,9 +236,9 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
// The mock reply mentions two backtick paths: demo.html was truly uploaded above via
|
||||
// files/content; missing-report.pdf exists nowhere. The card must render after the
|
||||
// files/stat round-trip with a single row for the existing file only.
|
||||
await ta.fill("文件卡测试");
|
||||
await ta.fill("files card test");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText(/报告已生成/)).toBeVisible();
|
||||
await expect(page.getByText(/Report generated/)).toBeVisible();
|
||||
await expect(page.getByText("1 个文件")).toBeVisible();
|
||||
// Card rows carry the 点击预览 ("click to preview") affordance — that distinguishes them from
|
||||
// the Files panel tree rows, which also use title=<name> on their buttons.
|
||||
@@ -260,11 +260,11 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
|
||||
// --- agent group collapse / expand (header button shows the agent name, so its state
|
||||
// lives on aria-label rather than a duplicate title tooltip) ---
|
||||
await expect(sidebar.getByText("配置 Tailwind 主题")).toBeVisible();
|
||||
await expect(sidebar.getByText("Configure Tailwind theme")).toBeVisible();
|
||||
await sidebar.locator('button[aria-label="折叠"]').first().click();
|
||||
await expect(sidebar.getByText("配置 Tailwind 主题")).toHaveCount(0);
|
||||
await expect(sidebar.getByText("Configure Tailwind theme")).toHaveCount(0);
|
||||
await sidebar.locator('button[aria-label="展开"]').first().click();
|
||||
await expect(sidebar.getByText("配置 Tailwind 主题")).toBeVisible();
|
||||
await expect(sidebar.getByText("Configure Tailwind theme")).toBeVisible();
|
||||
|
||||
// --- settings: adjustable accent color + font size (default gray/white) ---
|
||||
// The username button shares its name with the Project switcher (the initial Project's
|
||||
@@ -281,14 +281,14 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// --- session rename (manual title wins over the auto-generated one) ---
|
||||
const renameTarget = sidebar.locator("li", { hasText: "配置 Tailwind 主题" }).first();
|
||||
const renameTarget = sidebar.locator("li", { hasText: "Configure Tailwind theme" }).first();
|
||||
await renameTarget.hover();
|
||||
await renameTarget.getByRole("button", { name: "重命名对话" }).click();
|
||||
await page.getByLabel("标题").fill("我改的标题");
|
||||
await page.getByLabel("标题").fill("My renamed title");
|
||||
await page.getByRole("button", { name: "保存" }).click();
|
||||
await expect(sidebar.getByText("我改的标题")).toBeVisible();
|
||||
await expect(sidebar.getByText("My renamed title")).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(sidebar.getByText("我改的标题")).toBeVisible();
|
||||
await expect(sidebar.getByText("My renamed title")).toBeVisible();
|
||||
|
||||
// --- session archive + delete (throwaway session) ---
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`, {
|
||||
|
||||
@@ -56,7 +56,7 @@ test("aborting before any turn completes: /compact says so instead of doing noth
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
// Wait for the approval to be pending before aborting: at this point the LLM stream has
|
||||
// already finished sending, the engine is blocked on `await approve(tc)`, and the generator is
|
||||
@@ -86,8 +86,8 @@ test("aborting before any turn completes: /compact says so instead of doing noth
|
||||
await ta.press("Enter");
|
||||
|
||||
// There must be feedback — silently doing nothing is exactly the bug being tested for.
|
||||
await expect(page.getByRole("button", { name: /没有可压缩的内容/ })).toBeVisible();
|
||||
expect(nativeDialogs, "反馈必须走应用内轻提示,不得回退到原生 alert").toHaveLength(0);
|
||||
await expect(page.getByRole("button", { name: /nothing to compact/ })).toBeVisible();
|
||||
expect(nativeDialogs, "feedback must be an in-app toast, not a native alert").toHaveLength(0);
|
||||
// And indeed no compaction was started (no banner).
|
||||
await expect(page.getByText(/\[压缩\]/)).toHaveCount(0);
|
||||
});
|
||||
@@ -123,9 +123,9 @@ test("compacting twice in a row: says the context was just compacted, not that n
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
|
||||
const nativeDialogs = [];
|
||||
page.on("dialog", (d) => {
|
||||
@@ -137,14 +137,20 @@ test("compacting twice in a row: says the context was just compacted, not that n
|
||||
await ta.fill("/compact");
|
||||
await ta.press("Enter");
|
||||
await expect(page.getByText(/\[压缩\]/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /压缩/ })).toHaveCount(0);
|
||||
// No compact-unavailable toast (match the 409 reasons, not bare "compact" — usernames like
|
||||
// "dblcompact" would collide).
|
||||
await expect(
|
||||
page.getByRole("button", { name: /compaction configured|nothing to compact|just compacted/ }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Second /compact: there's no conversation yet in the new context -> tell the user clearly, and the wording must match reality.
|
||||
await ta.fill("/compact");
|
||||
await ta.press("Enter");
|
||||
await expect(page.getByRole("button", { name: /刚压缩过/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /just compacted/ })).toBeVisible();
|
||||
// Having just finished a full round, saying "no completed conversation round yet" would be absurd.
|
||||
await expect(page.getByRole("button", { name: /尚无已完成的对话轮次/ })).toHaveCount(0);
|
||||
expect(nativeDialogs, "反馈必须走应用内轻提示,不得回退到原生 alert").toHaveLength(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /no completed conversation turns yet/ }),
|
||||
).toHaveCount(0);
|
||||
expect(nativeDialogs, "feedback must be an in-app toast, not a native alert").toHaveLength(0);
|
||||
await expect(page.getByText(/\[压缩\]/)).toHaveCount(1); // no second compaction banner
|
||||
});
|
||||
@@ -53,10 +53,10 @@ test("compaction mid-turn: the reply's stats line is still reachable by hovering
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
const reply = page.getByText("命令已执行完成,结果符合预期。").first();
|
||||
const reply = page.getByText("Command finished; the result looks as expected.").first();
|
||||
await expect(reply).toBeVisible();
|
||||
const banner = page.getByText(/\[压缩\]/).first();
|
||||
await expect(banner).toBeVisible();
|
||||
@@ -97,7 +97,7 @@ test("compaction mid-turn: the reply's stats line is still reachable by hovering
|
||||
// fast the summary was generated), not a "—".
|
||||
await page.goto(`${BASE}/traces`);
|
||||
const main = page.locator("main");
|
||||
const node = main.getByText(/配置|新对话/).first();
|
||||
const node = main.getByText(/Configure|新对话/).first();
|
||||
await expect(node).toBeVisible();
|
||||
await node.click();
|
||||
await expect(main.getByText("第 1 轮")).toBeVisible();
|
||||
@@ -191,9 +191,9 @@ test("manual /compact between turns: reloading must not fold the compaction into
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
|
||||
// The whole stats line is transparent by default, but innerText still reads fine (no need to hover).
|
||||
const statsText = async () =>
|
||||
@@ -207,7 +207,7 @@ test("manual /compact between turns: reloading must not fold the compaction into
|
||||
};
|
||||
|
||||
const live = await statsText();
|
||||
expect(costOf(live), `统计行应有成本:${live}`).not.toBeNull();
|
||||
expect(costOf(live), `stats line should include a cost: ${live}`).not.toBeNull();
|
||||
expect(elapsedMsOf(live)).toBeLessThan(1500);
|
||||
|
||||
// The user reads the reply, then 3 seconds later remembers to compact.
|
||||
@@ -216,14 +216,14 @@ test("manual /compact between turns: reloading must not fold the compaction into
|
||||
await ta.press("Enter");
|
||||
await expect(page.getByText(/\[压缩\]/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);
|
||||
expect(await statsText(), "实时流里上一轮早已收口,压缩不该改动它").toBe(live);
|
||||
expect(await statsText(), "compaction must not touch the already-closed prior turn").toBe(live);
|
||||
|
||||
await page.reload();
|
||||
await ta.waitFor();
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
const rebuilt = await statsText();
|
||||
// Cost is derived from Token counts: if compaction's usage were folded in, cost would double out of nowhere.
|
||||
expect(costOf(rebuilt), `刷新前后成本必须一致(前:${live}|后:${rebuilt})`).toBe(costOf(live));
|
||||
expect(costOf(rebuilt), `cost must survive reload (${live} -> ${rebuilt})`).toBe(costOf(live));
|
||||
// Elapsed time should include neither the 3-second thinking gap nor compaction itself.
|
||||
expect(elapsedMsOf(rebuilt), `刷新后用时不得吃进压缩(${rebuilt})`).toBeLessThan(1500);
|
||||
expect(elapsedMsOf(rebuilt), `elapsed must not eat compaction (${rebuilt})`).toBeLessThan(1500);
|
||||
});
|
||||
@@ -15,7 +15,7 @@ const P = "password123";
|
||||
const UA = "draftowner";
|
||||
const UB = "draftguest";
|
||||
|
||||
test("切换账号:B 不恢复 A 的草稿,两人草稿并存", async ({ page }) => {
|
||||
test("switching accounts: B does not restore A's draft; both drafts coexist", async ({ page }) => {
|
||||
// A is provisioned and logged in (page's cookie belongs to A); B is only provisioned, logging in via the UI later. userId is the username.
|
||||
const userA = (await provisionAndLogin(page.request, UA, P)).userId;
|
||||
await provisionUser(UB, P);
|
||||
@@ -49,13 +49,13 @@ test("切换账号:B 不恢复 A 的草稿,两人草稿并存", async ({ pag
|
||||
// A types a body on the draft page and waits for debounce to persist it under A's key; after a reload it restores as usual (confirming it was persisted).
|
||||
await page.goto(`${BASE}/chat`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.fill("A 的机密草稿");
|
||||
await ta.fill("A's secret draft");
|
||||
const keyA = `penguin.chatDraft.${userA}.${projectId}`;
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), keyA))
|
||||
.toContain("A 的机密草稿");
|
||||
.toContain("A's secret draft");
|
||||
await page.reload();
|
||||
await expect(ta).toHaveValue("A 的机密草稿");
|
||||
await expect(ta).toHaveValue("A's secret draft");
|
||||
|
||||
// A logs out: the bottom-left user menu (the username button shares its name with the top
|
||||
// Project switcher — the initial Project's display name defaults to the username — so take
|
||||
@@ -77,10 +77,10 @@ test("切换账号:B 不恢复 A 的草稿,两人草稿并存", async ({ pag
|
||||
|
||||
// B writes their own draft: it lands under B's key (the key includes projectId, confirming B
|
||||
// is on the same shared Project), while A's draft is left untouched — neither overwrites the other.
|
||||
await ta.fill("B 自己的草稿");
|
||||
await ta.fill("B's own draft");
|
||||
const keyB = `penguin.chatDraft.${userB}.${projectId}`;
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), keyB))
|
||||
.toContain("B 自己的草稿");
|
||||
expect(await page.evaluate((k) => localStorage.getItem(k), keyA)).toContain("A 的机密草稿");
|
||||
.toContain("B's own draft");
|
||||
expect(await page.evaluate((k) => localStorage.getItem(k), keyA)).toContain("A's secret draft");
|
||||
});
|
||||
@@ -15,7 +15,9 @@ const MOCK = process.env.MOCK_URL;
|
||||
const U = "draftuser";
|
||||
const P = "password123";
|
||||
|
||||
test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存 → 侧栏 + 指定 Agent", async ({ page }) => {
|
||||
test("draft: pick model/approval -> reload restores them -> send creates the session and clears the cache -> sidebar + scopes the Agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Draft keys are isolated by user x Project/Session (#68); building the key needs userId (i.e. the username).
|
||||
const userId = (await provisionAndLogin(page.request, U, P)).userId;
|
||||
|
||||
@@ -57,14 +59,14 @@ test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存
|
||||
await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible();
|
||||
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.fill("草稿正文不能丢");
|
||||
await ta.fill("Draft body must not be lost");
|
||||
|
||||
// The @ delegation target is also draft content: typing an @ prefix at the end summons the
|
||||
// menu, selecting it turns into a chip (the @token in the body text is stripped out).
|
||||
await ta.fill("草稿正文不能丢 @agent_hel");
|
||||
await ta.fill("Draft body must not be lost @agent_hel");
|
||||
await page.getByRole("button", { name: /@agent_helper/ }).click();
|
||||
await expect(page.getByText("@agent_helper")).toBeVisible();
|
||||
await expect(ta).toHaveValue("草稿正文不能丢");
|
||||
await expect(ta).toHaveValue("Draft body must not be lost");
|
||||
|
||||
// Switch the model: the selector sits to the left of the send button, opens downward, with a quick-search field at the top.
|
||||
await page.getByRole("button", { name: "选择模型" }).click();
|
||||
@@ -80,10 +82,10 @@ test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存
|
||||
const draftKey = `penguin.chatDraft.${userId}.${projectId}`;
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), draftKey))
|
||||
.toContain("草稿正文不能丢");
|
||||
.toContain("Draft body must not be lost");
|
||||
|
||||
await page.reload();
|
||||
await expect(ta).toHaveValue("草稿正文不能丢");
|
||||
await expect(ta).toHaveValue("Draft body must not be lost");
|
||||
// After restoring, the cursor lands at the end of the draft (focus defaults to the start, so it must be explicitly moved to the end to keep typing).
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -120,7 +122,7 @@ test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存
|
||||
await groupRow.getByRole("button", { name: "新建对话" }).click();
|
||||
|
||||
await expect(page.getByLabel("选择 Agent")).toContainText("Helper Agent");
|
||||
await ta.fill("给 helper 的第一条");
|
||||
await ta.fill("First message for helper");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await page.waitForURL(/\/chat\/session-/);
|
||||
const secondSessionId = page.url().split("/chat/")[1];
|
||||
@@ -133,17 +135,17 @@ test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存
|
||||
expect(second.session.approvalMode).toBe("allow-all");
|
||||
|
||||
// Under allow-all, the mock's exec_command is auto-approved, so the round runs to completion (turn 2 text lands).
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
|
||||
// —— Input draft for an existing session: cached by user x Session, restored on reload, cleared once sending succeeds ——
|
||||
await ta.fill("会话里的草稿");
|
||||
await ta.fill("Draft inside the session");
|
||||
const sessionKey = `penguin.chatDraft.session.${userId}.${secondSessionId}`;
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey))
|
||||
.toContain("会话里的草稿");
|
||||
.toContain("Draft inside the session");
|
||||
|
||||
await page.reload();
|
||||
await expect(ta).toHaveValue("会话里的草稿");
|
||||
await expect(ta).toHaveValue("Draft inside the session");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect.poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey)).toBeNull();
|
||||
});
|
||||
@@ -40,12 +40,14 @@ test("a malformed tool_call settles unpaired; the retry line shows and the retry
|
||||
const sessionId = sess.session.sessionId;
|
||||
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
await page.getByPlaceholder(/输入消息/).fill("坏流测试");
|
||||
await page.getByPlaceholder(/输入消息/).fill("bad stream test");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
// The retried exec_command finishes → final answer, proving the retry and the following
|
||||
// tool call complete normally.
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// Retry hint line: set to "retry attempt 1 started" once request_begin arrives.
|
||||
await expect(page.getByText(/已发起第 1 次重试/)).toBeVisible();
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* Mock Anthropic Messages API (streaming SSE) for E2E.
|
||||
* Branches on request body:
|
||||
* - title request (prompt contains "concise title") -> short text
|
||||
* - files-card probe ("文件卡测试") -> text with two backtick paths (one real, one missing)
|
||||
* - files-card probe ("files card test") -> text with two backtick paths (one real, one missing)
|
||||
* - subagent's own turn (its prompt is the only user text) -> final text
|
||||
* - parent asked to delegate ("跑子agent") -> tool_use(run_subagent)
|
||||
* - parent asked to delegate ("run a subagent") -> tool_use(run_subagent)
|
||||
* - last message has tool_result -> final text (turn 2)
|
||||
* - otherwise (first user turn) -> thinking + text + tool_use(exec_command)
|
||||
*/
|
||||
import http from "node:http";
|
||||
|
||||
/** The run_subagent prompt; also the marker the mock uses to detect "this is the child session's own request". */
|
||||
const SUBAGENT_PROMPT = "统计仓库里的 TODO 数量";
|
||||
const SUBAGENT_PROMPT = "Count the TODO items in the repository";
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT || 8931);
|
||||
|
||||
@@ -87,15 +87,15 @@ const server = http.createServer((req, res) => {
|
||||
const msgCount = messages.length;
|
||||
const hasToolResult = flat.includes("tool_result");
|
||||
// Child-session request: the context has only the prompt handed down by run_subagent, no parent user input.
|
||||
const isSubagentTurn = flat.includes(SUBAGENT_PROMPT) && !flat.includes("跑子agent");
|
||||
const wantsSubagent = flat.includes("跑子agent");
|
||||
const isSubagentTurn = flat.includes(SUBAGENT_PROMPT) && !flat.includes("run a subagent");
|
||||
const wantsSubagent = flat.includes("run a subagent");
|
||||
// "Bad stream" test case: the first request streams half the tool_use arguments then cuts
|
||||
// the connection (no message_stop), so AgentHub reports "stream incomplete" -> GenerativeModel
|
||||
// resolves it as malformed. On reconnect the engine **resends the input verbatim** — in this
|
||||
// scenario the failed attempt only has a half tool_call (never committed to the ledger), so
|
||||
// the retry request carries no <turn_retried> block and is byte-for-byte identical to the
|
||||
// first request; the mock can only tell them apart by request count (see the malformedTurns counter).
|
||||
const wantsMalformed = flat.includes("坏流测试");
|
||||
const wantsMalformed = flat.includes("bad stream test");
|
||||
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
@@ -109,9 +109,12 @@ const server = http.createServer((req, res) => {
|
||||
// assistant material; respond with a distinguishable title based on that, so the E2E
|
||||
// test can prove the child session's title really comes from its own conversation,
|
||||
// not from the run_subagent prompt.
|
||||
const forSubagent = flat.includes("子 agent 报告");
|
||||
const forSubagent = flat.includes("Subagent report");
|
||||
block(res, 0, { type: "text", text: "" }, [
|
||||
{ type: "text_delta", text: forSubagent ? "子 agent 的 TODO 统计" : "配置 Tailwind 主题" },
|
||||
{
|
||||
type: "text_delta",
|
||||
text: forSubagent ? "Subagent TODO summary" : "Configure Tailwind theme",
|
||||
},
|
||||
]);
|
||||
messageStop(res, "end_turn", 8);
|
||||
return;
|
||||
@@ -122,11 +125,11 @@ const server = http.createServer((req, res) => {
|
||||
// missing-report.pdf doesn't exist; the card should only list the former. This branch
|
||||
// must be checked before hasToolResult (the same session's history already has a
|
||||
// first-round tool_result).
|
||||
if (flat.includes("文件卡测试")) {
|
||||
if (flat.includes("files card test")) {
|
||||
block(res, 0, { type: "text", text: "" }, [
|
||||
{
|
||||
type: "text_delta",
|
||||
text: "报告已生成:`demo.html`;另一个文件 `missing-report.pdf` 并不存在。",
|
||||
text: "Report generated: `demo.html`; the other file `missing-report.pdf` does not exist.",
|
||||
},
|
||||
]);
|
||||
messageStop(res, "end_turn", 18);
|
||||
@@ -166,7 +169,7 @@ const server = http.createServer((req, res) => {
|
||||
|
||||
if (isSubagentTurn) {
|
||||
block(res, 0, { type: "text", text: "" }, [
|
||||
{ type: "text_delta", text: "子 agent 报告:3 处 TODO" },
|
||||
{ type: "text_delta", text: "Subagent report: 3 TODOs" },
|
||||
]);
|
||||
messageStop(res, "end_turn", 12);
|
||||
return;
|
||||
@@ -184,8 +187,8 @@ const server = http.createServer((req, res) => {
|
||||
if (hasToolResult) {
|
||||
// Turn 2: final answer text.
|
||||
block(res, 0, { type: "text", text: "" }, [
|
||||
{ type: "text_delta", text: "命令已执行完成," },
|
||||
{ type: "text_delta", text: "结果符合预期。" },
|
||||
{ type: "text_delta", text: "Command finished; " },
|
||||
{ type: "text_delta", text: "the result looks as expected." },
|
||||
]);
|
||||
messageStop(res, "end_turn", 20);
|
||||
return;
|
||||
@@ -199,15 +202,15 @@ const server = http.createServer((req, res) => {
|
||||
0,
|
||||
{ type: "thinking", thinking: "" },
|
||||
[
|
||||
{ type: "thinking_delta", thinking: "先看看" },
|
||||
{ type: "thinking_delta", thinking: "目录结构" },
|
||||
{ type: "thinking_delta", thinking: "Let me look at " },
|
||||
{ type: "thinking_delta", thinking: "the directory structure" },
|
||||
],
|
||||
{ type: "signature_delta", signature: "sig_mock_abc" },
|
||||
),
|
||||
);
|
||||
steps.push(() =>
|
||||
block(res, 1, { type: "text", text: "" }, [
|
||||
{ type: "text_delta", text: "我来运行一个命令查看。" },
|
||||
{ type: "text_delta", text: "I'll run a command to check." },
|
||||
]),
|
||||
);
|
||||
steps.push(() =>
|
||||
|
||||
@@ -20,7 +20,9 @@ const U = "projswitcher";
|
||||
const P = "password123";
|
||||
const PROJ = "SwitchTarget";
|
||||
|
||||
test("下拉里点当前 Project:Agent 与 Session 列表不消失", async ({ page }) => {
|
||||
test("clicking the current Project in the dropdown: Agent and Session lists must not disappear", async ({
|
||||
page,
|
||||
}) => {
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
|
||||
const initial = (await (await page.request.get(`${BASE}/api/projects`)).json()).projects[0];
|
||||
|
||||
@@ -43,7 +43,7 @@ test("mobile Files sheet: open → nested dir → md rendered preview → esc cl
|
||||
).json();
|
||||
const sessionId = sess.session.sessionId;
|
||||
|
||||
const md = Buffer.from("# Sheet 标题\n\n正文段落。").toString("base64");
|
||||
const md = Buffer.from("# Sheet Heading\n\nBody paragraph.").toString("base64");
|
||||
const up = await page.request.put(
|
||||
`${BASE}/api/sessions/${sessionId}/files/content?path=notes/demo.md`,
|
||||
{ data: { dataBase64: md } },
|
||||
@@ -58,7 +58,7 @@ test("mobile Files sheet: open → nested dir → md rendered preview → esc cl
|
||||
// Drill into the directory → open the md → default rendered view (h1 shown, not source).
|
||||
await sheet.getByText("notes").first().click();
|
||||
await sheet.getByText("demo.md").first().click();
|
||||
await expect(sheet.getByRole("heading", { name: "Sheet 标题" })).toBeVisible();
|
||||
await expect(sheet.getByRole("heading", { name: "Sheet Heading" })).toBeVisible();
|
||||
|
||||
// Esc closes: unmounts entirely once the exit animation finishes.
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -58,7 +58,7 @@ const SKILLS = [
|
||||
// and the card renders a custom icon.svg — neither spot should show this fallback path.
|
||||
const BOOK_PATH_PREFIX = "M2 3h6a4";
|
||||
|
||||
test("skills: 技能库分组与卡片 → 管理安装 Modal → 快捷调用预填 → 下拉过滤与 slash 选中", async ({
|
||||
test("skills: library groups and cards -> manage-install Modal -> quick-invoke prefill -> dropdown filter and slash selection", async ({
|
||||
page,
|
||||
}) => {
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
@@ -225,7 +225,7 @@ test("skills: 技能库分组与卡片 → 管理安装 Modal → 快捷调用
|
||||
await expect(skillsBtn).not.toContainText("1");
|
||||
|
||||
// The mock LLM's fallback reply completes a full round (allow-all auto-approves exec_command).
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
|
||||
// —— Slash invocation: typing /agent-opt shows a skill command item; pressing Enter selects it and clears the input box (without sending) ——
|
||||
await ta.fill("/agent-opt");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* session_meta pointer for the child session; the server's readMessages expands the child Trace
|
||||
* from that pointer and fills in the origin chain, and the frontend reattaches the child session
|
||||
* to the tool card from that. Also verifies the child session's title is generated by the model
|
||||
* from **the child session's own conversation** (the mock only replies with "child agent TODO
|
||||
* from **the child session's own conversation** (the mock only replies with "Subagent TODO
|
||||
* summary" when the title request sees the child's own answer), and that discovery-based
|
||||
* backfill must not collapse it to NULL.
|
||||
*
|
||||
@@ -60,13 +60,13 @@ test("subagent card survives a reload; child session title is generated from its
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("跑子agent");
|
||||
await ta.fill("run a subagent");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
// Parent's final answer (turn 2): the whole turn has ended. Nothing changes with streaming
|
||||
// after this, so assertions are deterministic (turn end collapses the "Reasoning & tools"
|
||||
// group, and child session end collapses the child session card).
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
|
||||
/** Expand the "Reasoning & tools" group → expand the child session card → child session body visible. */
|
||||
const openSubagent = async () => {
|
||||
@@ -78,7 +78,7 @@ test("subagent card survives a reload; child session title is generated from its
|
||||
const card = page.getByRole("button", { name: /子会话/ }).first();
|
||||
await expect(card).toBeVisible();
|
||||
await card.click();
|
||||
await expect(page.getByText("子 agent 报告:3 处 TODO").first()).toBeVisible();
|
||||
await expect(page.getByText("Subagent report: 3 TODOs").first()).toBeVisible();
|
||||
};
|
||||
|
||||
// Under live streaming, the child session card is nested inside the run_subagent tool card.
|
||||
@@ -95,5 +95,5 @@ test("subagent card survives a reload; child session title is generated from its
|
||||
).json();
|
||||
return list.sessions.find((s) => s.sessionId !== sessionId)?.title ?? null;
|
||||
};
|
||||
await expect.poll(childTitle, { timeout: 10000 }).toBe("子 agent 的 TODO 统计");
|
||||
await expect.poll(childTitle, { timeout: 10000 }).toBe("Subagent TODO summary");
|
||||
});
|
||||
Reference in new issue
Block a user