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:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+13
View File
@@ -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`。
+38
View File
@@ -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);
}
+323
View File
@@ -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();
});
+150
View File
@@ -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
});
+229
View File
@@ -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 的机密草稿");
});
+149
View File
@@ -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();
});
+220
View File
@@ -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/);
});
+82
View File
@@ -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);
});
+235
View File
@@ -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}`);
});
+16
View File
@@ -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"],
},
});
+111
View File
@@ -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");
});
+46
View File
@@ -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"
+66
View File
@@ -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();
});
+257
View File
@@ -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 技能");
});
+99
View File
@@ -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 统计");
});