Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Web E2E(Playwright)
|
||||
|
||||
浏览器端到端:对话(thinking + 工具审批 + 工具执行 + 二轮答复)、图标化统计(成本折算 /
|
||||
复制回复)、轨迹观测(分 Task 时间线 + 图例 + 悬停联动高亮)、Workspace 文件预览(HTML
|
||||
sandbox 渲染、路径默认隐藏)。LLM 由 `mock-llm.mjs`(mock Anthropic Messages SSE)驱动,
|
||||
不联网。
|
||||
|
||||
```sh
|
||||
pnpm --filter @prismshadow/penguin-web test:e2e # 构建 + 起服务 + 跑用例
|
||||
SKIP_BUILD=1 pnpm --filter @prismshadow/penguin-web test:e2e # 跳过构建
|
||||
```
|
||||
|
||||
首次需 `npx playwright install chromium`。
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* e2e auth helper: with signup disabled, test users are always provisioned via
|
||||
* the built-in admin account, then logged in. The server seeds an admin
|
||||
* (admin / admin123) on startup; a single e2e run shares one data root, and
|
||||
* provisioning is idempotent (reuses the user if it already exists) so a
|
||||
* single spec can be rerun on its own.
|
||||
*/
|
||||
import { request } from "@playwright/test";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
export const ADMIN_ID = "admin";
|
||||
export const ADMIN_PASSWORD = "admin123";
|
||||
|
||||
/** Log in: the cookie lands in the given request context (page.request is the browser context); returns user. */
|
||||
export async function login(ctx, userId, password) {
|
||||
const res = await ctx.post(`${BASE}/api/auth/login`, { data: { userId, password } });
|
||||
if (!res.ok()) {
|
||||
throw new Error(`login ${userId} failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
return (await res.json()).user;
|
||||
}
|
||||
|
||||
/** Admin creates the user (409 is treated as already-exists, idempotent). */
|
||||
export async function provisionUser(userId, password) {
|
||||
const adminCtx = await request.newContext();
|
||||
await login(adminCtx, ADMIN_ID, ADMIN_PASSWORD);
|
||||
const created = await adminCtx.post(`${BASE}/api/admin/users`, { data: { userId, password } });
|
||||
if (!created.ok() && created.status() !== 409) {
|
||||
throw new Error(`create user ${userId} failed: ${created.status()} ${await created.text()}`);
|
||||
}
|
||||
await adminCtx.dispose();
|
||||
}
|
||||
|
||||
/** Provision the user and log ctx in as them; returns user. */
|
||||
export async function provisionAndLogin(ctx, userId, password) {
|
||||
await provisionUser(userId, password);
|
||||
return login(ctx, userId, password);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
const U = "e2euser";
|
||||
const P = "password123";
|
||||
|
||||
test("chat + tool approval + stats/cost/copy + traces + files", async ({ page }) => {
|
||||
// --- seed via API (cookies land in the browser context) ---
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
|
||||
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
pricing: { cacheRead: 1, cacheWrite: 5, output: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.ok(), "put models").toBeTruthy();
|
||||
|
||||
const agents = await (await page.request.get(`${BASE}/api/projects/${projectId}/agents`)).json();
|
||||
// The project ships with exactly one builtin agent: default_agent.
|
||||
const agentIds = agents.agents.map((a) => a.agentId);
|
||||
expect(agentIds).toEqual(["default_agent"]);
|
||||
const agentId = "default_agent";
|
||||
|
||||
// Approval defaults to allow-all; this test verifies the manual approval flow, so specify always-ask explicitly.
|
||||
const sess = await (
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`, {
|
||||
data: { provider: "custom", modelId: "claude-4-8", approvalMode: "always-ask" },
|
||||
})
|
||||
).json();
|
||||
const sessionId = sess.session.sessionId;
|
||||
|
||||
// --- chat ---
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
|
||||
// Approval-mode is a custom dropdown (button), NOT a native <select>.
|
||||
await expect(page.locator("select")).toHaveCount(0);
|
||||
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
// Tool name is shown on the collapsed tool row + in the pending-approval block.
|
||||
await expect(page.getByText("exec_command").first()).toBeVisible();
|
||||
// Thinking + tool calls are wrapped in a work group; header shows running/done status.
|
||||
await expect(page.getByText("运行中").first()).toBeVisible();
|
||||
// The user takes control of the running work group (toggle = userToggled), keeps it open, and
|
||||
// opens the exec_command card to watch the arguments. Both must survive the end of the turn.
|
||||
//
|
||||
// The group deliberately auto-collapses once it is no longer the last segment — but only when the
|
||||
// user has NOT toggled it (WorkGroup keeps that in a ref). A remount wipes both the ref and the
|
||||
// open state, so the group would collapse anyway and take the card down with it (the body is
|
||||
// conditionally rendered). That is what happens if the turn's `group` container is created only
|
||||
// when the stats line lands: the already-rendered work group moves into a new parent, React
|
||||
// unmounts and remounts it, and the user's open card snaps shut the instant the reply finishes.
|
||||
// (aria-expanded can't be asserted in between: a pending approval force-shows the body no matter
|
||||
// what `open` says — the approval row lives in there.)
|
||||
const runningGroup = page.locator("button[aria-expanded]").filter({ hasText: "运行中" }).first();
|
||||
await runningGroup.click(); // toggle → marks the group user-toggled (open := false)
|
||||
await runningGroup.click(); // toggle back → the user is deliberately keeping it open
|
||||
|
||||
const toolCard = page
|
||||
.locator("button[aria-expanded]")
|
||||
.filter({ hasText: "exec_command" })
|
||||
.first();
|
||||
await toolCard.click();
|
||||
await expect(toolCard).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
await page.getByRole("button", { name: "允许" }).click();
|
||||
|
||||
// Final assistant answer (turn 2 from mock).
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).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
|
||||
// anchored to the initial containing block, bypassing the panel's overflow-hidden and
|
||||
// stretching the document wide enough to create a horizontal scrollbar).
|
||||
const docWidth = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(docWidth.scrollWidth, "no horizontal page overflow").toBeLessThanOrEqual(
|
||||
docWidth.clientWidth,
|
||||
);
|
||||
|
||||
// Turn is over and the stats line has landed. The card is still open — which also proves the
|
||||
// group did not collapse (a collapsed group unmounts its body, so the card would be gone).
|
||||
await expect(
|
||||
page.locator("button[aria-expanded]").filter({ hasText: "exec_command" }).first(),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// The stats line IS the AI reply's footer (bottom-left, mirroring the user footer's bottom-right):
|
||||
// stats + reply-time + copy, the whole line hover-gated. Scope assertions to it (NOT page-wide):
|
||||
// the top bar also renders a session cost, so a page-wide /$0.\d+/ would match the header and
|
||||
// prove nothing about this line.
|
||||
const copyBtn = page.getByRole("button", { name: "复制回复" }).first();
|
||||
const statsLine = copyBtn.locator("xpath=..");
|
||||
await expect(statsLine.locator("text=/\\$0\\.\\d+/")).toBeVisible(); // cost chip (pricing set)
|
||||
|
||||
// Hidden by default but SPACE-RESERVED (opacity-0, not hidden). Park the cursor first: after
|
||||
// clicking 允许 ("Allow") the pointer sits where that button was and the re-render can drop a
|
||||
// hoverable row right under it, which would flake the "hidden" assert.
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(statsLine).toHaveCSS("opacity", "0");
|
||||
expect(
|
||||
await copyBtn.boundingBox(),
|
||||
"footer must occupy space even while invisible",
|
||||
).not.toBeNull();
|
||||
|
||||
// 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 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("命令已执行完成");
|
||||
|
||||
// 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
|
||||
// buttons. So the only per-message footer is the user's.
|
||||
await expect(page.getByRole("button", { name: "复制消息" })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "复制回复" })).toHaveCount(1);
|
||||
|
||||
// User-message footer: hidden by default but SPACE-RESERVED (opacity-0, not hidden) — with
|
||||
// `hidden` the footer would appear on hover and shove everything below it down, making the list
|
||||
// jitter as the mouse crosses it.
|
||||
const msgCopy = page.getByRole("button", { name: "复制消息" });
|
||||
const meta = msgCopy.locator("xpath=..");
|
||||
await expect(meta).toHaveCSS("opacity", "0");
|
||||
expect(
|
||||
await msgCopy.boundingBox(),
|
||||
"footer must occupy space even while invisible",
|
||||
).not.toBeNull();
|
||||
|
||||
// Hover the user bubble → its footer fades in (human-readable time + copy).
|
||||
await page.getByText("帮我配置 @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");
|
||||
|
||||
// --- 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();
|
||||
await expect(titleNode).toBeVisible();
|
||||
await titleNode.click();
|
||||
await expect(main.getByText("轨迹观测").first()).toBeVisible();
|
||||
// Legend shows the 5 fixed categories (tool-exec renamed).
|
||||
await expect(main.getByText("工具调用执行").first()).toBeVisible();
|
||||
await expect(main.getByText("模型回复").first()).toBeVisible();
|
||||
// Per-task label.
|
||||
await expect(main.getByText("第 1 轮").first()).toBeVisible();
|
||||
// Global summary is grouped (counts / tokens / time-cost-TPS). The old "Request 耗时" item is
|
||||
// gone — its label said "duration" while it actually rendered a count.
|
||||
await expect(main.getByText("全局统计")).toBeVisible();
|
||||
await expect(main.getByText("Request 耗时")).toHaveCount(0);
|
||||
for (const label of ["轮次", "工具调用", "压缩次数", "输入 tokens", "输出 TPS"]) {
|
||||
await expect(main.getByText(label, { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
// Time-axis zoom: Premiere-style scrubber (role=scrollbar) + −/+ buttons.
|
||||
await expect(main.getByText("缩放", { exact: true })).toBeVisible();
|
||||
await expect(main.getByRole("scrollbar").first()).toBeVisible();
|
||||
await expect(main.getByText("1.00×")).toBeVisible();
|
||||
const timeline = main.locator(".no-scrollbar.overflow-x-auto").first();
|
||||
// Wheel-to-zoom is deliberately NOT supported (#58): scrolling the page over the timeline must
|
||||
// not change the zoom by accident — the ratio stays at 1.00×. Settle first, otherwise the
|
||||
// assertion could pass on its first poll before a (regressed) wheel handler re-rendered.
|
||||
await timeline.hover();
|
||||
await page.mouse.wheel(0, -240);
|
||||
await page.waitForTimeout(300);
|
||||
await expect(main.getByText("1.00×")).toBeVisible();
|
||||
// Zoom is still reachable through the + button (1 × 1.4 = 1.40×).
|
||||
await main.getByRole("button", { name: "放大" }).click();
|
||||
await expect(main.getByText("1.00×")).toHaveCount(0);
|
||||
await expect(main.getByText("1.40×")).toBeVisible();
|
||||
// Timeline scroll container disables the vertical scrollbar (only horizontal on zoom).
|
||||
const overflowY = await timeline.evaluate((el) => getComputedStyle(el).overflowY);
|
||||
expect(overflowY).toBe("hidden");
|
||||
// Hover a timeline tool-exec segment → the matching event row highlights (cross-link).
|
||||
const seg = main.getByTitle(/exec_command · 工具调用执行/).first();
|
||||
await expect(seg).toBeVisible();
|
||||
await seg.hover();
|
||||
await expect(main.locator("button.bg-amber-50").first()).toBeVisible();
|
||||
|
||||
// --- files: HTML preview (scripts run in sandbox, localStorage shim) + path hidden ---
|
||||
// Script uses localStorage: without the shim it would throw SecurityError (opaque origin);
|
||||
// with the shim it runs and appends #shim-ok.
|
||||
const html = Buffer.from(
|
||||
"<html><body><h1>Hello E2E</h1><script>localStorage.setItem('e2e','1');" +
|
||||
"document.body.insertAdjacentHTML('beforeend','<p id=\\'shim-ok\\'>'+localStorage.getItem('e2e')+'</p>')" +
|
||||
"</script></body></html>",
|
||||
).toString("base64");
|
||||
const up = await page.request.put(
|
||||
`${BASE}/api/sessions/${sessionId}/files/content?path=demo.html`,
|
||||
{ data: { dataBase64: html } },
|
||||
);
|
||||
expect(up.ok(), "upload html").toBeTruthy();
|
||||
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
await page.getByRole("button", { name: "打开工作区" }).click();
|
||||
const fileNode = page.getByText("demo.html").first();
|
||||
await expect(fileNode).toBeVisible();
|
||||
await fileNode.click();
|
||||
// Rendered iframe present; the localStorage script ran (shim) → #shim-ok appended.
|
||||
await expect(page.locator("iframe")).toBeVisible();
|
||||
await expect(page.frameLocator("iframe").locator("#shim-ok")).toHaveText("1");
|
||||
// The list and the preview are mutually exclusive: the toolbar only appears after
|
||||
// returning to the list (which also triggers #59's return-refresh).
|
||||
await page.getByRole("button", { name: "返回列表" }).click();
|
||||
await expect(page.getByText("demo.html").first()).toBeVisible();
|
||||
// Workspace path is hidden until the 详情 ("Details") toggle is used.
|
||||
const workspaceAbs = sess.session.workspace;
|
||||
await expect(page.getByText(workspaceAbs, { exact: false })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "详情" }).click();
|
||||
await expect(page.getByText(workspaceAbs, { exact: false }).first()).toBeVisible();
|
||||
|
||||
// --- message files card: only files that really exist in the workspace make the card ---
|
||||
// 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 page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText(/报告已生成/)).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.
|
||||
const cardRow = page.locator('button[title="demo.html"]').filter({ hasText: "点击预览" });
|
||||
await expect(cardRow).toBeVisible();
|
||||
await expect(page.locator('button[title="missing-report.pdf"]')).toHaveCount(0);
|
||||
// Clicking the row opens the Files panel preview via the normalized relative path.
|
||||
await cardRow.click();
|
||||
await expect(page.locator("iframe")).toBeVisible();
|
||||
await expect(page.frameLocator("iframe").locator("#shim-ok")).toHaveText("1");
|
||||
|
||||
// --- sidebar collapse / expand ---
|
||||
await page.getByRole("button", { name: "收起侧栏" }).click();
|
||||
await expect(page.getByRole("button", { name: "展开侧栏" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "展开侧栏" }).click();
|
||||
await expect(page.getByRole("button", { name: "收起侧栏" })).toBeVisible();
|
||||
|
||||
const sidebar = page.getByRole("complementary");
|
||||
|
||||
// --- 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 sidebar.locator('button[aria-label="折叠"]').first().click();
|
||||
await expect(sidebar.getByText("配置 Tailwind 主题")).toHaveCount(0);
|
||||
await sidebar.locator('button[aria-label="展开"]').first().click();
|
||||
await expect(sidebar.getByText("配置 Tailwind 主题")).toBeVisible();
|
||||
|
||||
// --- settings: adjustable accent color + font size (default gray/white) ---
|
||||
// The username button shares its name with the Project switcher (the initial Project's
|
||||
// display name defaults to the username), so take the last match — the bottom user menu.
|
||||
await page.getByRole("button", { name: "e2euser" }).last().click();
|
||||
await page.getByRole("button", { name: "蓝", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.dataset.accent))
|
||||
.toBe("blue");
|
||||
await page.getByRole("button", { name: "大", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.fontSize))
|
||||
.toBe("20px");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// --- session rename (manual title wins over the auto-generated one) ---
|
||||
const renameTarget = sidebar.locator("li", { hasText: "配置 Tailwind 主题" }).first();
|
||||
await renameTarget.hover();
|
||||
await renameTarget.getByRole("button", { name: "重命名对话" }).click();
|
||||
await page.getByLabel("标题").fill("我改的标题");
|
||||
await page.getByRole("button", { name: "保存" }).click();
|
||||
await expect(sidebar.getByText("我改的标题")).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(sidebar.getByText("我改的标题")).toBeVisible();
|
||||
|
||||
// --- session archive + delete (throwaway session) ---
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`, {
|
||||
data: { provider: "custom", modelId: "claude-4-8" },
|
||||
});
|
||||
await page.reload();
|
||||
const throwaway = sidebar.locator("li", { hasText: "新对话" }).first();
|
||||
await expect(throwaway).toBeVisible();
|
||||
// Archive: moves it under the collapsed "已归档" group.
|
||||
await throwaway.hover();
|
||||
await throwaway.getByRole("button", { name: "归档", exact: true }).click();
|
||||
await expect(sidebar.getByText(/已归档(\d+)/).first()).toBeVisible();
|
||||
await sidebar
|
||||
.getByText(/已归档(\d+)/)
|
||||
.first()
|
||||
.click();
|
||||
const archived = sidebar.locator("li", { hasText: "新对话" }).first();
|
||||
await expect(archived).toBeVisible();
|
||||
// Delete from the archived group (delete + archive share one action group).
|
||||
await archived.hover();
|
||||
await archived.getByRole("button", { name: "删除对话" }).click();
|
||||
await page.getByRole("button", { name: "删除", exact: true }).click();
|
||||
await expect(sidebar.getByText("新对话")).toHaveCount(0);
|
||||
|
||||
// --- session expiry: any 401 sends the user back to /login (no stuck error page) ---
|
||||
// Clearing the cookie is what a rebuilt web.db looks like to the browser.
|
||||
await page.context().clearCookies();
|
||||
// Stay in the SPA: navigating to 成本中心 ("Cost Center") fires GET /usage -> 401 -> global redirect.
|
||||
await page.getByRole("link", { name: "成本中心" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.locator("form").getByRole("button", { name: "登录" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* `/compact` after an abort: it must give clear feedback instead of silently doing nothing.
|
||||
*
|
||||
* When the first request is aborted, token_usage was never received -> core's sessionTurns is
|
||||
* still 0 -> `compact()` returns immediately, **producing no message at all**. The server used
|
||||
* to still return 202, so the frontend would wait forever for a compaction banner that never
|
||||
* comes — from the user's perspective, it just looks like "nothing happened after sending the
|
||||
* compact command." Now the server explicitly returns 409 based on `session.compactability()`.
|
||||
*
|
||||
* Second test case: compacting twice in a row. The internal state is identical to the above
|
||||
* (sessionTurns === 0), but the message the user should see is completely different — they just
|
||||
* finished a full round and just compacted, so being told "no completed conversation round yet"
|
||||
* would be absurd and effectively say nothing useful.
|
||||
*
|
||||
* This file is deliberately named to sort after chat.spec: the first user registered becomes the
|
||||
* admin, which chat.spec relies on, and Playwright runs spec files in filename order (the other
|
||||
* specs also sort after it).
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
const U = "abortcompact";
|
||||
const P = "password123";
|
||||
|
||||
test("aborting before any turn completes: /compact says so instead of doing nothing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
pricing: { cacheRead: 1, cacheWrite: 5, output: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// always-ask: while approval is pending the request hasn't closed yet; aborting now means token_usage was never produced.
|
||||
const sess = await (
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
|
||||
data: { provider: "custom", modelId: "claude-4-8", approvalMode: "always-ask" },
|
||||
})
|
||||
).json();
|
||||
const sessionId = sess.session.sessionId;
|
||||
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @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
|
||||
// suspended at `yield`. This is where an abort most commonly lands, and it used to be the
|
||||
// trigger for a **permanent session hang** — if the abort still went back to pull from the
|
||||
// already-aborted upstream stream, `it.next()` would never settle, the run would never close,
|
||||
// and the Session would stay stuck at running: the frontend's /compact is gated behind
|
||||
// `!running`, so pressing it would do nothing at all (see the abort pre-check in core's
|
||||
// generative-model).
|
||||
await expect(page.getByRole("button", { name: "允许" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "停止" }).click();
|
||||
// The abort must converge back to idle (don't wait for "发送" ("Send") — it only appears
|
||||
// when the input box has content, and the input box is empty at this point).
|
||||
await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);
|
||||
|
||||
// The actual user path: type /compact in the input box and press Enter (slash menu).
|
||||
// Feedback goes through a **top toast** (the Toaster from components/ui/toast; the toast
|
||||
// itself is a clickable, dismissible button), not a native browser alert — the latter would
|
||||
// trigger Playwright's dialog event, which lets us assert we didn't fall back to it.
|
||||
const nativeDialogs = [];
|
||||
page.on("dialog", (d) => {
|
||||
nativeDialogs.push(d.message());
|
||||
void d.dismiss();
|
||||
});
|
||||
await ta.fill("/compact");
|
||||
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);
|
||||
// And indeed no compaction was started (no banner).
|
||||
await expect(page.getByText(/\[压缩\]/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("compacting twice in a row: says the context was just compacted, not that nothing ever ran", async ({
|
||||
page,
|
||||
}) => {
|
||||
const U2 = "dblcompact";
|
||||
await provisionAndLogin(page.request, U2, P);
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
pricing: { cacheRead: 1, cacheWrite: 5, output: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const sess = await (
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
|
||||
data: { provider: "custom", modelId: "claude-4-8", approvalMode: "allow-all" },
|
||||
})
|
||||
).json();
|
||||
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
|
||||
const nativeDialogs = [];
|
||||
page.on("dialog", (d) => {
|
||||
nativeDialogs.push(d.message());
|
||||
void d.dismiss();
|
||||
});
|
||||
|
||||
// First /compact: compacts normally, shows the banner, no toast expected.
|
||||
await ta.fill("/compact");
|
||||
await ta.press("Enter");
|
||||
await expect(page.getByText(/\[压缩\]/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /压缩/ })).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();
|
||||
// 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.getByText(/\[压缩\]/)).toHaveCount(1); // no second compaction banner
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Auto-compaction triggered at the **end** of a round: item order is
|
||||
* assistant_text -> compaction -> task_stats, with the compaction banner sandwiched between
|
||||
* the reply and its stats line.
|
||||
*
|
||||
* This ordering used to break the "reply + stats line" pairing, leaving the stats line
|
||||
* orphaned — and since the whole line is transparent by default, being orphaned meant it was
|
||||
* neither visible nor hoverable (the element's own `group` class has no effect on itself:
|
||||
* group-hover is a descendant selector). Now the entire round's AI-side content, stats line
|
||||
* included, is wrapped in the same group, so hovering any content in the round reveals it.
|
||||
*
|
||||
* Trigger mechanism: the mock's usage grows with context length, pinning the threshold between
|
||||
* "below the first request" and "above the second request (which carries the tool result)", so
|
||||
* compaction only happens once, at the end of the round.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
const U = "compactuser";
|
||||
const P = "password123";
|
||||
/** Context window: the engine takes 75% as the compaction threshold (240 x 0.75 = 180), landing between the two requests' usage. */
|
||||
const CONTEXT_WINDOW = 240;
|
||||
|
||||
test("compaction mid-turn: the reply's stats line is still reachable by hovering the reply", async ({
|
||||
page,
|
||||
}) => {
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: CONTEXT_WINDOW,
|
||||
pricing: { cacheRead: 1, cacheWrite: 5, output: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const sess = await (
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
|
||||
data: { provider: "custom", modelId: "claude-4-8", approvalMode: "allow-all" },
|
||||
})
|
||||
).json();
|
||||
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
const reply = page.getByText("命令已执行完成,结果符合预期。").first();
|
||||
await expect(reply).toBeVisible();
|
||||
const banner = page.getByText(/\[压缩\]/).first();
|
||||
await expect(banner).toBeVisible();
|
||||
|
||||
// The compaction banner doesn't show Token counts: compaction at round end isn't attributed
|
||||
// to this round (its usage shows up in the Session total and the Trace page's compaction-round
|
||||
// card instead) — the banner only states "compaction happened, succeeded or not."
|
||||
await expect(page.getByText(/\[压缩\].*tokens/)).toHaveCount(0);
|
||||
|
||||
// Order: reply -> **this round's stats line** -> compaction banner. The stats line is about
|
||||
// this round of conversation; compaction is housekeeping outside this round, listed after this
|
||||
// round's tally. (It used to be the other way around: the banner sat between the reply and the
|
||||
// stats line, making the stats line read like "compaction's stats," and this round's elapsed
|
||||
// time would fold in the entire compaction request — compaction is itself a full LLM request.)
|
||||
const copyBtn = page.getByRole("button", { name: "复制回复" }).first();
|
||||
const statsLine = copyBtn.locator("xpath=..");
|
||||
const yOf = async (l) => (await l.boundingBox()).y;
|
||||
expect(await yOf(statsLine)).toBeGreaterThan(await yOf(reply));
|
||||
expect(await yOf(banner)).toBeGreaterThan(await yOf(statsLine));
|
||||
|
||||
// Transparent by default but space-reserved; hovering **the reply itself** reveals it
|
||||
// (it used to be orphaned -> never revealable).
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(statsLine).toHaveCSS("opacity", "0");
|
||||
await reply.hover();
|
||||
await expect(statsLine).toHaveCSS("opacity", "1");
|
||||
await expect(statsLine.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible();
|
||||
|
||||
// Compaction succeeded and no ordinary request has reported usage since, so the context ring must
|
||||
// read UNKNOWN (`—`), not 0. Zero would claim the context is empty — but the summary itself costs
|
||||
// tokens; we simply have not measured the new size yet.
|
||||
await expect(page.getByText(`—/${CONTEXT_WINDOW}`)).toBeVisible();
|
||||
await expect(page.getByText(`0/${CONTEXT_WINDOW}`)).toHaveCount(0);
|
||||
|
||||
// —— Trace page ——
|
||||
// Compaction is its own round: the user round's elapsed time and TPS don't include compaction
|
||||
// (matching the chat page's convention), and the compaction round has **its own TPS** (how
|
||||
// fast the summary was generated), not a "—".
|
||||
await page.goto(`${BASE}/traces`);
|
||||
const main = page.locator("main");
|
||||
const node = main.getByText(/配置|新对话/).first();
|
||||
await expect(node).toBeVisible();
|
||||
await node.click();
|
||||
await expect(main.getByText("第 1 轮")).toBeVisible();
|
||||
await expect(main.getByText("第 2 轮")).toBeVisible();
|
||||
const roundRows = main.locator("button").filter({ hasText: /第 \d 轮/ });
|
||||
const compactionRow = roundRows.filter({ hasText: "第 2 轮" }).first();
|
||||
await expect(compactionRow).toContainText("tok/s"); // the compaction round has its own TPS
|
||||
await expect(compactionRow).not.toContainText("—");
|
||||
|
||||
// Global elapsed time = **the sum of each round's elapsed time (including the compaction
|
||||
// round)**, matching the same scope as the per-round display below — adding up each round
|
||||
// card's elapsed time must equal the total. But it's not "last message - first message" (that
|
||||
// would include the gap while the user was thinking/away between rounds, which isn't the
|
||||
// Agent's working time). Assert this property directly against the API — the elapsed time
|
||||
// shown in the UI is rounded by humanizeDuration, so parsing it back out of the text would be
|
||||
// both fragile and inaccurate.
|
||||
const traces = await (
|
||||
await page.request.get(`${BASE}/api/projects/${projectId}/agents/default_agent/traces`)
|
||||
).json();
|
||||
const file = traces.dates[0].sessions[0].files[0];
|
||||
const analysis = await (
|
||||
await page.request.get(
|
||||
`${BASE}/api/projects/${projectId}/agents/default_agent/traces/${sess.session.sessionId}/${file.index}/analysis`,
|
||||
)
|
||||
).json();
|
||||
expect(analysis.tasks).toHaveLength(2);
|
||||
|
||||
// The compaction round is flagged as "compaction" and counts toward the total elapsed time
|
||||
// just like a user round; each has an elapsed time greater than 0.
|
||||
const userTasks = analysis.tasks.filter((t) => t.compaction !== true);
|
||||
const compactionTasks = analysis.tasks.filter((t) => t.compaction === true);
|
||||
expect(userTasks).toHaveLength(1);
|
||||
expect(compactionTasks).toHaveLength(1);
|
||||
const allSum = analysis.tasks.reduce(
|
||||
(acc, t) => acc + (Date.parse(t.endTs) - Date.parse(t.startTs)),
|
||||
0,
|
||||
);
|
||||
expect(analysis.elapsedMs).toBe(allSum);
|
||||
expect(
|
||||
Date.parse(compactionTasks[0].endTs) - Date.parse(compactionTasks[0].startTs),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Manual `/compact` between two rounds: reloading the page must **not** rewrite the previous
|
||||
* round's tally.
|
||||
*
|
||||
* The server's startCompact requires the Session to be idle, so manual compaction always falls
|
||||
* outside a round. In the live stream, the previous round already closed at idle, so compaction's
|
||||
* usage and duration never fold into it; but history rebuild has no idle signal, so that round
|
||||
* still looks open — compaction's Token, cost, and elapsed time would get folded wholesale into
|
||||
* the previous round. The symptom: the same reply's stats look fine at first, then after a
|
||||
* reload they flip from "260 tokens · $0.0012 · 401ms" to "510 · $0.0020 · 1.7s".
|
||||
*
|
||||
* The unit test (stream-model) locks down the message sequence, but it can't lock down whether
|
||||
* a real `/compact` actually emits compaction_begin.reason === manual — that's exactly what the
|
||||
* frontend relies on to decide "compaction is outside the round," so this is covered end-to-end
|
||||
* here. Compaction is deliberately delayed by 3 seconds (the user reads the reply, then remembers
|
||||
* to compact): if that gap were counted, the elapsed time would jump from hundreds of
|
||||
* milliseconds to over 3 seconds.
|
||||
*/
|
||||
test("manual /compact between turns: reloading must not fold the compaction into the previous turn", async ({
|
||||
page,
|
||||
}) => {
|
||||
const U2 = "manualcompact";
|
||||
await provisionAndLogin(page.request, U2, P);
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
// Window large enough that compaction only ever triggers manually via /compact.
|
||||
contextWindow: 200000,
|
||||
pricing: { cacheRead: 1, cacheWrite: 5, output: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const sess = await (
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
|
||||
data: { provider: "custom", modelId: "claude-4-8", approvalMode: "allow-all" },
|
||||
})
|
||||
).json();
|
||||
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
|
||||
// The whole stats line is transparent by default, but innerText still reads fine (no need to hover).
|
||||
const statsText = async () =>
|
||||
(await page.getByRole("button", { name: "复制回复" }).first().locator("xpath=..").innerText())
|
||||
.replace(/\n/g, " ")
|
||||
.trim();
|
||||
const costOf = (t) => t.match(/\$[\d.]+/)?.[0] ?? null;
|
||||
const elapsedMsOf = (t) => {
|
||||
const hit = [...t.matchAll(/(\d+(?:\.\d+)?)(ms|s)(?![\w/])/g)].at(-1);
|
||||
return hit ? Number(hit[1]) * (hit[2] === "s" ? 1000 : 1) : null;
|
||||
};
|
||||
|
||||
const live = await statsText();
|
||||
expect(costOf(live), `统计行应有成本:${live}`).not.toBeNull();
|
||||
expect(elapsedMsOf(live)).toBeLessThan(1500);
|
||||
|
||||
// The user reads the reply, then 3 seconds later remembers to compact.
|
||||
await page.waitForTimeout(3000);
|
||||
await ta.fill("/compact");
|
||||
await ta.press("Enter");
|
||||
await expect(page.getByText(/\[压缩\]/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);
|
||||
expect(await statsText(), "实时流里上一轮早已收口,压缩不该改动它").toBe(live);
|
||||
|
||||
await page.reload();
|
||||
await ta.waitFor();
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).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));
|
||||
// Elapsed time should include neither the 3-second thinking gap nor compaction itself.
|
||||
expect(elapsedMsOf(rebuilt), `刷新后用时不得吃进压缩(${rebuilt})`).toBeLessThan(1500);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Regression (#68): switching accounts in the same browser must keep draft caches isolated per user.
|
||||
*
|
||||
* Old bug: the draft key only included the Project/Session ID, with no user dimension. If user A
|
||||
* left an unsent draft in a shared Project and logged out, user B logging in and visiting the
|
||||
* same Project would restore A's body and selections — an info leak across accounts. After the
|
||||
* fix, the key includes userId (penguin.chatDraft.<userId>.<projectId>), so each account only sees its own draft.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin, provisionUser } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
const P = "password123";
|
||||
const UA = "draftowner";
|
||||
const UB = "draftguest";
|
||||
|
||||
test("切换账号:B 不恢复 A 的草稿,两人草稿并存", 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);
|
||||
const userB = UB;
|
||||
|
||||
// Configure a model on A's initial Project (missing credentials would trigger the onboarding
|
||||
// prompt), then add B as a member (shared Project). After logging in, B lands on projects[0] —
|
||||
// the list is sorted by creation time ascending, and A's Project is older, exactly reproducing the issue's scenario.
|
||||
const projectId = (await (await page.request.get(`${BASE}/api/projects`)).json()).projects[0]
|
||||
.projectId;
|
||||
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.ok(), "put models").toBeTruthy();
|
||||
const add = await page.request.post(`${BASE}/api/projects/${projectId}/members`, {
|
||||
data: { userId: UB },
|
||||
});
|
||||
expect(add.ok(), "add member").toBeTruthy();
|
||||
|
||||
// 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 的机密草稿");
|
||||
const keyA = `penguin.chatDraft.${userA}.${projectId}`;
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), keyA))
|
||||
.toContain("A 的机密草稿");
|
||||
await page.reload();
|
||||
await expect(ta).toHaveValue("A 的机密草稿");
|
||||
|
||||
// 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
|
||||
// the last match, which is the bottom user menu).
|
||||
await page.getByRole("button", { name: UA }).last().click();
|
||||
await page.getByRole("button", { name: "登出" }).click();
|
||||
await page.waitForURL(/\/login/);
|
||||
|
||||
// B logs in on the same browser, landing on the draft page for the shared Project.
|
||||
await page.getByLabel("用户名").fill(UB);
|
||||
// exact: without it, a substring match would also hit the "show password" toggle button (aria-label) next to the password field.
|
||||
await page.getByLabel("密码", { exact: true }).fill(P);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL(/\/chat/);
|
||||
await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible();
|
||||
|
||||
// The leak's regression point: B's input area must be empty (before the fix, it would restore A's body and selections).
|
||||
await expect(ta).toHaveValue("");
|
||||
|
||||
// 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 自己的草稿");
|
||||
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 的机密草稿");
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* End-to-end test for the draft-state new-conversation flow:
|
||||
* - /chat/new lets you pick Model / approval mode up front; the Session is only created when
|
||||
* the first message is sent, and all four selections land faithfully in its meta;
|
||||
* - the draft auto-caches (body persisted via debounce): after a page reload, both the body and
|
||||
* the selections are restored, and the cache clears once sending succeeds;
|
||||
* - the sidebar group header's "+" creates a draft scoped to that group's Agent (explicitly set
|
||||
* via router state, overriding the cache).
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
const U = "draftuser";
|
||||
const P = "password123";
|
||||
|
||||
test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存 → 侧栏 + 指定 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;
|
||||
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
|
||||
// Two models: claude-4-8 as default, plus claude-4-8-mini for the draft to switch to (both point at the mock).
|
||||
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8-mini",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 100000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.ok(), "put models").toBeTruthy();
|
||||
|
||||
// The only builtin Agent is default_agent, so the @ delegation and sidebar group-header "+" targets use a custom-created Agent.
|
||||
const created = await page.request.post(`${BASE}/api/projects/${projectId}/agents`, {
|
||||
data: { agentId: "agent_helper", name: "Helper Agent" },
|
||||
});
|
||||
expect(created.ok(), "create helper agent").toBeTruthy();
|
||||
|
||||
// No Session exists yet: entering the site lands on the draft page (the brand heading marks the draft page).
|
||||
await page.goto(`${BASE}/chat`);
|
||||
await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible();
|
||||
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.fill("草稿正文不能丢");
|
||||
|
||||
// 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 page.getByRole("button", { name: /@agent_helper/ }).click();
|
||||
await expect(page.getByText("@agent_helper")).toBeVisible();
|
||||
await expect(ta).toHaveValue("草稿正文不能丢");
|
||||
|
||||
// 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();
|
||||
await page.getByPlaceholder(/搜索模型/).fill("mini");
|
||||
await page.getByRole("button", { name: /claude-4-8-mini/ }).click();
|
||||
|
||||
// Switch the approval mode to read-only (the trigger button shows the Chinese description).
|
||||
await page.getByRole("button", { name: "审批模式" }).click();
|
||||
await page.getByRole("button", { name: /放行只读/ }).click();
|
||||
await expect(page.getByRole("button", { name: "审批模式" })).toContainText("放行只读");
|
||||
|
||||
// Reload only after the body is persisted via debounce: both the body and the two selections should restore from the cache.
|
||||
const draftKey = `penguin.chatDraft.${userId}.${projectId}`;
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), draftKey))
|
||||
.toContain("草稿正文不能丢");
|
||||
|
||||
await page.reload();
|
||||
await expect(ta).toHaveValue("草稿正文不能丢");
|
||||
// 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(() =>
|
||||
page.evaluate(() => {
|
||||
const el = document.querySelector("textarea");
|
||||
return el ? el.selectionStart === el.value.length && el.value.length > 0 : false;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(page.getByRole("button", { name: "选择模型" })).toContainText("claude-4-8-mini");
|
||||
await expect(page.getByRole("button", { name: "审批模式" })).toContainText("放行只读");
|
||||
// The @ target restores along with the draft; removing it falls back to a normal send (no delegation triggered).
|
||||
await expect(page.getByText("@agent_helper")).toBeVisible();
|
||||
await page.getByRole("button", { name: "移除 @ 目标" }).click();
|
||||
await expect(page.getByText("@agent_helper")).toHaveCount(0);
|
||||
|
||||
// Send: the Session is only created now, and the selections land faithfully in its meta.
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await page.waitForURL(/\/chat\/session-/);
|
||||
const firstSessionId = page.url().split("/chat/")[1];
|
||||
const first = await (await page.request.get(`${BASE}/api/sessions/${firstSessionId}`)).json();
|
||||
expect(first.session.agentId).toBe("default_agent");
|
||||
expect(first.session.modelId).toBe("claude-4-8-mini");
|
||||
expect(first.session.provider).toBe("custom");
|
||||
expect(first.session.approvalMode).toBe("read-only");
|
||||
|
||||
// The cache clears as soon as sending succeeds.
|
||||
await expect.poll(() => page.evaluate((k) => localStorage.getItem(k), draftKey)).toBeNull();
|
||||
|
||||
// —— Sidebar group-header "+": create a draft with that group's Agent (overrides the previously cached Agent) ——
|
||||
const helperHeader = page.getByText("Helper Agent", { exact: true }).first();
|
||||
await expect(helperHeader).toBeVisible();
|
||||
const groupRow = helperHeader.locator("xpath=ancestor::div[contains(@class,'items-center')][1]");
|
||||
await groupRow.getByRole("button", { name: "新建对话" }).click();
|
||||
|
||||
await expect(page.getByLabel("选择 Agent")).toContainText("Helper Agent");
|
||||
await ta.fill("给 helper 的第一条");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await page.waitForURL(/\/chat\/session-/);
|
||||
const secondSessionId = page.url().split("/chat/")[1];
|
||||
expect(secondSessionId).not.toBe(firstSessionId);
|
||||
const second = await (await page.request.get(`${BASE}/api/sessions/${secondSessionId}`)).json();
|
||||
expect(second.session.agentId).toBe("agent_helper");
|
||||
// No selection was changed: the model falls back to the project default, and approval mode falls back to allow-all (the previous draft was cleared, so read-only doesn't linger).
|
||||
expect(second.session.modelId).toBe("claude-4-8");
|
||||
expect(second.session.provider).toBe("custom");
|
||||
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();
|
||||
|
||||
// —— Input draft for an existing session: cached by user x Session, restored on reload, cleared once sending succeeds ——
|
||||
await ta.fill("会话里的草稿");
|
||||
const sessionKey = `penguin.chatDraft.session.${userId}.${secondSessionId}`;
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey))
|
||||
.toContain("会话里的草稿");
|
||||
|
||||
await page.reload();
|
||||
await expect(ta).toHaveValue("会话里的草稿");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect.poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey)).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Layout regressions:
|
||||
* - the English draft page must not overflow horizontally on desktop (1280) or mobile (390)
|
||||
* (the input card's toolbar row help text / button text must wrap to fit the **card width**;
|
||||
* it used to blow out the card under English copy);
|
||||
* - the draft state doesn't show the context-usage ring (no session yet); it shows normally
|
||||
* once inside a session;
|
||||
* - the models page at 390x844 must not overflow horizontally, and text must not overlap
|
||||
* (the group header's provider name used to get pushed out of the button box and overlap
|
||||
* the group-level actions);
|
||||
* - the sidebar's "New chat" button has no background fill (same gray-scale style as nav items);
|
||||
* - login page: a single brand penguin logo above the form (part of the form area; the
|
||||
* background still only has the trace animation), the trace animation grows in after a
|
||||
* delayed blank first paint, no two trace segments cross or touch (except where a fork shares
|
||||
* an endpoint with its parent line), the language / theme controls work, and English sits
|
||||
* left of 中文.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const U = "layoutuser";
|
||||
const P = "password123";
|
||||
|
||||
const docWidths = (page) =>
|
||||
page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
|
||||
/** Count of pairwise rectangle intersections among visible leaf text elements (2px tolerance; ancestor-descendant pairs excluded). */
|
||||
const textOverlapCount = (page) =>
|
||||
page.evaluate(() => {
|
||||
const isVisible = (el) => {
|
||||
const s = getComputedStyle(el);
|
||||
return s.visibility !== "hidden" && s.display !== "none" && Number(s.opacity) > 0.05;
|
||||
};
|
||||
const leaves = [];
|
||||
for (const el of document.querySelectorAll("body *")) {
|
||||
if (!isVisible(el)) continue;
|
||||
const hasText = [...el.childNodes].some(
|
||||
(n) => n.nodeType === 3 && n.textContent && n.textContent.trim(),
|
||||
);
|
||||
if (!hasText) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 2 || r.height < 2) continue;
|
||||
leaves.push({ el, r });
|
||||
}
|
||||
const TOL = 2;
|
||||
let count = 0;
|
||||
for (let i = 0; i < leaves.length; i += 1) {
|
||||
for (let j = i + 1; j < leaves.length; j += 1) {
|
||||
const a = leaves[i];
|
||||
const b = leaves[j];
|
||||
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
||||
const w = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
|
||||
const h = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
|
||||
if (w > TOL && h > TOL) count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
test("layout: en draft + context gauge + mobile models", async ({ page }) => {
|
||||
// English copy is longer, making it the worst case for layout wrapping.
|
||||
await page.addInitScript(() => localStorage.setItem("penguin.lang", "en"));
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
contextWindow: 200000,
|
||||
pricing: { cacheRead: 1, cacheWrite: 5, output: 10 },
|
||||
},
|
||||
{ provider: "openai", modelId: "gpt-5.5", apiKey: "sk-mock2" },
|
||||
{ provider: "google", modelId: "gemini-3-pro" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.ok(), "put models").toBeTruthy();
|
||||
|
||||
// --- Draft page: must not overflow horizontally on desktop or mobile; no context ring in draft state ---
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(`${BASE}/chat/new`);
|
||||
await page.getByPlaceholder(/Type a message/).waitFor();
|
||||
let d = await docWidths(page);
|
||||
expect(d.scrollWidth, "draft @1280 no horizontal overflow").toBeLessThanOrEqual(d.clientWidth);
|
||||
await expect(page.locator('[title*="Context usage"]')).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(200);
|
||||
d = await docWidths(page);
|
||||
expect(d.scrollWidth, "draft @390 no horizontal overflow").toBeLessThanOrEqual(d.clientWidth);
|
||||
|
||||
// --- Session state shows the ring as usual (creating a session via the API and entering it directly, no need to actually run a Task) ---
|
||||
const sess = await (
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
|
||||
data: { provider: "custom", modelId: "claude-4-8" },
|
||||
})
|
||||
).json();
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
await page.getByPlaceholder(/Type a message/).waitFor();
|
||||
await expect(page.locator('[title*="Context usage"]')).toHaveCount(1);
|
||||
|
||||
// --- Models page @390: must not overflow, text must not overlap ---
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${BASE}/models`);
|
||||
await page.getByText("claude-4-8").first().waitFor();
|
||||
d = await docWidths(page);
|
||||
expect(d.scrollWidth, "models @390 no horizontal overflow").toBeLessThanOrEqual(d.clientWidth);
|
||||
expect(await textOverlapCount(page), "models @390 no overlapping text").toBe(0);
|
||||
|
||||
// --- Sidebar "New chat" button: no background fill (its resting state outside the draft page should have a transparent background) ---
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
const newChat = page.locator("nav").getByRole("button", { name: "New chat" });
|
||||
await expect(newChat).toBeVisible();
|
||||
expect(
|
||||
await newChat.evaluate((el) => getComputedStyle(el).backgroundColor),
|
||||
"new-chat button has no background fill",
|
||||
).toBe("rgba(0, 0, 0, 0)");
|
||||
});
|
||||
|
||||
test("layout: login — blank start, non-crossing traces, lang/theme controls", async ({ page }) => {
|
||||
await page.goto(`${BASE}/login`);
|
||||
await page.getByRole("heading", { name: "PenguinHarness" }).waitFor();
|
||||
|
||||
// The only graphic asset is the brand penguin logo above the form; the
|
||||
// background still has only the trace animation, and the page must have no other img elements.
|
||||
await expect(page.locator("img")).toHaveCount(1);
|
||||
await expect(page.locator('img[src*="penguin-logo"]')).toBeVisible();
|
||||
|
||||
// Asserting the mechanism behind the blank first paint: every trace's delay is non-negative
|
||||
// (no line is mid-animation on the first frame), and the base state (style before the
|
||||
// animation starts) is fully hidden — temporarily disable the animation to read the base
|
||||
// state, then restore it.
|
||||
const delays = await page.evaluate(() =>
|
||||
[...document.querySelectorAll(".login-trace")].map((el) =>
|
||||
parseFloat(getComputedStyle(el).animationDelay),
|
||||
),
|
||||
);
|
||||
expect(delays.length, "traces rendered").toBeGreaterThanOrEqual(6);
|
||||
for (const d0 of delays) expect(d0, "non-negative delay").toBeGreaterThanOrEqual(0);
|
||||
const base = await page.evaluate(() => {
|
||||
const el = document.querySelector(".login-trace");
|
||||
el.style.animation = "none";
|
||||
const s = getComputedStyle(el);
|
||||
const r = { opacity: parseFloat(s.opacity), dashoffset: parseFloat(s.strokeDashoffset) };
|
||||
el.style.animation = "";
|
||||
return r;
|
||||
});
|
||||
expect(base.opacity, "pre-animation base state hidden").toBe(0);
|
||||
expect(base.dashoffset, "pre-animation base state undrawn").toBe(1);
|
||||
|
||||
// No two trace segments cross or touch (judged by zero gap between bounding boxes; excludes a fork sharing an endpoint with its parent line).
|
||||
const touching = await page.evaluate(() => {
|
||||
const segs = [];
|
||||
document.querySelectorAll(".login-trace").forEach((p) => {
|
||||
const n = (p.getAttribute("d")?.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
|
||||
for (let i = 0; i + 3 < n.length; i += 2) {
|
||||
segs.push({ x1: n[i], y1: n[i + 1], x2: n[i + 2], y2: n[i + 3] });
|
||||
}
|
||||
});
|
||||
const ends = (s) => [
|
||||
[s.x1, s.y1],
|
||||
[s.x2, s.y2],
|
||||
];
|
||||
let bad = 0;
|
||||
for (let i = 0; i < segs.length; i += 1) {
|
||||
for (let j = i + 1; j < segs.length; j += 1) {
|
||||
const a = segs[i];
|
||||
const b = segs[j];
|
||||
if (ends(a).some(([x, y]) => ends(b).some(([u, v]) => x === u && y === v))) continue;
|
||||
if (
|
||||
Math.min(a.x1, a.x2) <= Math.max(b.x1, b.x2) &&
|
||||
Math.max(a.x1, a.x2) >= Math.min(b.x1, b.x2) &&
|
||||
Math.min(a.y1, a.y2) <= Math.max(b.y1, b.y2) &&
|
||||
Math.max(a.y1, a.y2) >= Math.min(b.y1, b.y2)
|
||||
) {
|
||||
bad += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bad;
|
||||
});
|
||||
expect(touching, "no crossing or touching trace segments").toBe(0);
|
||||
|
||||
// Traces grow in after load: after a short wait, some trace should have entered its visible segment.
|
||||
await page.waitForTimeout(2600);
|
||||
const maxOpacity = await page.evaluate(() =>
|
||||
Math.max(
|
||||
...[...document.querySelectorAll(".login-trace")].map((el) =>
|
||||
parseFloat(getComputedStyle(el).opacity),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(maxOpacity, "traces grow in after load").toBeGreaterThan(0.5);
|
||||
|
||||
// The English language option sits left of 中文 (asserted by geometric position); switching takes effect immediately (headless defaults to the en environment).
|
||||
const enBtn = page.getByRole("button", { name: "English", exact: true });
|
||||
const zhBtn = page.getByRole("button", { name: "中文", exact: true });
|
||||
const [enBox, zhBox] = [await enBtn.boundingBox(), await zhBtn.boundingBox()];
|
||||
expect(enBox.x, "English left of 中文").toBeLessThan(zhBox.x);
|
||||
await zhBtn.click();
|
||||
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "English", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
|
||||
|
||||
// Theme toggle: Dark adds html.dark, Light removes it.
|
||||
await page.getByRole("button", { name: "Dark", exact: true }).click();
|
||||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||||
await page.getByRole("button", { name: "Light", exact: true }).click();
|
||||
await expect(page.locator("html")).not.toHaveClass(/dark/);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The LLM stream drops mid-way through writing tool arguments (AgentHub judges the "stream
|
||||
* incomplete" → malformed): that tool_call was never committed into AgentHub's history — the
|
||||
* engine never dispatches it and never emits a paired output; reconnect resends the same input
|
||||
* verbatim. The frontend settles the broken tool card as soon as the settle reason arrives (no
|
||||
* more running timer) and shows a retry hint line (with the attempt count). After a successful
|
||||
* retry, subsequent tool calls appear and complete as normal.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
|
||||
test("a malformed tool_call settles unpaired; the retry line shows and the retry succeeds", async ({
|
||||
page,
|
||||
}) => {
|
||||
await provisionAndLogin(page.request, "maluser", "password123");
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const sess = await (
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
|
||||
data: { provider: "custom", modelId: "claude-4-8" },
|
||||
})
|
||||
).json();
|
||||
const sessionId = sess.session.sessionId;
|
||||
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
await page.getByPlaceholder(/输入消息/).fill("坏流测试");
|
||||
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 });
|
||||
|
||||
// Retry hint line: set to "retry attempt 1 started" once request_begin arrives.
|
||||
await expect(page.getByText(/已发起第 1 次重试/)).toBeVisible();
|
||||
|
||||
// Trace: the broken tool_call is persisted (stop_reason=malformed) but **no paired output is
|
||||
// added** — it never entered AgentHub's history, so there's nothing to pair; the retry is
|
||||
// represented by a request_end(malformed) event.
|
||||
const msgs = await (await page.request.get(`${BASE}/api/sessions/${sessionId}/messages`)).json();
|
||||
const broken = msgs.messages.find(
|
||||
(m) => m.payload.type === "tool_call" && m.payload.stop_reason === "malformed",
|
||||
);
|
||||
expect(broken, "malformed tool_call recorded").toBeTruthy();
|
||||
const paired = msgs.messages.find(
|
||||
(m) =>
|
||||
m.payload.type === "tool_call_output" &&
|
||||
m.payload.tool_call_id === broken.payload.tool_call_id,
|
||||
);
|
||||
expect(paired, "no paired output for a never-dispatched call").toBeFalsy();
|
||||
const retryEnd = msgs.messages.find(
|
||||
(m) => m.payload.type === "request_end" && m.payload.status === "malformed",
|
||||
);
|
||||
expect(retryEnd, "request_end(malformed) recorded").toBeTruthy();
|
||||
|
||||
// UI: after expanding the group, the broken tool card shows malformed and is **settled**
|
||||
// (no more running timer).
|
||||
await page
|
||||
.getByRole("button", { name: /运行完毕/ })
|
||||
.first()
|
||||
.click();
|
||||
const group = page.locator(".anim-msg.my-2").first();
|
||||
await expect(group).toContainText("malformed");
|
||||
// A running spinner has role=status; none should remain after the turn ends.
|
||||
await expect(page.locator('[role="status"]')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 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)
|
||||
* - subagent's own turn (its prompt is the only user text) -> final text
|
||||
* - parent asked to delegate ("跑子agent") -> 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 PORT = Number(process.env.MOCK_PORT || 8931);
|
||||
|
||||
/** Count of non-replay requests seen in the "bad stream" conversation: the 1st is cut off (malformed), later ones are retries that get a full tool call. */
|
||||
let malformedTurns = 0;
|
||||
|
||||
function sse(res, event, data) {
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function messageStart(res, msgCount = 1) {
|
||||
sse(res, "message_start", {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_mock",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-4-8",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
// Usage grows with context length (as real providers do): more messages in the request
|
||||
// means a larger prompt. If the mock reported a fixed value, the compaction threshold
|
||||
// would either be hit on the very first request or never be hit at all, and it wouldn't
|
||||
// drop back down after compaction — this would make it impossible to reproduce the
|
||||
// "compaction only triggers at round end" timing, and would send the engine into an
|
||||
// infinite compaction loop.
|
||||
usage: {
|
||||
input_tokens: 40,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 40 * msgCount,
|
||||
cache_creation_input_tokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
function messageStop(res, stopReason, outputTokens) {
|
||||
sse(res, "message_delta", {
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: outputTokens },
|
||||
});
|
||||
sse(res, "message_stop", { type: "message_stop" });
|
||||
res.end();
|
||||
}
|
||||
|
||||
function block(res, index, start, deltas, extra) {
|
||||
sse(res, "content_block_start", { type: "content_block_start", index, content_block: start });
|
||||
for (const d of deltas)
|
||||
sse(res, "content_block_delta", { type: "content_block_delta", index, delta: d });
|
||||
if (extra) sse(res, "content_block_delta", { type: "content_block_delta", index, delta: extra });
|
||||
sse(res, "content_block_stop", { type: "content_block_stop", index });
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
let json = {};
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {}
|
||||
const messages = json.messages || [];
|
||||
const flat = JSON.stringify(messages);
|
||||
const isTitle = flat.includes("concise title");
|
||||
// After compaction the new context has only the summary left, so the message count drops
|
||||
// sharply -> reported usage drops along with it, letting compaction converge.
|
||||
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");
|
||||
// "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("坏流测试");
|
||||
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
messageStart(res, msgCount);
|
||||
|
||||
if (isTitle) {
|
||||
// The child session's title request carries **the child session's own answer** as
|
||||
// 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 报告");
|
||||
block(res, 0, { type: "text", text: "" }, [
|
||||
{ type: "text_delta", text: forSubagent ? "子 agent 的 TODO 统计" : "配置 Tailwind 主题" },
|
||||
]);
|
||||
messageStop(res, "end_turn", 8);
|
||||
return;
|
||||
}
|
||||
|
||||
// Files-card test case: the reply carries two backtick paths — demo.html was actually
|
||||
// written into the Workspace beforehand by the spec via files/content, while
|
||||
// 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("文件卡测试")) {
|
||||
block(res, 0, { type: "text", text: "" }, [
|
||||
{
|
||||
type: "text_delta",
|
||||
text: "报告已生成:`demo.html`;另一个文件 `missing-report.pdf` 并不存在。",
|
||||
},
|
||||
]);
|
||||
messageStop(res, "end_turn", 18);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantsMalformed && !hasToolResult) {
|
||||
malformedTurns += 1;
|
||||
if (malformedTurns === 1) {
|
||||
sse(res, "content_block_start", {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_broken_1",
|
||||
name: "exec_command",
|
||||
input: {},
|
||||
},
|
||||
});
|
||||
sse(res, "content_block_delta", {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"cmd": "ec' },
|
||||
});
|
||||
sse(res, "content_block_stop", { type: "content_block_stop", index: 0 });
|
||||
res.end(); // ends normally but is missing message_delta/message_stop -> AgentHub reports "stream incomplete"
|
||||
return;
|
||||
}
|
||||
// Retry (original input resent): return a complete tool_use, then proceed normally.
|
||||
block(res, 0, { type: "tool_use", id: "toolu_retry_1", name: "exec_command", input: {} }, [
|
||||
{ type: "input_json_delta", partial_json: '{"cmd"' },
|
||||
{ type: "input_json_delta", partial_json: ': "echo ok"}' },
|
||||
]);
|
||||
messageStop(res, "tool_use", 12);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubagentTurn) {
|
||||
block(res, 0, { type: "text", text: "" }, [
|
||||
{ type: "text_delta", text: "子 agent 报告:3 处 TODO" },
|
||||
]);
|
||||
messageStop(res, "end_turn", 12);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantsSubagent && !hasToolResult) {
|
||||
block(res, 0, { type: "tool_use", id: "toolu_mock_sub", name: "run_subagent", input: {} }, [
|
||||
{ type: "input_json_delta", partial_json: '{"prompt": ' },
|
||||
{ type: "input_json_delta", partial_json: `${JSON.stringify(SUBAGENT_PROMPT)}}` },
|
||||
]);
|
||||
messageStop(res, "tool_use", 15);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasToolResult) {
|
||||
// Turn 2: final answer text.
|
||||
block(res, 0, { type: "text", text: "" }, [
|
||||
{ type: "text_delta", text: "命令已执行完成," },
|
||||
{ type: "text_delta", text: "结果符合预期。" },
|
||||
]);
|
||||
messageStop(res, "end_turn", 20);
|
||||
return;
|
||||
}
|
||||
|
||||
// Turn 1: thinking + text + tool_use, streamed with small delays.
|
||||
const steps = [];
|
||||
steps.push(() =>
|
||||
block(
|
||||
res,
|
||||
0,
|
||||
{ type: "thinking", thinking: "" },
|
||||
[
|
||||
{ type: "thinking_delta", thinking: "先看看" },
|
||||
{ type: "thinking_delta", thinking: "目录结构" },
|
||||
],
|
||||
{ type: "signature_delta", signature: "sig_mock_abc" },
|
||||
),
|
||||
);
|
||||
steps.push(() =>
|
||||
block(res, 1, { type: "text", text: "" }, [
|
||||
{ type: "text_delta", text: "我来运行一个命令查看。" },
|
||||
]),
|
||||
);
|
||||
steps.push(() =>
|
||||
block(res, 2, { type: "tool_use", id: "toolu_mock_1", name: "exec_command", input: {} }, [
|
||||
{ type: "input_json_delta", partial_json: '{"cmd"' },
|
||||
{ type: "input_json_delta", partial_json: ': "ls -la"}' },
|
||||
]),
|
||||
);
|
||||
let i = 0;
|
||||
const run = () => {
|
||||
if (i < steps.length) {
|
||||
steps[i]();
|
||||
i += 1;
|
||||
setTimeout(run, 120);
|
||||
} else {
|
||||
messageStop(res, "tool_use", 30);
|
||||
}
|
||||
};
|
||||
run();
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`mock-llm on http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL,
|
||||
headless: true,
|
||||
locale: "zh-CN",
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Regression: clicking the "currently selected" Project in the Project dropdown must not clear
|
||||
* the Agent list and the Sessions under it.
|
||||
*
|
||||
* Old bug: setCurrentProjectId unconditionally called setAgents([]) + setAgentsLoading(true),
|
||||
* while currentProjectId itself didn't change — reloadAgents' effect depends on it and wouldn't
|
||||
* re-run, so the sidebar's Agents and the Sessions hanging under them would all disappear and
|
||||
* never come back (reproducible by simply clicking the current Project in the dropdown).
|
||||
*
|
||||
* A Project is deliberately created with a display name different from the username: a newly
|
||||
* registered user's initial Project display name defaults to the username, and the bottom-left
|
||||
* user menu also shows the username, so locating by name would match both.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
const U = "projswitcher";
|
||||
const P = "password123";
|
||||
const PROJ = "SwitchTarget";
|
||||
|
||||
test("下拉里点当前 Project:Agent 与 Session 列表不消失", async ({ page }) => {
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
|
||||
const initial = (await (await page.request.get(`${BASE}/api/projects`)).json()).projects[0];
|
||||
|
||||
// A non-admin's Project id must be prefixed with <username>-.
|
||||
const created = await page.request.post(`${BASE}/api/projects`, {
|
||||
data: { projectId: `${U}-target`, name: PROJ },
|
||||
});
|
||||
expect(created.ok(), "create project").toBeTruthy();
|
||||
const projectId = (await created.json()).project.projectId;
|
||||
|
||||
// Configure Model + credentials on both Projects: a Project without credentials pops an
|
||||
// onboarding overlay (fixed inset-0) as soon as the page loads, which would block every click on the sidebar.
|
||||
for (const id of [initial.projectId, projectId]) {
|
||||
const put = await page.request.put(`${BASE}/api/projects/${id}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.ok(), `put models ${id}`).toBeTruthy();
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// First wait for the initial Project's sidebar to settle: during this time it fetches the
|
||||
// Agent list and lands on the draft page (the brand heading marks the draft page); operating
|
||||
// the dropdown midway through would get closed by a subsequent re-render.
|
||||
const generalAgent = page.getByText("General Agent").first();
|
||||
const draftTitle = page.getByRole("heading", { name: "PenguinHarness" });
|
||||
await expect(generalAgent).toBeVisible();
|
||||
await expect(draftTitle).toBeVisible();
|
||||
|
||||
// Switch to this newly created Project (a different id: takes the normal switch path).
|
||||
const byName = page.getByRole("button", { name: PROJ });
|
||||
await page.getByRole("button", { name: U }).first().click(); // the initial Project's display name defaults to the username
|
||||
await byName.first().click();
|
||||
|
||||
// Wait for the new Project's sidebar to settle too (Agent list + draft page).
|
||||
await expect(generalAgent).toBeVisible();
|
||||
await expect(draftTitle).toBeVisible();
|
||||
|
||||
// The key action: click this "currently selected" Project again in the dropdown.
|
||||
await byName.first().click(); // the top trigger button (currently displaying PROJ)
|
||||
await expect(byName).toHaveCount(2); // the trigger button + the same-named item in the menu
|
||||
await byName.nth(1).click();
|
||||
|
||||
// The regression point: both the Agent and the draft page are still there (before the fix,
|
||||
// these two assertions would fail — the sidebar gets cleared and stuck in loading).
|
||||
await expect(generalAgent).toBeVisible();
|
||||
await expect(draftTitle).toBeVisible();
|
||||
|
||||
// —— Regression (#74 comment): switching Project on the skill library page must not leave
|
||||
// installation state from the previous Project —— both Projects' default_agent share the same
|
||||
// name (agentId is the page state table's key): first uninstall agent-creation from the current
|
||||
// (target) Project via the API, then switch over from the initial Project's skill library page
|
||||
// — before the fix, the old Project's snapshot would overwrite the freshly fetched data, so
|
||||
// "Manage installation" would always show "Installed."
|
||||
const del = await page.request.delete(
|
||||
`${BASE}/api/projects/${projectId}/agents/default_agent/skills/agent-creation`,
|
||||
);
|
||||
expect(del.ok(), "uninstall agent-creation on target project").toBeTruthy();
|
||||
|
||||
// Switch back to the initial Project, populating the skill library page's snapshot (default_agent has everything preinstalled -> Installed).
|
||||
await byName.first().click();
|
||||
await page.getByRole("button", { name: U }).first().click();
|
||||
await expect(generalAgent).toBeVisible();
|
||||
await page.getByRole("link", { name: "技能库" }).click();
|
||||
await expect(page).toHaveURL(/\/skills$/);
|
||||
await page.getByRole("button", { name: "管理安装 agent-creation" }).click();
|
||||
await expect(page.getByRole("button", { name: "卸载 default_agent" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Staying on the skill library page while switching to target: the same-named default_agent's state must flip to "Install" (not installed).
|
||||
await page.getByRole("button", { name: U }).first().click();
|
||||
await byName.first().click();
|
||||
await page.getByRole("button", { name: "管理安装 agent-creation" }).click();
|
||||
await expect(page.getByRole("button", { name: "安装 default_agent" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end verification (Playwright + mock LLM): build skills/core/server/web -> start mock Anthropic SSE ->
|
||||
# start server (temp data root) -> run chat.spec.mjs. SKIP_BUILD=1 skips the build.
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../../.." && pwd)"
|
||||
DATA="$(mktemp -d)"
|
||||
MOCK_PORT="${MOCK_PORT:-8931}"
|
||||
SRV_PORT="${SRV_PORT:-8930}"
|
||||
export BASE_URL="http://127.0.0.1:$SRV_PORT"
|
||||
export MOCK_URL="http://127.0.0.1:$MOCK_PORT"
|
||||
|
||||
cleanup() {
|
||||
[ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null
|
||||
[ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null
|
||||
rm -rf "$DATA"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ "${SKIP_BUILD:-0}" != "1" ]; then
|
||||
echo "== build skills/core/server/web =="
|
||||
(cd "$ROOT" && pnpm --filter @prismshadow/penguin-skills build \
|
||||
&& pnpm --filter @prismshadow/penguin-core build \
|
||||
&& pnpm --filter @prismshadow/penguin-server build \
|
||||
&& pnpm --filter @prismshadow/penguin-web build) || { echo "BUILD FAILED"; exit 1; }
|
||||
fi
|
||||
|
||||
echo "== start mock LLM =="
|
||||
MOCK_PORT=$MOCK_PORT node "$HERE/mock-llm.mjs" &
|
||||
MOCK_PID=$!
|
||||
|
||||
echo "== start server =="
|
||||
PENGUIN_HOME="$DATA" PORT=$SRV_PORT HOST=127.0.0.1 PENGUIN_WEB_DB="$DATA/web.db" \
|
||||
PENGUIN_WEB_DIST="$ROOT/packages/web/dist" \
|
||||
node "$ROOT/packages/server/dist/index.js" &
|
||||
SRV_PID=$!
|
||||
|
||||
echo "== wait for server =="
|
||||
for _ in $(seq 1 40); do
|
||||
curl -sf "$BASE_URL/" >/dev/null 2>&1 && break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "== run playwright =="
|
||||
cd "$ROOT/packages/web"
|
||||
npx playwright test -c "$HERE/playwright.config.mjs"
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Narrow viewport (<1024px, below use-files-panel's isDocked breakpoint): the Files panel
|
||||
* uses a bottom Sheet: open → drill into a directory → Markdown renders by default → Esc
|
||||
* closes and unmounts.
|
||||
* Upload uses a nested path (notes/demo.md): also covers the server's sandbox auto-creating
|
||||
* a missing parent directory.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
|
||||
test.use({ viewport: { width: 412, height: 915 } });
|
||||
|
||||
test("mobile Files sheet: open → nested dir → md rendered preview → esc close", async ({
|
||||
page,
|
||||
}) => {
|
||||
await provisionAndLogin(page.request, "sheetuser", "password123");
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
|
||||
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.ok(), "put models").toBeTruthy();
|
||||
|
||||
const sess = await (
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
|
||||
data: { provider: "custom", modelId: "claude-4-8" },
|
||||
})
|
||||
).json();
|
||||
const sessionId = sess.session.sessionId;
|
||||
|
||||
const md = Buffer.from("# Sheet 标题\n\n正文段落。").toString("base64");
|
||||
const up = await page.request.put(
|
||||
`${BASE}/api/sessions/${sessionId}/files/content?path=notes/demo.md`,
|
||||
{ data: { dataBase64: md } },
|
||||
);
|
||||
expect(up.ok(), "upload nested md").toBeTruthy();
|
||||
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
await page.getByRole("button", { name: "打开工作区" }).click();
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// 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();
|
||||
|
||||
// Esc closes: unmounts entirely once the exit animation finishes.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(sheet).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* End-to-end test for the skill library (locale zh-CN):
|
||||
* - the sidebar nav shows "技能库" ("Skill Library"), and the page renders the library's skill
|
||||
* cards across group sections (a collapsible group header: group name + skill count,
|
||||
* **no icon**; the group name follows the UI language — when the server ships a Chinese
|
||||
* group name it's "Agent 开发 / 数据分析 / Penguin 开发 / 网页开发 / 软件工程", falling back to
|
||||
* English by default); cards carry a custom icon (icon.svg sanitized then inlined, not the book
|
||||
* fallback), with metadata showing version and usage count (worded semantically, not a bare
|
||||
* number badge);
|
||||
* - the "Manage installation" Modal: an Agent row + Install / Installed (hover flips to
|
||||
* Uninstall) button, with optimistic updates on install/uninstall;
|
||||
* - "Quick invoke" navigates to /chat/new (default_agent), **prefilling** the invoke text in the
|
||||
* UI language (zh: "使用 X 技能") and preselecting that skill — the toolbar's skill dropdown
|
||||
* button shows a count badge of 1, and that item shows as selected in the menu;
|
||||
* - the skill dropdown: the search box filters by name (typing "sdk" leaves only
|
||||
* penguin-sdk); clicking a row toggles selection **without closing the menu**;
|
||||
* - selections are written into the draft (#74 comment): in draft state, checking the dropdown
|
||||
* then reloading keeps both the body and the selection; in session state, selecting via slash
|
||||
* then reloading likewise persists (keyed by user x Session);
|
||||
* - sending the prefilled body directly -> the message stream collapses the <use_skills> block
|
||||
* into a "使用技能" ("Use skills") banner, the invoke text still renders normally, the stored
|
||||
* message really does start with the block, and the selection clears once sending succeeds;
|
||||
* - slash invocation: typing /<prefix> shows a skill command item, and pressing Enter selects
|
||||
* that skill and clears the input box (without sending).
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
const U = "skillsuser";
|
||||
const P = "password123";
|
||||
|
||||
// Group names follow the UI language: Chinese when the server dist ships titleZh, otherwise
|
||||
// falling back to English (both states are asserted).
|
||||
// The group header is a collapsible button (group name + skill count); matched by a substring of its accessible name.
|
||||
const GROUPS = [
|
||||
/Agent Development|Agent 开发/,
|
||||
/Data Analysis|数据分析/,
|
||||
/Penguin Development|Penguin 开发/,
|
||||
/Web Development|网页开发/,
|
||||
/Software Engineering|软件工程/,
|
||||
];
|
||||
const SKILLS = [
|
||||
"agent-creation",
|
||||
"benchmark-design",
|
||||
"agent-evaluation",
|
||||
"agent-optimization",
|
||||
"data-analysis",
|
||||
"penguin-sdk",
|
||||
"penguin-cli",
|
||||
"agenthub-models",
|
||||
"web-design",
|
||||
"software-engineering",
|
||||
];
|
||||
|
||||
// The path prefix of the default book icon (used as a fallback): the group header has no icon,
|
||||
// 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 ({
|
||||
page,
|
||||
}) => {
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
|
||||
// The default model points at the mock LLM (once sent, the mock provides the fallback
|
||||
// reply). The model reference is given as a pair: provider and modelId are separate fields,
|
||||
// and modelId is the upstream id verbatim (no concatenation of any kind).
|
||||
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.ok(), "put models").toBeTruthy();
|
||||
|
||||
// The install target for "Manage installation": default_agent has all skills preinstalled, so use a blank Agent to exercise the install/uninstall flow.
|
||||
const created = await page.request.post(`${BASE}/api/projects/${projectId}/agents`, {
|
||||
data: { agentId: "agent_helper", name: "Helper Agent" },
|
||||
});
|
||||
expect(created.ok(), "create helper agent").toBeTruthy();
|
||||
|
||||
// —— Skill library page: sidebar nav entry + grouped cards (group headers are collapsible buttons, all expanded by default) ——
|
||||
await page.goto(`${BASE}/chat`);
|
||||
const navLink = page.getByRole("link", { name: "技能库" });
|
||||
await expect(navLink).toBeVisible();
|
||||
await navLink.click();
|
||||
await expect(page).toHaveURL(/\/skills$/);
|
||||
for (const g of GROUPS) {
|
||||
const header = page.getByRole("button", { name: g });
|
||||
await expect(header).toBeVisible();
|
||||
await expect(header).toHaveAttribute("aria-expanded", "true");
|
||||
// The group header no longer has an icon: the book path must not appear in the group header button.
|
||||
await expect(header.locator(`svg path[d^="${BOOK_PATH_PREFIX}"]`)).toHaveCount(0);
|
||||
}
|
||||
for (const s of SKILLS) {
|
||||
await expect(page.getByText(s, { exact: true })).toBeVisible();
|
||||
}
|
||||
// Card metadata is worded semantically: version + usage count (default_agent has all skills preinstalled -> at least 1 in use).
|
||||
await expect(page.getByText(/v\d+ · .*Agent 在用/).first()).toBeVisible();
|
||||
|
||||
// Card icon: the DTO icon (icon.svg in the skill's directory) is sanitized and rendered
|
||||
// inline (an aria-hidden wrapper + svg); builtin skills each carry a custom icon, not the book
|
||||
// fallback. The card root = the nearest rounded-md ancestor of the name element (the new card
|
||||
// layout has the icon spanning two rows on the left, with the name and short description as
|
||||
// separate text columns, so it can no longer be located by "the innermost div containing the name").
|
||||
const creationCard = page
|
||||
.getByText("agent-creation", { exact: true })
|
||||
.locator("xpath=ancestor::div[contains(@class,'rounded-md')][1]");
|
||||
await expect(creationCard.locator("span[aria-hidden] > svg")).toHaveCount(1);
|
||||
await expect(creationCard.locator(`svg path[d^="${BOOK_PATH_PREFIX}"]`)).toHaveCount(0);
|
||||
|
||||
// Clicking the group header collapses it: aria-expanded flips, and the group's content
|
||||
// becomes inert (a zero-height card can't be interacted with); clicking again expands it back.
|
||||
// The collapsed content is still in the DOM (a grid-rows 0fr height transition), so assert
|
||||
// inert rather than visibility.
|
||||
const firstHeader = page.getByRole("button", { name: GROUPS[0] });
|
||||
const firstGroup = page.locator("section").filter({ has: firstHeader });
|
||||
await firstHeader.click();
|
||||
await expect(firstHeader).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(firstGroup.locator("[inert]")).toHaveCount(1);
|
||||
await firstHeader.click();
|
||||
await expect(firstHeader).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(firstGroup.locator("[inert]")).toHaveCount(0);
|
||||
|
||||
// —— Manage installation Modal: an Agent row + Install/Installed (clicking Installed uninstalls) ——
|
||||
await page.getByRole("button", { name: "管理安装 agent-creation" }).click();
|
||||
const dialog = page.locator("div").filter({ hasText: "管理安装:agent-creation" }).last();
|
||||
// default_agent (display name General Agent) has everything preinstalled -> "已安装"
|
||||
// ("Installed", whose accessible name is the uninstall action); agent_helper has nothing
|
||||
// installed -> "安装" ("Install").
|
||||
const uninstallDefault = page.getByRole("button", { name: "卸载 default_agent" });
|
||||
const installHelper = page.getByRole("button", { name: "安装 agent_helper" });
|
||||
await expect(uninstallDefault).toBeVisible();
|
||||
await expect(uninstallDefault).toContainText("已安装");
|
||||
await expect(installHelper).toBeVisible();
|
||||
// Install onto helper: optimistic update, the button flips to "已安装" ("Installed", the uninstall action).
|
||||
await installHelper.click();
|
||||
const uninstallHelper = page.getByRole("button", { name: "卸载 agent_helper" });
|
||||
await expect(uninstallHelper).toBeVisible();
|
||||
await expect(uninstallHelper).toContainText("已安装");
|
||||
// Server-side truth converges: agent-creation really does appear in helper's installed list.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await (
|
||||
await page.request.get(`${BASE}/api/projects/${projectId}/agents/agent_helper/skills`)
|
||||
).json();
|
||||
return res.skills.map((s) => s.name);
|
||||
})
|
||||
.toContain("agent-creation");
|
||||
// Clicking "已安装" ("Installed") uninstalls: flips back to "安装" ("Install").
|
||||
await uninstallHelper.click();
|
||||
await expect(page.getByRole("button", { name: "安装 agent_helper" })).toBeVisible();
|
||||
// Escape closes the Modal (built into the Modal).
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
// —— Quick invoke: navigates to /chat/new (default_agent), prefilling the invoke text + preselecting that skill ——
|
||||
await page.getByRole("button", { name: "快捷调用 agent-creation" }).click();
|
||||
await page.waitForURL(/\/chat\/new$/);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
// Prefilled per the UI language (zh): 使用 X 技能 ("use the X skill").
|
||||
await expect(ta).toHaveValue("使用 agent-creation 技能");
|
||||
// The draft belongs to default_agent (display name General Agent).
|
||||
await expect(page.getByLabel("选择 Agent")).toContainText("General Agent");
|
||||
|
||||
// The toolbar's skill dropdown button: the selected-count badge is 1 (chips no longer exist; the badge is the only visible rendering).
|
||||
const skillsBtn = page.getByRole("button", { name: "技能", exact: true });
|
||||
await expect(skillsBtn).toBeVisible();
|
||||
await expect(skillsBtn).toContainText("1");
|
||||
|
||||
// Open the menu: all listed skills each occupy a row (default_agent has everything preinstalled), with agent-creation shown as selected.
|
||||
const row = (name) => page.getByRole("button", { name: new RegExp(`^${name}`) });
|
||||
await skillsBtn.click();
|
||||
for (const s of SKILLS) {
|
||||
await expect(row(s)).toBeVisible();
|
||||
}
|
||||
await expect(row("agent-creation")).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Search filter: typing "sdk" leaves only penguin-sdk.
|
||||
await page.getByPlaceholder("搜索技能").fill("sdk");
|
||||
await expect(row("penguin-sdk")).toBeVisible();
|
||||
await expect(row("agent-creation")).toHaveCount(0);
|
||||
await page.getByPlaceholder("搜索技能").fill("");
|
||||
|
||||
// Clicking a row toggles its selection **without closing the menu**: select penguin-sdk, then deselect it.
|
||||
await row("penguin-sdk").click();
|
||||
await expect(row("penguin-sdk")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(skillsBtn).toContainText("2");
|
||||
|
||||
// —— Survives a reload (#74 comment): checking the dropdown writes into the draft immediately, so both the body and the selection persist after a reload ——
|
||||
await page.reload();
|
||||
await expect(ta).toHaveValue("使用 agent-creation 技能");
|
||||
await expect(skillsBtn).toContainText("2");
|
||||
await skillsBtn.click();
|
||||
await expect(row("penguin-sdk")).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await row("penguin-sdk").click();
|
||||
await expect(row("penguin-sdk")).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(skillsBtn).toContainText("1");
|
||||
// Escape closes the menu (built into the Dropdown).
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(row("agent-creation")).toHaveCount(0);
|
||||
|
||||
// —— Sending the prefilled body directly -> "使用技能" ("Use skills") banner + invoke text lands in the message ——
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await page.waitForURL(/\/chat\/session-/);
|
||||
const sessionId = page.url().split("/chat/")[1];
|
||||
|
||||
// Message stream: the block collapses into a "使用技能" ("Use skills") banner, and the
|
||||
// prefilled body "使用 agent-creation 技能" still renders normally; the selection clears once
|
||||
// sending succeeds (the dropdown button's badge disappears).
|
||||
await expect(page.getByText(/使用技能.*agent-creation/)).toBeVisible();
|
||||
await expect(page.getByText("使用 agent-creation 技能", { exact: true })).toBeVisible();
|
||||
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();
|
||||
|
||||
// —— 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");
|
||||
await expect(page.getByRole("button", { name: /\/agent-optimization/ })).toBeVisible();
|
||||
await ta.press("Enter");
|
||||
await expect(ta).toHaveValue("");
|
||||
await expect(skillsBtn).toContainText("1");
|
||||
await skillsBtn.click();
|
||||
await expect(row("agent-optimization")).toHaveAttribute("aria-pressed", "true");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// The session-state selection is likewise written into the draft (keyed by user x Session): the selection persists after a reload.
|
||||
await page.reload();
|
||||
await expect(skillsBtn).toContainText("1");
|
||||
await skillsBtn.click();
|
||||
await expect(row("agent-optimization")).toHaveAttribute("aria-pressed", "true");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// The stored message really does start with the <use_skills> block (the banner is only a
|
||||
// rendering-layer collapse; Trace/storage keeps the raw text), with the body being the invoke
|
||||
// text prefilled by quick invoke.
|
||||
const messages = await (
|
||||
await page.request.get(`${BASE}/api/sessions/${sessionId}/messages`)
|
||||
).json();
|
||||
const flat = JSON.stringify(messages);
|
||||
expect(flat, "stored message keeps the <use_skills> block").toContain("<use_skills>");
|
||||
expect(flat, "block lists the selected skill").toContain("skills: agent-creation");
|
||||
expect(flat, "prefilled body follows the block").toContain("使用 agent-creation 技能");
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* The subagent (run_subagent) card survives a **reload**: the parent Trace only stores a single
|
||||
* 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
|
||||
* summary" when the title request sees the child's own answer), and that discovery-based
|
||||
* backfill must not collapse it to NULL.
|
||||
*
|
||||
* Standalone spec: shares one server with chat.spec.mjs, so it registers its own user here
|
||||
* (not the first user; registration auto-provisions a `project-<8hex>`), independent of
|
||||
* chat.spec's execution order.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
const U = "subuser";
|
||||
const P = "password123";
|
||||
|
||||
test("subagent card survives a reload; child session title is generated from its own conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
|
||||
// Account creation auto-provisions a default Project; must use it (the frontend selects it
|
||||
// too) — don't create another one.
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
expect(projects.projects, "auto-provisioned project").toHaveLength(1);
|
||||
const projectId = projects.projects[0].projectId;
|
||||
|
||||
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.ok(), "put models").toBeTruthy();
|
||||
|
||||
const agentId = "default_agent";
|
||||
// Approval defaults to allow-all: child sessions inherit the parent's approval mode, no
|
||||
// manual approval needed.
|
||||
const sessRes = await page.request.post(
|
||||
`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`,
|
||||
{ data: { provider: "custom", modelId: "claude-4-8" } },
|
||||
);
|
||||
expect(sessRes.ok(), `create session: ${await sessRes.text()}`).toBeTruthy();
|
||||
const sess = await sessRes.json();
|
||||
const sessionId = sess.session.sessionId;
|
||||
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("跑子agent");
|
||||
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();
|
||||
|
||||
/** Expand the "Reasoning & tools" group → expand the child session card → child session body visible. */
|
||||
const openSubagent = async () => {
|
||||
await page
|
||||
.getByRole("button", { name: /运行完毕/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByText("run_subagent").first()).toBeVisible();
|
||||
const card = page.getByRole("button", { name: /子会话/ }).first();
|
||||
await expect(card).toBeVisible();
|
||||
await card.click();
|
||||
await expect(page.getByText("子 agent 报告:3 处 TODO").first()).toBeVisible();
|
||||
};
|
||||
|
||||
// Under live streaming, the child session card is nested inside the run_subagent tool card.
|
||||
await openSubagent();
|
||||
|
||||
// --- Must still hold after reload (the fix's goal: parent Trace's child session pointer + server-side expansion) ---
|
||||
await page.reload();
|
||||
await openSubagent();
|
||||
|
||||
// --- Child session title: generated by the model from the child session's own conversation (async, poll until persisted) ---
|
||||
const childTitle = async () => {
|
||||
const list = await (
|
||||
await page.request.get(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`)
|
||||
).json();
|
||||
return list.sessions.find((s) => s.sessionId !== sessionId)?.title ?? null;
|
||||
};
|
||||
await expect.poll(childTitle, { timeout: 10000 }).toBe("子 agent 的 TODO 统计");
|
||||
});
|
||||
Reference in New Issue
Block a user