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:
1 parent
056bed7aeb
commit
45bfae6e94
543 files changed
+92949
No files matched your search
@@ -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 技能");
|
||||
});
|
||||
Reference in new issue
Block a user