mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 11:40:35 +00:00
* fix(capabilities): tidy the extension detail layout and narrow-screen overflow The extension detail view led with a page-level reload button and sat the back button beside it 3px off: the two flowed inline and aligned by baseline, which the back button takes from its icon. It also kept a directory search that filters nothing there, a heading close to the page title, and status copy that read like body text. On narrow screens the tab strip widened the whole page, leaving a horizontal scrollbar. Lead with the back button and move reload to the opposite end, drop the search from the detail view, and give the detail an icon, package name and a status notice holding the installation guide. On the directory, reload now sits beside the hint that explains it. The tab strip scrolls inside itself and keeps the active tab in view, and reload collapses to an icon below `sm` with its accessible name intact. No copy changes. * fix(capabilities): realign the tab strip on resize without moving the page Review follow-up. The active tab was only realigned when the tab changed, so a rotation or window resize could leave it clipped. Realign whenever the strip resizes, and leave manual swipes alone. Scroll only the strip. scrollIntoView would also move the page, pulling it back up when a resize arrives after the user has scrolled past the strip. Also hide the strip's scrollbar in browsers without scrollbar-width support, and find the page scroller in the E2E test by computed style rather than by a class name.
839 lines
29 KiB
TypeScript
839 lines
29 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
import path from "node:path";
|
|
|
|
import { expect, test, type Locator, type Page } from "@playwright/test";
|
|
|
|
import { extensionCatalog } from "../../src/core/extensions/catalog";
|
|
|
|
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
|
|
|
// Found by computed style, so renaming the scroller's classes cannot break it.
|
|
function capabilityCenterScroller(page: Page) {
|
|
return page
|
|
.getByRole("heading", { name: "Capability Center" })
|
|
.evaluateHandle((node) => {
|
|
let scroller = node.parentElement;
|
|
while (scroller && getComputedStyle(scroller).overflowY !== "auto") {
|
|
scroller = scroller.parentElement;
|
|
}
|
|
if (!scroller) {
|
|
throw new Error("Capability Center has no scrolling ancestor");
|
|
}
|
|
return scroller;
|
|
});
|
|
}
|
|
|
|
let gateway: ChildProcess;
|
|
let gatewayURL: string;
|
|
test.beforeAll(async ({ request }) => {
|
|
const probe = createServer();
|
|
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
|
const address = probe.address();
|
|
if (!address || typeof address === "string")
|
|
throw new Error("Missing test port");
|
|
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
|
gatewayURL = `http://127.0.0.1:${address.port}`;
|
|
const backend = path.resolve(process.cwd(), "../backend");
|
|
gateway = spawn(
|
|
process.env.PLAYWRIGHT_PYTHON ??
|
|
path.join(
|
|
backend,
|
|
process.platform === "win32"
|
|
? ".venv/Scripts/python.exe"
|
|
: ".venv/bin/python",
|
|
),
|
|
[
|
|
"-m",
|
|
"extension_test_fixtures.bookmark_plugin_gateway",
|
|
String(address.port),
|
|
],
|
|
{ cwd: backend, stdio: "pipe" },
|
|
);
|
|
let diagnostics = "";
|
|
gateway.stderr?.on("data", (chunk) => {
|
|
diagnostics += String(chunk);
|
|
});
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
if (gateway.exitCode !== null) throw new Error(diagnostics);
|
|
return request
|
|
.get(`${gatewayURL}/api/plugins`)
|
|
.then((r) => r.status())
|
|
.catch(() => 0);
|
|
},
|
|
{ timeout: 60_000 },
|
|
)
|
|
.toBe(200);
|
|
});
|
|
test.afterAll(async () => {
|
|
if (gateway?.exitCode === null) {
|
|
const exited = new Promise<void>((resolve) =>
|
|
gateway.once("exit", () => resolve()),
|
|
);
|
|
gateway.kill("SIGTERM");
|
|
await exited;
|
|
}
|
|
});
|
|
|
|
for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
|
|
test(`bookmark package (${source}): save, reopen original agent, tool lookup, isolation and delete`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const frontendURL =
|
|
process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000";
|
|
const backendBase = process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "";
|
|
const backendURL = new URL(backendBase || frontendURL, frontendURL);
|
|
await page.context().addCookies([
|
|
{
|
|
name: "plugin_test_session",
|
|
value: "synthetic",
|
|
url: backendURL.origin,
|
|
},
|
|
]);
|
|
await page.setViewportSize({ width: 1360, height: 1050 });
|
|
const agentName = source === "default" ? undefined : "researcher";
|
|
const conversationURL = agentName
|
|
? `/workspace/agents/${agentName}/chats/${MOCK_THREAD_ID}`
|
|
: `/workspace/chats/${MOCK_THREAD_ID}`;
|
|
mockLangGraphAPI(page, {
|
|
agents: agentName
|
|
? [{ name: agentName, description: "Research assistant" }]
|
|
: [],
|
|
threads: [
|
|
{
|
|
thread_id: MOCK_THREAD_ID,
|
|
title: "Plugin architecture",
|
|
agent_name: agentName,
|
|
messages: [
|
|
{
|
|
id: "u",
|
|
type: "human",
|
|
content: "What should the plugin host provide?",
|
|
},
|
|
{
|
|
id: "a",
|
|
type: "ai",
|
|
content:
|
|
"ORCHID plugin design: one package provides a custom page, backend actions and model tools. Deployment owns installation and activation.",
|
|
additional_kwargs: { reasoning_content: "PRIVATE REASONING" },
|
|
},
|
|
{
|
|
id: "hidden",
|
|
type: "ai",
|
|
content: "HIDDEN ANSWER",
|
|
additional_kwargs: { hide_from_ui: true },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
let savedPayload: Record<string, unknown> | undefined;
|
|
const moduleRequests: string[] = [];
|
|
await page.route("**/api/plugins**", async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const cors = {
|
|
"access-control-allow-origin": new URL(frontendURL).origin,
|
|
"access-control-allow-credentials": "true",
|
|
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
"access-control-allow-headers":
|
|
"content-type, x-deerflow-plugin-viewer, x-csrf-token",
|
|
};
|
|
if (route.request().method() === "OPTIONS") {
|
|
await route.fulfill({ status: 204, headers: cors });
|
|
return;
|
|
}
|
|
if (url.pathname.includes("/assets/")) {
|
|
moduleRequests.push(url.href);
|
|
if (
|
|
!(await route.request().allHeaders()).cookie?.includes(
|
|
"plugin_test_session=synthetic",
|
|
)
|
|
) {
|
|
await route.fulfill({ status: 401, headers: cors });
|
|
return;
|
|
}
|
|
}
|
|
if (url.pathname.endsWith("/actions/save"))
|
|
savedPayload = route.request().postDataJSON();
|
|
const response = await route.fetch({
|
|
url: gatewayURL + url.pathname.slice(url.pathname.indexOf("/api/")),
|
|
headers: {
|
|
...route.request().headers(),
|
|
"x-test-user": "alice",
|
|
"x-test-role": "admin",
|
|
},
|
|
});
|
|
await route.fulfill({
|
|
response,
|
|
headers: { ...response.headers(), ...cors },
|
|
});
|
|
});
|
|
await page.goto(conversationURL);
|
|
const libraryURL = "/workspace/extensions/community.bookmarks/library";
|
|
const libraryLink = page.getByRole("link", {
|
|
name: "My bookmarks",
|
|
exact: true,
|
|
});
|
|
await expect(libraryLink).toHaveAttribute("href", libraryURL);
|
|
expect(moduleRequests).toHaveLength(2);
|
|
const modulePrefix = new URL(
|
|
`${backendBase.replace(/\/+$/, "")}/api/plugins/community.bookmarks/assets/`,
|
|
frontendURL,
|
|
).href;
|
|
expect(moduleRequests[0]?.startsWith(modulePrefix)).toBe(true);
|
|
if (source === "custom-sidebar") {
|
|
await page
|
|
.locator(`a[data-sidebar="menu-button"][href="${conversationURL}"]`)
|
|
.locator("xpath=..")
|
|
.getByRole("button", { name: "More" })
|
|
.click();
|
|
await page
|
|
.getByRole("menuitem", { name: "Bookmarks", exact: true })
|
|
.hover();
|
|
} else {
|
|
await page
|
|
.getByRole("button", { name: "Bookmarks", exact: true })
|
|
.click();
|
|
}
|
|
await page
|
|
.getByRole("menuitem", { name: "Save last answer", exact: true })
|
|
.click();
|
|
await expect(
|
|
page.getByText("Saved. Open My bookmarks in the sidebar.", {
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
expect(savedPayload?.message_id).toBe("a");
|
|
expect(JSON.stringify(savedPayload)).not.toContain("PRIVATE");
|
|
expect(JSON.stringify(savedPayload)).not.toContain("HIDDEN");
|
|
await page.goto("/workspace/capabilities?tab=extensions");
|
|
await expect(page.getByRole("switch")).toHaveCount(0);
|
|
if (process.env.EXTENSION_SCREENSHOT_DIR) {
|
|
await mkdir(process.env.EXTENSION_SCREENSHOT_DIR, { recursive: true });
|
|
await page.screenshot({
|
|
path: path.join(
|
|
process.env.EXTENSION_SCREENSHOT_DIR,
|
|
"bookmarks-directory.png",
|
|
),
|
|
fullPage: true,
|
|
});
|
|
}
|
|
await page
|
|
.getByRole("button", { name: "View Bookmarks", exact: true })
|
|
.click();
|
|
await expect(
|
|
page.getByRole("searchbox", { name: "Search bookmarks" }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByText("Enabled · Managed by your administrator", {
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
await libraryLink.click();
|
|
await expect(page).toHaveURL(new RegExp(`${libraryURL}$`));
|
|
await expect(
|
|
page.getByRole("heading", { name: "My bookmarks", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByText("Keep answers worth returning to", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".intro")).toHaveCSS("border-radius", "16px");
|
|
await expect
|
|
.poll(() =>
|
|
page.locator(".intro img").evaluate((node) => {
|
|
const image = node as HTMLImageElement;
|
|
return image.complete && image.naturalWidth > 0;
|
|
}),
|
|
)
|
|
.toBe(true);
|
|
expect(moduleRequests.some((url) => url.endsWith("/styles.css"))).toBe(
|
|
true,
|
|
);
|
|
expect(moduleRequests.some((url) => url.endsWith("/bookmark.svg"))).toBe(
|
|
true,
|
|
);
|
|
const name = page.getByRole("textbox", {
|
|
name: "Bookmark name",
|
|
exact: true,
|
|
});
|
|
await expect(name).toBeVisible();
|
|
await name.fill("Plugin interface decisions");
|
|
await page.getByRole("button", { name: "Save name", exact: true }).click();
|
|
await page.reload();
|
|
await expect(name).toHaveValue("Plugin interface decisions");
|
|
await page
|
|
.getByRole("searchbox", { name: "Search bookmarks", exact: true })
|
|
.fill("ORCHID");
|
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
await expect(page.getByText(/1 saved/)).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", { name: "Search", exact: true }),
|
|
).toBeEnabled();
|
|
await page
|
|
.getByRole("button", { name: "Open conversation", exact: true })
|
|
.click();
|
|
await expect(page).toHaveURL(new RegExp(`${conversationURL}$`));
|
|
// Verify continuing the reopened conversation still invokes its originating agent.
|
|
const runRequest = page.waitForRequest(
|
|
(request) =>
|
|
request.method() === "POST" && request.url().includes("/runs/stream"),
|
|
);
|
|
await page.getByRole("textbox").fill("Continue this conversation");
|
|
await page.getByRole("textbox").press("Enter");
|
|
const runPayload = (await runRequest).postDataJSON();
|
|
expect(runPayload.assistant_id).toBe(agentName ?? "lead_agent");
|
|
if (agentName) expect(runPayload.context.agent_name).toBe(agentName);
|
|
await libraryLink.click();
|
|
await expect(name).toHaveValue("Plugin interface decisions");
|
|
await expect(page.getByRole("switch")).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("button", { name: "Save plugin settings" }),
|
|
).toHaveCount(0);
|
|
const modelResponse = await request.post(
|
|
`${gatewayURL}/test/model-search`,
|
|
{
|
|
headers: { "x-test-user": "alice" },
|
|
data: { query: "ORCHID" },
|
|
},
|
|
);
|
|
const modelResult = await modelResponse.json();
|
|
expect(modelResult.tool).toContain("search_bookmarks");
|
|
const items = JSON.parse(modelResult.content).items;
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].label).toBe("Plugin interface decisions");
|
|
const bob = await request.post(`${gatewayURL}/test/model-search`, {
|
|
headers: { "x-test-user": "bob" },
|
|
data: { query: "ORCHID" },
|
|
});
|
|
expect(JSON.parse((await bob.json()).content).items).toEqual([]);
|
|
expect(
|
|
(
|
|
await request.post(
|
|
`${gatewayURL}/api/plugins/community.bookmarks/actions/delete`,
|
|
{ headers: { "x-test-user": "bob" }, data: { id: items[0].id } },
|
|
)
|
|
).status(),
|
|
).toBe(422);
|
|
expect(
|
|
(
|
|
await request.patch(`${gatewayURL}/api/plugins/community.bookmarks`, {
|
|
headers: { "x-test-role": "admin" },
|
|
data: { revision: "fake", changes: { enabled: false } },
|
|
})
|
|
).status(),
|
|
).toBe(404);
|
|
if (process.env.EXTENSION_SCREENSHOT_DIR)
|
|
await page.screenshot({
|
|
path: path.join(
|
|
process.env.EXTENSION_SCREENSHOT_DIR,
|
|
"bookmarks-detail.png",
|
|
),
|
|
fullPage: true,
|
|
});
|
|
await page.getByRole("button", { name: "Delete", exact: true }).click();
|
|
await page
|
|
.getByRole("button", { name: "Confirm delete", exact: true })
|
|
.click();
|
|
await expect(
|
|
page.getByText("No matching bookmarks", { exact: true }),
|
|
).toBeVisible();
|
|
// A refreshed deployment snapshot removes both the entry and direct-page access.
|
|
await page.route("**/api/plugins", async (route) => {
|
|
const response = await request.get(`${gatewayURL}/api/plugins`);
|
|
const entries = await response.json();
|
|
await route.fulfill({
|
|
json: entries.map((entry: { settings: Record<string, unknown> }) => ({
|
|
...entry,
|
|
settings: { ...entry.settings, enabled: false },
|
|
})),
|
|
});
|
|
});
|
|
await page.reload();
|
|
await expect(libraryLink).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("heading", { name: "Extension page unavailable" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("searchbox", { name: "Search bookmarks" }),
|
|
).toHaveCount(0);
|
|
});
|
|
}
|
|
|
|
for (const brokenFactory of [
|
|
"throw new Error('broken plugin');",
|
|
"return { actions: undefined };",
|
|
"return { label: 'Broken', icon: 'bookmark', actions: [{ id: 'broken', label: 'Broken', icon: 'bookmark', available: async () => { throw new Error('rejected availability'); }, execute: async () => {} }] };",
|
|
]) {
|
|
test(`a broken plugin action factory is isolated: ${brokenFactory}`, async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
mockLangGraphAPI(page, {
|
|
threads: [
|
|
{
|
|
thread_id: MOCK_THREAD_ID,
|
|
title: "Healthy conversation",
|
|
messages: [
|
|
{
|
|
id: "answer",
|
|
type: "ai",
|
|
content: "The conversation is still usable.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const code = {
|
|
broken: `export default { apiVersion: 1, module: 'broken', conversationActions() { ${brokenFactory} } };`,
|
|
healthy: `export default { apiVersion: 1, module: 'healthy', conversationActions() { return { label: 'Healthy actions', icon: 'bookmark', actions: [{ id: 'test', label: 'Run healthy action', icon: 'bookmark', available: () => true, execute: async (_context, services) => services.showMessage('Healthy action completed.') }] }; } };`,
|
|
};
|
|
const entries = Object.entries(code).map(([module, source]) => ({
|
|
namespace: `test.${module}`,
|
|
module,
|
|
title: module,
|
|
description: "",
|
|
settings: { enabled: true },
|
|
entry: `/api/plugins/modules/${module}/${createHash("sha256").update(source).digest("hex")}.mjs`,
|
|
}));
|
|
await page.route("**/api/plugins**", async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const headers = {
|
|
"access-control-allow-origin": new URL(baseURL!).origin,
|
|
"access-control-allow-credentials": "true",
|
|
};
|
|
if (url.pathname.endsWith("/api/plugins"))
|
|
return route.fulfill({ json: entries, headers });
|
|
const entry = entries.find((item) => url.pathname.endsWith(item.entry));
|
|
if (!entry) return route.fulfill({ status: 404, headers });
|
|
return route.fulfill({
|
|
body: code[entry.module as keyof typeof code],
|
|
contentType: "text/javascript",
|
|
headers,
|
|
});
|
|
});
|
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
|
await expect(
|
|
page.getByText("The conversation is still usable.", { exact: true }),
|
|
).toBeVisible();
|
|
await page
|
|
.getByRole("button", { name: "Healthy actions", exact: true })
|
|
.click();
|
|
await page
|
|
.getByRole("menuitem", { name: "Run healthy action", exact: true })
|
|
.click();
|
|
await expect(
|
|
page.getByText("Healthy action completed.", { exact: true }),
|
|
).toBeVisible();
|
|
expect(errors).toEqual([]);
|
|
});
|
|
}
|
|
|
|
for (const locale of ["en-US", "zh-CN"]) {
|
|
test(`extension host uses ${locale} copy and its own search label`, async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const zh = locale === "zh-CN";
|
|
mockLangGraphAPI(page);
|
|
await page
|
|
.context()
|
|
.addCookies([{ name: "locale", value: locale, url: baseURL! }]);
|
|
await page.goto("/workspace/capabilities?tab=extensions");
|
|
await expect(
|
|
page.getByRole("tab", {
|
|
name: zh ? "扩展插件" : "Extensions",
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByPlaceholder(
|
|
zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
|
|
),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", {
|
|
name: zh
|
|
? "重新加载扩展(刷新页面)"
|
|
: "Reload extensions (refresh page)",
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
await expect(page.getByRole("article")).toHaveCount(
|
|
extensionCatalog.length,
|
|
);
|
|
const management = page.getByRole("article").filter({
|
|
hasText: zh ? "知识入库与处理" : "Knowledge ingestion and processing",
|
|
});
|
|
await expect(management).toHaveCount(1);
|
|
await expect(management).toContainText(
|
|
zh
|
|
? "需要安装和配置,不包含管理页面。"
|
|
: "Requires installation and configuration; no management page is included.",
|
|
);
|
|
const lightrag = page.getByRole("article").filter({ hasText: "LightRAG" });
|
|
await expect(lightrag).toHaveCount(1);
|
|
await expect(lightrag).toContainText(
|
|
zh
|
|
? "从 LightRAG 检索文档与实体关系,并使用统一的来源引用。"
|
|
: "Retrieve documents and entity relations from LightRAG with shared source citations.",
|
|
);
|
|
const weknoraTitle = zh
|
|
? "WeKnora 知识检索"
|
|
: "WeKnora knowledge retrieval";
|
|
const weknora = page.getByRole("article").filter({ hasText: weknoraTitle });
|
|
await expect(weknora).toHaveCount(1);
|
|
await expect(weknora).toContainText(
|
|
zh
|
|
? "需要管理员安装并配置访问权限。"
|
|
: "Requires administrator installation and access configuration.",
|
|
);
|
|
await weknora
|
|
.getByRole("button", {
|
|
name: zh ? `查看 ${weknoraTitle}` : `View ${weknoraTitle}`,
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
page.getByRole("link", {
|
|
name: zh ? "查看安装说明" : "Installation guide",
|
|
}),
|
|
).toHaveAttribute("href", /deerflow-extension-weknora#readme$/);
|
|
await page
|
|
.getByRole("button", {
|
|
name: zh ? "全部扩展" : "All extensions",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
const knowledgeTitle = zh ? "知识范围选择示例" : "Knowledge scope example";
|
|
const knowledge = page
|
|
.getByRole("article")
|
|
.filter({ hasText: knowledgeTitle });
|
|
await expect(knowledge).toHaveCount(1);
|
|
await expect(knowledge).toContainText(
|
|
zh
|
|
? "用示例文档演示知识库和文件范围选择、检索及来源引用。"
|
|
: "Search sample documents within selected knowledge bases or files and inspect source citations.",
|
|
);
|
|
await knowledge
|
|
.getByRole("button", {
|
|
name: zh ? `查看 ${knowledgeTitle}` : `View ${knowledgeTitle}`,
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
page.getByRole("heading", { name: knowledgeTitle, exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("link", {
|
|
name: zh ? "查看安装说明" : "Installation guide",
|
|
}),
|
|
).toHaveAttribute("href", /deerflow-extension-knowledge#readme$/);
|
|
await page
|
|
.getByRole("button", {
|
|
name: zh ? "全部扩展" : "All extensions",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
const workflow = page.getByRole("article").filter({
|
|
hasText: zh ? "脚本工作流" : "Script workflows",
|
|
});
|
|
await expect(workflow).toHaveCount(1);
|
|
await expect(workflow).toContainText(
|
|
zh
|
|
? "不支持重启回放或硬金额预算"
|
|
: "No restart replay or hard monetary budget",
|
|
);
|
|
await workflow
|
|
.getByRole("button", {
|
|
name: zh ? "查看 脚本工作流" : "View Script workflows",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
page.getByRole("link", {
|
|
name: zh ? "查看安装说明" : "Installation guide",
|
|
}),
|
|
).toHaveAttribute("href", /deerflow-extension-workflows#readme$/);
|
|
await page
|
|
.getByRole("button", {
|
|
name: zh ? "全部扩展" : "All extensions",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
page.getByRole("button", {
|
|
name: zh ? "查看 会话书签" : "View Bookmarks",
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
await page
|
|
.getByRole("button", {
|
|
name: zh ? "查看 Agent 团队" : "View Agent teams",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
page.getByRole("heading", {
|
|
name: zh ? "Agent 团队" : "Agent teams",
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByText(
|
|
zh
|
|
? "此条目来自仓库扩展目录;实际安装和启用状态请由部署管理员确认。"
|
|
: "This entry comes from the repository extension catalog. Ask your deployment administrator to confirm its installation and activation status.",
|
|
{ exact: true },
|
|
),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByText("deerflow-extension-agent-teams", { exact: true }),
|
|
).toBeVisible();
|
|
// The search only filters the directory, so the detail view drops it.
|
|
await expect(
|
|
page.getByPlaceholder(
|
|
zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
|
|
),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("link", {
|
|
name: zh ? "查看安装说明" : "Installation guide",
|
|
}),
|
|
).toHaveAttribute("href", /deerflow-extension-agent-teams#readme$/);
|
|
await page
|
|
.getByRole("button", {
|
|
name: zh ? "全部扩展" : "All extensions",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await page
|
|
.getByPlaceholder(
|
|
zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
|
|
)
|
|
.fill("deerflow-extension-retrieval");
|
|
await expect(page.getByRole("article")).toHaveCount(1);
|
|
await page
|
|
.getByRole("button", {
|
|
name: zh ? "查看 本地检索示例" : "View Local retrieval example",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
page.getByRole("heading", {
|
|
name: zh ? "本地检索示例" : "Local retrieval example",
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("link", {
|
|
name: zh ? "查看安装说明" : "Installation guide",
|
|
}),
|
|
).toHaveAttribute("href", /deerflow-extension-retrieval#readme$/);
|
|
await page
|
|
.getByRole("button", {
|
|
name: zh ? "全部扩展" : "All extensions",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await page
|
|
.getByPlaceholder(
|
|
zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
|
|
)
|
|
.fill("jev-screening");
|
|
await expect(page.getByRole("article")).toHaveCount(1);
|
|
await page
|
|
.getByRole("button", {
|
|
name: zh ? "查看 内容风险提示" : "View Content risk warnings",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
page.getByRole("link", {
|
|
name: zh ? "查看安装说明" : "Installation guide",
|
|
}),
|
|
).toHaveAttribute("href", /deerflow-extension-jev-screening#readme$/);
|
|
await page
|
|
.getByRole("button", {
|
|
name: zh ? "全部扩展" : "All extensions",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await page
|
|
.getByPlaceholder(
|
|
zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
|
|
)
|
|
.fill("no-such-extension");
|
|
await expect(
|
|
page.getByText(zh ? "没有匹配的扩展。" : "No matching extensions.", {
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
await page.goto("/workspace/extensions/missing.plugin/library");
|
|
await expect(
|
|
page.getByRole("heading", {
|
|
name: zh ? "扩展页面不可用" : "Extension page unavailable",
|
|
}),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("link", { name: zh ? "查看扩展" : "View extensions" }),
|
|
).toBeVisible();
|
|
});
|
|
}
|
|
|
|
test("extension detail fits a narrow viewport with its tab in view", async ({
|
|
page,
|
|
}) => {
|
|
mockLangGraphAPI(page);
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await page.goto(
|
|
"/workspace/capabilities?tab=extensions&extension=community.agent-teams",
|
|
);
|
|
await expect(
|
|
page.getByRole("heading", { name: "Agent teams", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("tab", { name: "Extensions", exact: true }),
|
|
).toBeInViewport({ ratio: 1 });
|
|
await expect(
|
|
page.getByRole("button", {
|
|
name: "Reload extensions (refresh page)",
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
const scroller = await capabilityCenterScroller(page);
|
|
expect(
|
|
await scroller.evaluate((node) => node.scrollWidth - node.clientWidth),
|
|
).toBe(0);
|
|
const note = page.getByRole("note");
|
|
const noteBox = await note.boundingBox();
|
|
const textBox = await note
|
|
.getByText(
|
|
"This entry comes from the repository extension catalog. Ask your deployment administrator to confirm its installation and activation status.",
|
|
{ exact: true },
|
|
)
|
|
.boundingBox();
|
|
expect(textBox!.x + textBox!.width).toBeLessThanOrEqual(
|
|
noteBox!.x + noteBox!.width,
|
|
);
|
|
});
|
|
|
|
test("capability tabs realign on resize without moving the page", async ({
|
|
page,
|
|
}) => {
|
|
mockLangGraphAPI(page);
|
|
await page.setViewportSize({ width: 375, height: 480 });
|
|
await page.goto("/workspace/capabilities?tab=extensions");
|
|
const extensions = page.getByRole("tab", {
|
|
name: "Extensions",
|
|
exact: true,
|
|
});
|
|
const strip = page
|
|
.getByRole("tablist")
|
|
.filter({ has: extensions })
|
|
.locator("..");
|
|
const clippedBy = async (tab: Locator) => {
|
|
const [tabBox, stripBox] = await Promise.all([
|
|
tab.boundingBox(),
|
|
strip.boundingBox(),
|
|
]);
|
|
return Math.max(
|
|
stripBox!.x - tabBox!.x,
|
|
tabBox!.x + tabBox!.width - stripBox!.x - stripBox!.width,
|
|
);
|
|
};
|
|
await expect(extensions).toBeInViewport({ ratio: 1 });
|
|
|
|
// A manual swipe stays where the user left it.
|
|
await strip.evaluate((node) => {
|
|
node.scrollLeft = 0;
|
|
});
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
|
),
|
|
);
|
|
expect(await strip.evaluate((node) => node.scrollLeft)).toBe(0);
|
|
expect(await clippedBy(extensions)).toBeGreaterThan(0);
|
|
|
|
// Scrolled past the strip, a resize realigns it and leaves the page alone.
|
|
const scroller = await capabilityCenterScroller(page);
|
|
await scroller.evaluate((node) => {
|
|
node.scrollTop = node.scrollHeight;
|
|
});
|
|
await expect(strip).not.toBeInViewport();
|
|
await page.setViewportSize({ width: 320, height: 480 });
|
|
await expect.poll(() => clippedBy(extensions)).toBeLessThan(1);
|
|
await expect(strip).not.toBeInViewport();
|
|
|
|
// The same holds for a tab clipped on the other side.
|
|
await page.setViewportSize({ width: 375, height: 480 });
|
|
await page.goto("/workspace/capabilities?tab=plugins");
|
|
const tools = page.getByRole("tab", {
|
|
name: "Tools & integrations",
|
|
exact: true,
|
|
});
|
|
await expect(tools).toBeInViewport({ ratio: 1 });
|
|
await strip.evaluate((node) => {
|
|
node.scrollLeft = node.scrollWidth;
|
|
});
|
|
expect(await clippedBy(tools)).toBeGreaterThan(0);
|
|
await page.setViewportSize({ width: 320, height: 480 });
|
|
await expect.poll(() => clippedBy(tools)).toBeLessThan(1);
|
|
});
|
|
|
|
test("unregistered plugin pages stay unavailable", async ({ page }) => {
|
|
mockLangGraphAPI(page);
|
|
await page.goto("/workspace/extensions/missing.plugin/library");
|
|
await expect(
|
|
page.getByRole("heading", { name: "Extension page unavailable" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("searchbox", { name: "Search bookmarks" }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("catalog remains discoverable when runtime discovery fails", async ({
|
|
page,
|
|
}) => {
|
|
mockLangGraphAPI(page);
|
|
await page.route("**/api/plugins", (route) =>
|
|
route.fulfill({ status: 503, json: { detail: "Unavailable" } }),
|
|
);
|
|
await page.goto("/workspace/capabilities?tab=extensions");
|
|
await expect(
|
|
page.getByRole("alert").filter({ hasText: "Extensions unavailable." }),
|
|
).toBeVisible();
|
|
await expect(page.getByRole("article")).toHaveCount(extensionCatalog.length);
|
|
await expect(
|
|
page.getByRole("article").filter({ hasText: "Catalog extension" }),
|
|
).toHaveCount(extensionCatalog.length);
|
|
await expect(
|
|
page.getByRole("article").filter({ hasText: "Script workflows" }),
|
|
).toHaveCount(1);
|
|
const knowledge = page
|
|
.getByRole("article")
|
|
.filter({ hasText: "Knowledge scope example" });
|
|
await expect(knowledge).toHaveCount(1);
|
|
await expect(knowledge).toContainText(
|
|
"Search sample documents within selected knowledge bases or files and inspect source citations.",
|
|
);
|
|
await knowledge
|
|
.getByRole("button", { name: "View Knowledge scope example", exact: true })
|
|
.click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Knowledge scope example", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("link", { name: "Installation guide" }),
|
|
).toHaveAttribute("href", /deerflow-extension-knowledge#readme$/);
|
|
});
|