d4faee3a1e
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
257 lines
14 KiB
JavaScript
257 lines
14 KiB
JavaScript
/**
|
|
* 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 "办公效率 / 软件开发 / AI 应用开发 / Agent 调优", 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 = [
|
|
/Office Productivity|办公效率/,
|
|
/Software Development|软件开发/,
|
|
/AI App Development|AI 应用开发/,
|
|
/Agent Tuning|Agent 调优/,
|
|
];
|
|
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: library groups and cards -> manage-install Modal -> quick-invoke prefill -> dropdown filter and slash selection", 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("Command finished; the result looks as expected.")).toBeVisible();
|
|
|
|
// —— Slash invocation: typing /agent-opt shows a skill command item; pressing Enter selects it and clears the input box (without sending) ——
|
|
await ta.fill("/agent-opt");
|
|
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 技能");
|
|
});
|