/** * 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 / 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 技能"); });