');
});
test("renders the default workspace as a separate ungrouped conversation section", () => {
@@ -61,7 +78,8 @@ describe("sidebar projects", () => {
});
test("manages project folders without restoring the legacy workspace UI", () => {
- expect(sidebarSource).toContain("onDoubleClick={() => setProjectExpanded((expanded) => !expanded)}");
+ expect(sidebarSource).toMatch(/if \(isSelectedProject\)[\s\S]*setProjectExpanded\(\(expanded\) => !expanded\);/);
+ expect(sidebarSource).not.toContain("onDoubleClick={() => setProjectExpanded");
expect(sidebarSource).toContain('data-testid="project-new-conversation-button"');
expect(sidebarSource).toContain("const createConversationInProject = async () =>");
expect(sidebarSource).toMatch(/await onSelectProject\(workspace\.id\);[\s\S]*await ctx\.onCreateTaskInWorkspace\(workspace\.id\);/);
@@ -84,6 +102,54 @@ describe("sidebar projects", () => {
expect(sessionPageSource).toContain('{createProjectError ? {
+ expect(sessionPageSource).toContain("if (!name) return;");
+ expect(sessionPageSource).toContain('disabled={createProjectBusy || !createProjectName.trim()}');
+ expect(sessionRouteSource).toContain("folderPath: folderPath || undefined");
+ expect(sessionRouteSource).toContain('if (!folderPath) throw new Error(t("projects.create_failed"));');
+ expect(sessionRouteSource).not.toContain('if (!name || !requestedFolderPath)');
+ });
+
+ test("matches the project creation design with localized, theme-aware controls", () => {
+ expect(sessionPageSource).toContain('data-testid="create-project-dialog"');
+ expect(sessionPageSource).not.toContain('showCloseButton={false}');
+ expect(sessionPageSource).toContain('data-testid="project-folder-picker"');
+ expect(sessionPageSource).toContain('data-testid="project-engine-option"');
+ expect(sessionPageSource).toContain('max-w-[516px]');
+ expect(sessionPageSource).toContain('t("projects.engine_locked_notice")');
+ expect(sessionPageSource).toContain("');
+ expect(sessionPageSource).toContain('className="block text-[13px] font-medium leading-5 text-foreground"');
+ expect(sessionPageSource).toContain('className="text-xs leading-[18px] text-muted-foreground"');
+ expect(sessionPageSource).toContain('text-[11px] leading-4 text-muted-foreground');
+ expect(appStyleSource).toContain("--project-dialog-accent: #1fbac0");
+ expect(appStyleSource).toContain("--project-dialog-accent-strong: #a9e7ea");
+ expect(appStyleSource).toContain("--project-dialog-option-border: #e7e7e8");
+ expect(sessionPageSource).toContain('data-testid="project-name-icon"');
+ expect(sessionPageSource).toContain('className="pointer-events-none absolute start-3 top-1/2 z-10 flex size-6');
+ expect(sessionPageSource).toContain('className="flex h-10 w-full items-center gap-3 rounded-lg border border-border bg-background px-3');
+ expect(sessionPageSource).toContain('data-testid="project-folder-icon"');
+ expect(sessionPageSource).toContain('className="flex size-6 shrink-0 items-center justify-center"');
+ expect(sessionPageSource).toContain('placeholder-shown:text-[13px] placeholder-shown:leading-5 placeholder:text-slate-9 focus-visible:ring-0! has-focus-visible:ring-0! dark:placeholder:text-slate-11');
+ expect(sessionPageSource).toContain('data-testid="project-folder-label"');
+ expect(sessionPageSource).toContain('? "text-sm leading-[22px] text-foreground"');
+ expect(sessionPageSource).toContain(': "text-[13px] leading-5 text-slate-9 dark:text-slate-11"');
+ expect(sessionPageSource).not.toContain('h-[141px]');
+ expect(sessionPageSource).not.toContain('border-dashed');
+ expect(sessionPageSource).toContain('createProjectFolder || t("projects.choose_folder")');
+ expect(englishLocaleSource).not.toContain('"projects.add_folder"');
+ expect(chineseLocaleSource).not.toContain('"projects.add_folder"');
+ expect(englishLocaleSource).toContain('"projects.name_example": "For example: iPolloWork"');
+ expect(chineseLocaleSource).toContain('"projects.name_example": "例如:iPolloWork"');
+ expect(existsSync(new URL("../src/react-app/domains/session/chat/assets/project-engine-selected.svg", import.meta.url))).toBe(true);
+ expect(existsSync(new URL("../src/react-app/domains/session/chat/assets/project-engine-unselected.svg", import.meta.url))).toBe(true);
+ });
+
test("shows nested conversation activity on a collapsed project", () => {
const tree = buildSessionTreeState(
[
@@ -104,6 +170,8 @@ describe("sidebar projects", () => {
expect(sidebarSource).toContain("function ConversationList");
expect(sidebarSource).toContain("flattenSessionRows(");
expect(sidebarSource).toContain("remainingSessionCount");
+ expect(sidebarSource).toContain('');
+ expect(sidebarSource).toContain('const rowPadding = depth > 0 ? "ps-[68px]" : "ps-8";');
expect(sidebarSource).not.toContain("GroupedSessionList");
});
diff --git a/apps/app/tests/workspace-resize-performance.test.ts b/apps/app/tests/workspace-resize-performance.test.ts
index 488bf94fe..a2e610e4e 100644
--- a/apps/app/tests/workspace-resize-performance.test.ts
+++ b/apps/app/tests/workspace-resize-performance.test.ts
@@ -72,4 +72,16 @@ describe("workspace resize performance", () => {
"state.workspaceLeftSidebarResizing === previous.workspaceLeftSidebarResizing",
);
});
+
+ test("lets embedded settings shrink to the main workspace width", () => {
+ expect(sessionPageSource).toContain(
+ '',
+ );
+ });
+
+ test("hides the right-panel toggle from the embedded Extensions navigation", () => {
+ expect(sessionPageSource).toContain(
+ '{mainHeaderHidden && mainWorkspaceView !== "extensions" ? (',
+ );
+ });
});
diff --git a/apps/desktop/electron/workspace-store.mjs b/apps/desktop/electron/workspace-store.mjs
index bed3d7a19..531e66546 100644
--- a/apps/desktop/electron/workspace-store.mjs
+++ b/apps/desktop/electron/workspace-store.mjs
@@ -656,6 +656,13 @@ export function createWorkspaceStore({ app, defaultDenBaseUrl, defaultRequireSig
return path.join(os.homedir(), "iPolloWork");
}
+ function managedProjectWorkspaceDir() {
+ if (process.env.IPOLLOWORK_DEV_MODE === "1") {
+ return path.join(app.getPath("userData"), "ipollowork-dev-data", "home", ".ipollowork", "projects");
+ }
+ return path.join(os.homedir(), ".ipollowork", "projects");
+ }
+
// True first run: create the default "iPolloWork" workspace under the user's
// home directory so the renderer lands directly in a ready workspace — no
// folder picker, no empty state. Cross-platform (os.homedir + path.join).
@@ -1046,8 +1053,9 @@ export function createWorkspaceStore({ app, defaultDenBaseUrl, defaultRequireSig
async function createWorkspace(input = {}) {
const rawFolderPath = String(input.folderPath ?? "").trim();
- if (!rawFolderPath) throw new Error("folderPath is required");
- const folderPath = await normalizeLocalWorkspacePath(rawFolderPath);
+ const folderPath = rawFolderPath
+ ? await normalizeLocalWorkspacePath(rawFolderPath)
+ : path.join(managedProjectWorkspaceDir(), randomBytes(12).toString("hex"));
const folderPathKey = normalizeWorkspacePathKey(folderPath);
return mutateWorkspaceState(async (state) => {
@@ -1062,20 +1070,21 @@ export function createWorkspaceStore({ app, defaultDenBaseUrl, defaultRequireSig
}
await mkdir(folderPath, { recursive: true });
+ const materializedFolderPath = await normalizeLocalWorkspacePath(folderPath);
const preset = String(input.preset ?? "starter");
const workspace = normalizeWorkspaceEntry({
- id: localWorkspaceId(folderPath),
- name: String(input.name ?? (path.basename(folderPath) || "Workspace")),
- displayName: String(input.name ?? (path.basename(folderPath) || "Workspace")),
- path: folderPath,
+ id: localWorkspaceId(materializedFolderPath),
+ name: String(input.name ?? (path.basename(materializedFolderPath) || "Workspace")),
+ displayName: String(input.name ?? (path.basename(materializedFolderPath) || "Workspace")),
+ path: materializedFolderPath,
preset,
workContextId: input.workContextId,
workspaceType: "local",
engineId: input.engineId,
});
if (workspace.engineId === "opencode") {
- await mkdir(path.join(folderPath, ".opencode"), { recursive: true });
- await writeWorkspaceiPolloWorkConfig(folderPath, defaultWorkspaceiPolloWorkConfig(folderPath, preset));
+ await mkdir(path.join(materializedFolderPath, ".opencode"), { recursive: true });
+ await writeWorkspaceiPolloWorkConfig(materializedFolderPath, defaultWorkspaceiPolloWorkConfig(materializedFolderPath, preset));
}
state.workspaces.push(workspace);
diff --git a/apps/desktop/electron/workspace-store.test.mjs b/apps/desktop/electron/workspace-store.test.mjs
index 5605229ed..ef1b9a491 100644
--- a/apps/desktop/electron/workspace-store.test.mjs
+++ b/apps/desktop/electron/workspace-store.test.mjs
@@ -137,6 +137,22 @@ test("persists an enterprise work context on its dedicated workspace", async ()
});
});
+test("creates a managed local project folder when no source folder is provided", async () => {
+ await withIsolatedBootstrapStore(async ({ store, root }) => {
+ const created = await store.createWorkspace({
+ name: "Managed Project",
+ preset: "starter",
+ });
+ const workspace = created.workspaces[0];
+ const managedProjectsRoot = path.join(root, "home", ".ipollowork", "projects");
+
+ assert.equal(path.dirname(workspace.path), await realpath(managedProjectsRoot));
+ assert.equal(workspace.name, "Managed Project");
+ await access(workspace.path);
+ await access(path.join(workspace.path, ".opencode"));
+ });
+});
+
test("migrates the system workspace marker without classifying named projects as ungrouped", async () => {
await withIsolatedBootstrapStore(async ({ createStore, root, userDataPath }) => {
const defaultPath = path.join(root, "home", "iPolloWork");
diff --git a/evals/flows/embedded-plugin-library-responsive.flow.mjs b/evals/flows/embedded-plugin-library-responsive.flow.mjs
new file mode 100644
index 000000000..b17e5c3bc
--- /dev/null
+++ b/evals/flows/embedded-plugin-library-responsive.flow.mjs
@@ -0,0 +1,542 @@
+export default {
+ id: "embedded-plugin-library-responsive",
+ title: "Embedded plugin library responsive width",
+ kind: "user-facing",
+ steps: [
+ {
+ name: "Open Extensions from the primary sidebar",
+ run: async (ctx) => {
+ await ctx.prove("The embedded plugin library fits the remaining workspace width", {
+ action: async () => {
+ const viewport = await ctx.eval(`({ height: innerHeight })`);
+ await ctx.client.send("Emulation.setDeviceMetricsOverride", {
+ width: 800,
+ height: viewport.height,
+ deviceScaleFactor: 1,
+ mobile: false,
+ });
+ await ctx.navigateHash("/");
+ await ctx.waitFor(`(() => {
+ const sidebar = [...document.querySelectorAll('[data-slot="sidebar"][data-side="left"]')]
+ .find((entry) => entry.querySelector('[data-testid="brand-logo"]'));
+ return Boolean([...sidebar?.querySelectorAll('[data-slot="sidebar-menu-button"]') ?? []]
+ .find((entry) => ['扩展', 'Extensions'].includes(entry.textContent?.trim() ?? '')));
+ })()`, {
+ timeoutMs: 30_000,
+ label: "loaded primary sidebar",
+ });
+ await ctx.eval(`(() => {
+ const sidebar = [...document.querySelectorAll('[data-slot="sidebar"][data-side="left"]')]
+ .find((entry) => entry.querySelector('[data-testid="brand-logo"]'));
+ if (sidebar?.getAttribute('data-state') !== 'collapsed') return true;
+ const trigger = document.querySelector('button[aria-label="展开"], button[aria-label="Expand"]')
+ ?? document.querySelector('[data-testid="embedded-sidebar-restore"]')
+ ?? sidebar.querySelector('[data-sidebar="trigger"]');
+ trigger?.click();
+ return Boolean(trigger);
+ })()`);
+ await ctx.waitFor(`[...document.querySelectorAll('[data-slot="sidebar"][data-side="left"]')]
+ .find((entry) => entry.querySelector('[data-testid="brand-logo"]'))?.getAttribute('data-state') === 'expanded'`, {
+ timeoutMs: 5_000,
+ label: "expanded primary sidebar",
+ });
+ await ctx.waitFor(`(() => {
+ const sidebar = [...document.querySelectorAll('[data-slot="sidebar"][data-side="left"]')]
+ .find((entry) => entry.querySelector('[data-testid="brand-logo"]'));
+ return Boolean([...sidebar?.querySelectorAll('[data-slot="sidebar-menu-button"]') ?? []]
+ .find((entry) => ['扩展', 'Extensions'].includes(entry.textContent?.trim() ?? '') && entry.getClientRects().length > 0));
+ })()`, {
+ timeoutMs: 30_000,
+ label: "primary extensions entry",
+ });
+ const opened = await ctx.eval(`(() => {
+ const sidebar = [...document.querySelectorAll('[data-slot="sidebar"][data-side="left"]')]
+ .find((entry) => entry.querySelector('[data-testid="brand-logo"]'));
+ const button = [...sidebar?.querySelectorAll('[data-slot="sidebar-menu-button"]') ?? []]
+ .find((entry) => ['扩展', 'Extensions'].includes(entry.textContent?.trim() ?? '') && entry.getClientRects().length > 0);
+ button?.click();
+ return Boolean(button);
+ })()`);
+ ctx.assert(opened, "The primary Extensions entry could not be opened.");
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ await ctx.eval(`(() => {
+ if (document.querySelector('[data-settings-shell]')) return true;
+ const sidebar = [...document.querySelectorAll('[data-slot="sidebar"][data-side="left"]')]
+ .find((entry) => entry.querySelector('[data-testid="brand-logo"]'));
+ const button = [...sidebar?.querySelectorAll('[data-slot="sidebar-menu-button"]') ?? []]
+ .find((entry) => ['扩展', 'Extensions'].includes(entry.textContent?.trim() ?? '') && entry.getClientRects().length > 0);
+ button?.click();
+ return Boolean(button);
+ })()`);
+ await ctx.waitFor(`Boolean(document.querySelector('[data-settings-shell]')
+ && document.querySelector('[data-testid="plugin-installed-row"]'))`, {
+ timeoutMs: 30_000,
+ label: "embedded plugin library",
+ });
+ await ctx.client.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 799, y: 760 });
+
+ const layout = await ctx.eval(`(() => {
+ const shell = document.querySelector('[data-settings-shell]');
+ const content = document.querySelector('[data-settings-content]');
+ const safeArea = document.querySelector('[data-settings-safe-area]');
+ const row = document.querySelector('[data-testid="plugin-installed-row"]');
+ const installedSection = document.querySelector('[data-testid="plugin-library-installed"]');
+ const installedHeader = installedSection?.firstElementChild;
+ const tiles = [...document.querySelectorAll('[data-testid="plugin-installed-tile"]')];
+ const expand = document.querySelector('[data-testid="plugin-installed-expand"]');
+ const primaryNavigationButtons = [...document.querySelectorAll('[data-slot="sidebar-menu-button"]')]
+ .filter((button) => ['新建对话', 'New conversation', '模版', '模板', 'Templates', '扩展', 'Extensions']
+ .includes(button.textContent?.trim() ?? ''));
+ const primaryNavigationIcons = primaryNavigationButtons
+ .map((button) => button.querySelector('[aria-hidden="true"]'))
+ .filter(Boolean);
+ const primaryExtensionsButton = primaryNavigationButtons.find((button) =>
+ ['扩展', 'Extensions'].includes(button.textContent?.trim() ?? ''));
+ const selectedProjectRow = document.querySelector('[data-testid="project-row"][aria-current="page"]');
+ const selectedProjectGroup = selectedProjectRow?.closest('[data-slot="sidebar-group"]');
+ const selectedConversationRow = selectedProjectGroup?.querySelector(
+ '[data-slot="sidebar-menu-sub-button"]:not([aria-disabled="true"]), [data-slot="sidebar-menu-sub"] [data-slot="context-menu-trigger"]',
+ );
+ const selectedProjectIcon = selectedProjectRow?.querySelector('[aria-hidden="true"]');
+ if (!shell || !content || !safeArea || !row || !installedHeader || tiles.length === 0) return null;
+ const shellRect = shell.getBoundingClientRect();
+ const contentRect = content.getBoundingClientRect();
+ const safeAreaRect = safeArea.getBoundingClientRect();
+ const rowRect = row.getBoundingClientRect();
+ const remaining = Number(expand?.getAttribute('data-remaining-count') ?? '0');
+ const capacity = Math.max(1, Math.floor((rowRect.width + 8) / 56));
+ return {
+ shellWidthMatchesWorkspace: Math.abs(shellRect.width - (innerWidth - shellRect.left)) <= 1,
+ rightEdgesFit: [shellRect, contentRect, safeAreaRect, rowRect]
+ .every((rect) => rect.right <= innerWidth + 1),
+ tilesFitRow: tiles.every((tile) => tile.getBoundingClientRect().right <= rowRect.right + 1),
+ dividerToModuleGap: Math.round(rowRect.top - installedHeader.getBoundingClientRect().bottom),
+ previewCountFits: tiles.length <= capacity && tiles.length + remaining > 0,
+ rightPanelToggleHidden: ![...document.querySelectorAll('button')]
+ .some((button) => button.getClientRects().length > 0
+ && button.querySelector('img[src*="sidebar-right-"]')),
+ closeButtonHidden: !document.querySelector(
+ 'button[aria-label="关闭设置"], button[aria-label="Close settings"]',
+ ),
+ primaryNavigationIconsAligned: primaryNavigationIcons.length === 3
+ && primaryNavigationIcons.every((icon) => Math.round(icon.getBoundingClientRect().width) === 16)
+ && primaryNavigationIcons.every((icon) => Math.abs(icon.getBoundingClientRect().left - primaryNavigationIcons[0].getBoundingClientRect().left) < 1)
+ && primaryNavigationIcons.every((icon) => {
+ const artwork = icon.querySelector('img');
+ if (!artwork) return false;
+ const iconRect = icon.getBoundingClientRect();
+ const artworkRect = artwork.getBoundingClientRect();
+ return Math.round(Math.max(artworkRect.width, artworkRect.height)) === 14
+ && Math.abs((iconRect.left + iconRect.width / 2) - (artworkRect.left + artworkRect.width / 2)) < 1;
+ })
+ && primaryExtensionsButton?.querySelector('img')?.getAttribute('src')?.includes('sidebar-icon/toy-brick.svg'),
+ projectIconAligned: selectedProjectIcon
+ && Math.round(selectedProjectIcon.getBoundingClientRect().width) === 16
+ && Math.abs(selectedProjectIcon.getBoundingClientRect().left - primaryNavigationIcons[0].getBoundingClientRect().left) < 1
+ && Math.round(Math.max(
+ selectedProjectIcon.querySelector('img')?.getBoundingClientRect().width ?? 0,
+ selectedProjectIcon.querySelector('img')?.getBoundingClientRect().height ?? 0,
+ )) === 14,
+ navigationHoverAreasMatch: selectedProjectRow
+ && primaryNavigationButtons.every((button) => {
+ const buttonRect = button.getBoundingClientRect();
+ const projectRect = selectedProjectRow.getBoundingClientRect();
+ return Math.abs(buttonRect.left - projectRect.left) < 1
+ && Math.abs(buttonRect.right - projectRect.right) < 1
+ && Math.round(buttonRect.height) === Math.round(projectRect.height);
+ }),
+ conversationHoverAreaMatches: selectedProjectRow && selectedConversationRow
+ && Math.abs(selectedConversationRow.getBoundingClientRect().left - selectedProjectRow.getBoundingClientRect().left) < 1
+ && Math.abs(selectedConversationRow.getBoundingClientRect().right - selectedProjectRow.getBoundingClientRect().right) < 1
+ && Math.round(selectedConversationRow.getBoundingClientRect().height) === Math.round(selectedProjectRow.getBoundingClientRect().height),
+ selectedProjectUsesExpansionOnly: selectedProjectRow
+ && selectedProjectRow.getAttribute('aria-expanded') === 'true'
+ && getComputedStyle(selectedProjectRow).backgroundColor === 'rgba(0, 0, 0, 0)',
+ dimensions: {
+ viewport: innerWidth,
+ shellLeft: Math.round(shellRect.left),
+ shellWidth: Math.round(shellRect.width),
+ shellRight: Math.round(shellRect.right),
+ rowWidth: Math.round(rowRect.width),
+ visibleTiles: tiles.length,
+ remaining,
+ capacity,
+ projectRow: selectedProjectRow ? {
+ left: Math.round(selectedProjectRow.getBoundingClientRect().left),
+ right: Math.round(selectedProjectRow.getBoundingClientRect().right),
+ height: Math.round(selectedProjectRow.getBoundingClientRect().height),
+ } : null,
+ conversationRow: selectedConversationRow ? {
+ left: Math.round(selectedConversationRow.getBoundingClientRect().left),
+ right: Math.round(selectedConversationRow.getBoundingClientRect().right),
+ height: Math.round(selectedConversationRow.getBoundingClientRect().height),
+ } : null,
+ },
+ };
+ })()`);
+ ctx.assert(
+ layout?.shellWidthMatchesWorkspace,
+ `Embedded settings should shrink to the remaining workspace (${JSON.stringify(layout?.dimensions)}).`,
+ );
+ ctx.assert(layout?.rightEdgesFit, "Embedded plugin content should remain inside the window.");
+ ctx.assert(layout?.tilesFitRow, "Installed plugin previews should remain inside their visible row.");
+ ctx.assert(layout?.dividerToModuleGap === 12, "The installed plugin divider should sit 12px above the icon module.");
+ ctx.assert(layout?.previewCountFits, "Installed plugin preview count should use the embedded row width.");
+ ctx.assert(layout?.rightPanelToggleHidden, "Embedded Extensions should not show the right-panel toggle.");
+ ctx.assert(layout?.closeButtonHidden, "Embedded Extensions should not show a redundant close button.");
+ ctx.assert(layout?.primaryNavigationIconsAligned, "Primary navigation icons should share aligned 16px slots with 14px artwork.");
+ ctx.assert(layout?.projectIconAligned, "Project icons should share the primary navigation alignment and 14px artwork size.");
+ ctx.assert(layout?.navigationHoverAreasMatch, "Primary navigation and project rows should share one hover width and height.");
+ ctx.assert(layout?.conversationHoverAreaMatches, `Conversation and project rows should share one hover width and height (${JSON.stringify(layout?.dimensions)}).`);
+ ctx.assert(layout?.selectedProjectUsesExpansionOnly, "The selected project should expand its conversations without a persistent background.");
+
+ const hoverTargets = await ctx.eval(`(() => {
+ const primary = [...document.querySelectorAll('[data-slot="sidebar-menu-button"]')]
+ .find((button) => ['模板', '模版', 'Templates'].includes(button.textContent?.trim() ?? ''));
+ const project = document.querySelector('[data-testid="project-row"][aria-current="page"]');
+ const center = (entry) => {
+ const rect = entry?.getBoundingClientRect();
+ return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null;
+ };
+ return { primary: center(primary), project: center(project) };
+ })()`);
+ ctx.assert(hoverTargets?.primary && hoverTargets?.project, "Sidebar hover targets were unavailable.");
+ await ctx.client.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...hoverTargets.primary });
+ const primaryHoverColor = await ctx.eval(`getComputedStyle([...document.querySelectorAll('[data-slot="sidebar-menu-button"]')]
+ .find((button) => ['模板', '模版', 'Templates'].includes(button.textContent?.trim() ?? ''))).backgroundColor`);
+ await ctx.client.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...hoverTargets.project });
+ const projectHoverColor = await ctx.eval(`getComputedStyle(document.querySelector('[data-testid="project-row"][aria-current="page"]')).backgroundColor`);
+ ctx.assert(
+ primaryHoverColor === projectHoverColor && projectHoverColor !== 'rgba(0, 0, 0, 0)',
+ `Project hover should use the same visible color as primary navigation (${primaryHoverColor} / ${projectHoverColor}).`,
+ );
+
+ const collapsedProject = await ctx.eval(`(() => {
+ const row = document.querySelector('[data-testid="project-row"][aria-current="page"]');
+ row?.click();
+ return Boolean(row);
+ })()`);
+ ctx.assert(collapsedProject, "The selected project row could not be collapsed.");
+ await ctx.waitFor(`document.querySelector('[data-testid="project-row"][aria-current="page"]')?.getAttribute('aria-expanded') === 'false'`, {
+ timeoutMs: 5_000,
+ label: "collapsed project with one click",
+ });
+ await ctx.eval(`document.querySelector('[data-testid="project-row"][aria-current="page"]')?.click()`);
+ await ctx.waitFor(`document.querySelector('[data-testid="project-row"][aria-current="page"]')?.getAttribute('aria-expanded') === 'true'`, {
+ timeoutMs: 5_000,
+ label: "expanded project with one click",
+ });
+ },
+ assert: async () => {
+ const copy = await ctx.eval(`document.body.innerText`);
+ ctx.assert(copy.includes("插件") || copy.includes("Plugins"), "The plugin library heading should be visible.");
+ ctx.assert(copy.includes("已安装") || copy.includes("Installed"), "The installed plugin section should be visible.");
+ },
+ screenshot: {
+ name: "plugin-library-embedded-responsive",
+ rejectText: ["Something went wrong"],
+ hashIncludes: "/session",
+ },
+ });
+ },
+ },
+ {
+ name: "Preview and toggle installed plugins",
+ run: async (ctx) => {
+ await ctx.prove("Installed plugin previews stay compact and can be expanded or collapsed as a group", {
+ action: async () => {
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="plugin-installed-row"]')
+ && document.querySelector('[data-testid="plugin-installed-expand"]'))`, {
+ timeoutMs: 5_000,
+ label: "installed plugin overflow toggle",
+ });
+
+ const hoverPoint = await ctx.eval(`(() => {
+ const tile = [...document.querySelectorAll('[data-testid="plugin-installed-tile"]')].at(-1);
+ if (!tile) return null;
+ const rect = tile.getBoundingClientRect();
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+ })()`);
+ ctx.assert(hoverPoint, "No installed plugin preview was available to hover.");
+ await ctx.client.send("Input.dispatchMouseEvent", {
+ type: "mouseMoved",
+ x: hoverPoint.x,
+ y: hoverPoint.y,
+ });
+ await ctx.waitFor(`(() => {
+ const tiles = [...document.querySelectorAll('[data-testid="plugin-installed-tile"]')];
+ const gaps = tiles.slice(1).map((tile, index) =>
+ Math.round(tile.getBoundingClientRect().left - tiles[index].getBoundingClientRect().right));
+ return tiles.length > 0
+ && tiles.every((tile) => Math.round(tile.getBoundingClientRect().width) === 48)
+ && gaps.every((gap) => gap === 8)
+ && getComputedStyle(tiles.at(-1)).borderColor === 'rgb(31, 186, 192)'
+ && getComputedStyle(tiles.at(-1)).borderTopWidth === '2px'
+ && !tiles.at(-1).hasAttribute('title')
+ && Boolean(document.querySelector('[data-testid="plugin-installed-tooltip"]')?.getClientRects().length)
+ && !document.querySelector('[data-testid="plugin-installed-details"]');
+ })()`, {
+ timeoutMs: 5_000,
+ label: "fixed compact installed plugin previews",
+ });
+ await ctx.client.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 0, y: 0 });
+
+ const collapsedCount = await ctx.eval(`document.querySelectorAll('[data-testid="plugin-installed-tile"]').length`);
+ const expanded = await ctx.eval(`(() => {
+ const toggle = document.querySelector('[data-testid="plugin-installed-expand"]');
+ toggle?.click();
+ return Boolean(toggle);
+ })()`);
+ ctx.assert(expanded, "The installed plugin overflow toggle could not be expanded.");
+ await ctx.waitFor(`(() => {
+ const toggle = document.querySelector('[data-testid="plugin-installed-expand"]');
+ return toggle?.getAttribute('aria-expanded') === 'true'
+ && ['收起', 'Collapse'].includes(toggle.textContent?.trim() ?? '')
+ && document.querySelectorAll('[data-testid="plugin-installed-tile"]').length > ${collapsedCount};
+ })()`, {
+ timeoutMs: 5_000,
+ label: "expanded installed plugins",
+ });
+ },
+ assert: async () => {
+ const copy = await ctx.eval(`document.body.innerText`);
+ ctx.assert(copy.includes("已安装") || copy.includes("Installed"), "The installed plugin section should be visible.");
+ },
+ screenshot: {
+ name: "plugin-library-installed-previews",
+ rejectText: ["Something went wrong"],
+ hashIncludes: "/session",
+ },
+ });
+ },
+ },
+ {
+ name: "Collapse installed plugins",
+ run: async (ctx) => {
+ await ctx.prove("The expanded installed plugin group can return to its compact row", {
+ action: async () => {
+ const expandedCount = await ctx.eval(`document.querySelectorAll('[data-testid="plugin-installed-tile"]').length`);
+ const collapsed = await ctx.eval(`(() => {
+ const toggle = document.querySelector('[data-testid="plugin-installed-expand"]');
+ toggle?.click();
+ return Boolean(toggle);
+ })()`);
+ ctx.assert(collapsed, "The installed plugin overflow toggle could not be collapsed.");
+ await ctx.waitFor(`document.querySelector('[data-testid="plugin-installed-expand"]')?.getAttribute('aria-expanded') === 'false'
+ && document.querySelectorAll('[data-testid="plugin-installed-tile"]').length < ${expandedCount}`, {
+ timeoutMs: 5_000,
+ label: "collapsed installed plugins",
+ });
+ },
+ });
+ },
+ },
+ {
+ name: "Open the settings select menu",
+ run: async (ctx) => {
+ await ctx.prove("Settings selects reuse the compact design-panel menu states", {
+ action: async () => {
+ const opened = await ctx.eval(`(() => {
+ const trigger = document.querySelector('[data-testid="plugin-category-filter"]');
+ trigger?.click();
+ return Boolean(trigger);
+ })()`);
+ ctx.assert(opened, "The plugin category select could not be opened.");
+ await ctx.waitFor(`Boolean(document.querySelector('[data-slot="select-content"]')?.getClientRects().length)`, {
+ timeoutMs: 5_000,
+ label: "settings select menu",
+ });
+ const hoverPoint = await ctx.eval(`(() => {
+ const item = [...document.querySelectorAll('[data-slot="select-item"]')].find((entry) => entry.getAttribute('aria-selected') !== 'true');
+ if (!item) return null;
+ const rect = item.getBoundingClientRect();
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+ })()`);
+ ctx.assert(hoverPoint, "No unselected settings option was available to hover.");
+ await ctx.client.send("Input.dispatchMouseEvent", {
+ type: "mouseMoved",
+ x: hoverPoint.x,
+ y: hoverPoint.y,
+ });
+ await ctx.waitFor(`(() => {
+ const trigger = document.querySelector('[data-testid="plugin-category-filter"]');
+ const popup = document.querySelector('[data-slot="select-content"]');
+ const items = [...document.querySelectorAll('[data-slot="select-item"]')];
+ const hovered = items.find((item) => item.hasAttribute('data-highlighted'));
+ const selected = items.find((item) => item.getAttribute('aria-selected') === 'true');
+ const indicator = selected?.querySelector('svg');
+ if (!trigger || !popup || !hovered || !indicator || items.length === 0) return false;
+ return Math.round(trigger.getBoundingClientRect().height) === 34
+ && popup.getBoundingClientRect().width > trigger.getBoundingClientRect().width
+ && popup.getBoundingClientRect().right <= innerWidth
+ && items.every((item) => item.scrollWidth <= item.clientWidth)
+ && getComputedStyle(trigger).borderRadius === '8px'
+ && getComputedStyle(popup).borderRadius === '8px'
+ && getComputedStyle(popup).backgroundColor === 'rgb(255, 255, 255)'
+ && items.every((item) => Math.round(item.getBoundingClientRect().height) === 32)
+ && items.every((item) => getComputedStyle(item).borderRadius === '6px')
+ && getComputedStyle(hovered).backgroundColor === 'rgb(246, 247, 251)'
+ && getComputedStyle(indicator).color === 'rgb(31, 186, 192)';
+ })()`, {
+ timeoutMs: 5_000,
+ label: "design-panel settings select states",
+ });
+ },
+ screenshot: {
+ name: "settings-select-design-panel-states",
+ rejectText: ["Something went wrong"],
+ hashIncludes: "/session",
+ },
+ });
+ },
+ },
+ {
+ name: "Restore the primary sidebar from Extensions",
+ run: async (ctx) => {
+ await ctx.prove("The Extensions navigation keeps a way to reopen the collapsed primary sidebar", {
+ action: async () => {
+ await ctx.client.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape" });
+ await ctx.client.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape" });
+ const collapsed = await ctx.eval(`(() => {
+ const sidebar = document.querySelector('[data-slot="sidebar-container"]');
+ const trigger = sidebar?.querySelector('[data-sidebar="trigger"]');
+ trigger?.click();
+ return Boolean(trigger);
+ })()`);
+ ctx.assert(collapsed, "The primary sidebar collapse action was unavailable.");
+ await ctx.waitFor(`(() => {
+ const restore = document.querySelector('[data-testid="embedded-sidebar-restore"]');
+ const sidebar = document.querySelector('[data-slot="sidebar"][data-side="left"]');
+ return Boolean(restore?.getClientRects().length)
+ && sidebar?.getAttribute('data-state') === 'collapsed';
+ })()`, {
+ timeoutMs: 5_000,
+ label: "embedded sidebar restore action",
+ });
+ const restored = await ctx.eval(`(() => {
+ const restore = document.querySelector('[data-testid="embedded-sidebar-restore"]');
+ restore?.click();
+ return Boolean(restore);
+ })()`);
+ ctx.assert(restored, "The embedded Extensions navigation could not restore the primary sidebar.");
+ await ctx.waitFor(`(() => {
+ const sidebar = document.querySelector('[data-slot="sidebar"][data-side="left"]');
+ return sidebar?.getAttribute('data-state') === 'expanded'
+ && !document.querySelector('[data-testid="embedded-sidebar-restore"]');
+ })()`, {
+ timeoutMs: 5_000,
+ label: "restored primary sidebar",
+ });
+ },
+ screenshot: {
+ name: "plugin-library-sidebar-restored",
+ rejectText: ["Something went wrong"],
+ hashIncludes: "/session",
+ },
+ });
+ },
+ },
+ {
+ name: "Clamp plugin descriptions",
+ run: async (ctx) => {
+ await ctx.prove("Plugin card descriptions stop after two lines and use an ellipsis", {
+ action: async () => {
+ await ctx.client.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape" });
+ await ctx.client.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape" });
+ const opened = await ctx.eval(`(() => {
+ const tab = [...document.querySelectorAll('[data-testid="plugin-library-source"] [role="tab"]')]
+ .find((entry) => ['个人', 'Personal'].includes(entry.textContent?.trim() ?? ''));
+ tab?.click();
+ return Boolean(tab);
+ })()`);
+ ctx.assert(opened, "The Personal plugin source could not be opened.");
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="plugin-package-list-item"]'))`, {
+ timeoutMs: 5_000,
+ label: "personal plugin cards",
+ });
+ const descriptions = await ctx.eval(`[...document.querySelectorAll('[data-testid="plugin-package-card-copy"]')]
+ .map((copy) => {
+ const description = copy.children[1];
+ if (!description) return null;
+ const style = getComputedStyle(description);
+ const lineHeight = Number.parseFloat(style.lineHeight);
+ return {
+ lineClamp: style.webkitLineClamp,
+ overflow: style.overflow,
+ height: description.getBoundingClientRect().height,
+ maxHeight: lineHeight * 2 + 1,
+ };
+ })`);
+ ctx.assert(
+ descriptions.length > 0 && descriptions.every((entry) =>
+ entry?.lineClamp === '2' && entry.overflow === 'hidden' && entry.height <= entry.maxHeight),
+ `Plugin descriptions should clamp to two lines (${JSON.stringify(descriptions)}).`,
+ );
+ },
+ assert: async () => {
+ const copy = await ctx.eval(`document.body.innerText`);
+ ctx.assert(copy.includes("个人") || copy.includes("Personal"), "The Personal plugin source should be visible.");
+ },
+ screenshot: {
+ name: "plugin-library-two-line-descriptions",
+ rejectText: ["Something went wrong"],
+ hashIncludes: "/session",
+ },
+ });
+ },
+ },
+ {
+ name: "Keep skill section spacing consistent",
+ run: async (ctx) => {
+ await ctx.prove("Skill sections use the same 12px divider-to-content spacing", {
+ action: async () => {
+ const opened = await ctx.eval(`(() => {
+ const tab = [...document.querySelectorAll('[role="tab"]')]
+ .find((entry) => ['技能', 'Skills'].includes(entry.textContent?.trim() ?? ''));
+ tab?.click();
+ return Boolean(tab);
+ })()`);
+ ctx.assert(opened, "The embedded Skills tab could not be opened.");
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="skills-installed-section"]')
+ && document.querySelector('[data-testid="skills-cloud-section"]')
+ && document.querySelector('[data-testid="skills-hub-section"]'))`, {
+ timeoutMs: 30_000,
+ label: "embedded skill sections",
+ });
+ const gaps = await ctx.eval(`['skills-installed-section', 'skills-cloud-section', 'skills-hub-section']
+ .map((testId) => {
+ const header = document.querySelector('[data-testid="' + testId + '"]');
+ const content = header?.nextElementSibling;
+ return header && content
+ ? Math.round(content.getBoundingClientRect().top - header.getBoundingClientRect().bottom)
+ : null;
+ })`);
+ ctx.assert(gaps.every((gap) => gap === 12), `Skill section spacing should be 12px (${JSON.stringify(gaps)}).`);
+ },
+ assert: async () => {
+ const copy = await ctx.eval(`document.body.innerText`);
+ ctx.assert(copy.includes("技能") || copy.includes("Skills"), "The skill library heading should be visible.");
+ ctx.assert(copy.includes("已安装") || copy.includes("Installed"), "The installed skill section should be visible.");
+ },
+ screenshot: {
+ name: "skill-library-section-spacing",
+ rejectText: ["Something went wrong"],
+ hashIncludes: "/session",
+ },
+ });
+ },
+ },
+ {
+ name: "Restore the desktop viewport",
+ run: async (ctx) => {
+ await ctx.prove("The responsive check leaves the desktop viewport unchanged", {
+ action: async () => {
+ await ctx.client.send("Emulation.clearDeviceMetricsOverride");
+ const viewportReady = await ctx.eval(`innerWidth > 0 && innerHeight > 0`);
+ ctx.assert(viewportReady, "The desktop viewport was not restored after the responsive check.");
+ },
+ });
+ },
+ },
+ ],
+};
diff --git a/evals/flows/plugin-library.flow.mjs b/evals/flows/plugin-library.flow.mjs
index bd2cf4103..39ae5207f 100644
--- a/evals/flows/plugin-library.flow.mjs
+++ b/evals/flows/plugin-library.flow.mjs
@@ -1,3 +1,36 @@
+let forcedHoverNodeId = null;
+
+async function clearForcedHover(ctx) {
+ if (!forcedHoverNodeId) return;
+ try {
+ await ctx.client.send("CSS.forcePseudoState", {
+ nodeId: forcedHoverNodeId,
+ forcedPseudoClasses: [],
+ });
+ } catch (error) {
+ if (!String(error).includes("Could not find node with given id")) throw error;
+ } finally {
+ forcedHoverNodeId = null;
+ }
+}
+
+async function forceHover(ctx, selector) {
+ await ctx.client.send("DOM.enable");
+ await ctx.client.send("CSS.enable");
+ await clearForcedHover(ctx);
+ const { root } = await ctx.client.send("DOM.getDocument");
+ const { nodeId } = await ctx.client.send("DOM.querySelector", {
+ nodeId: root.nodeId,
+ selector,
+ });
+ ctx.assert(nodeId, `Unable to force hover for ${selector}.`);
+ await ctx.client.send("CSS.forcePseudoState", {
+ nodeId,
+ forcedPseudoClasses: ["hover"],
+ });
+ forcedHoverNodeId = nodeId;
+}
+
export default {
id: "plugin-library",
title: "Unified plugin library navigation and catalog",
@@ -8,58 +41,246 @@ export default {
run: async (ctx) => {
await ctx.prove("Plugins open as one searchable capability-package library", {
action: async () => {
+ await ctx.client.send("Emulation.clearDeviceMetricsOverride");
await ctx.navigateHash("/settings/extensions");
- await ctx.waitFor(`Boolean([...document.querySelectorAll('[role="tab"]')]
+ const pluginsSelected = await ctx.waitFor(`Boolean([...document.querySelectorAll('[role="tab"]')]
.find((entry) => ['插件', 'Plugins'].includes(entry.textContent?.trim() ?? '')))`, {
timeoutMs: 30_000,
label: "plugin library tab",
});
- const pluginsSelected = await ctx.eval(`(() => {
+ ctx.assert(pluginsSelected, "Plugins tab was not found.");
+ await ctx.eval(`(() => {
const tab = [...document.querySelectorAll('[role="tab"]')]
.find((entry) => ['插件', 'Plugins'].includes(entry.textContent?.trim() ?? ''));
- tab?.click();
- return Boolean(tab);
+ if (tab?.getAttribute('aria-selected') !== 'true') tab?.click();
})()`);
- ctx.assert(pluginsSelected, "Plugins tab was not found.");
+ await ctx.waitFor(`[...document.querySelectorAll('[role="tab"][aria-selected="true"]')]
+ .some((entry) => ['插件', 'Plugins'].includes(entry.textContent?.trim() ?? ''))`, {
+ timeoutMs: 5_000,
+ label: "selected plugin library tab",
+ });
await ctx.waitFor(`Boolean(document.querySelector('input[aria-label="搜索插件"], input[aria-label="Search plugins"]'))`, {
timeoutMs: 30_000,
label: "plugin library search",
});
- const compactStructure = await ctx.eval(`(() => {
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="plugin-installed-tile"]'))`, {
+ timeoutMs: 30_000,
+ label: "installed plugin previews",
+ });
+ await ctx.eval(`(() => {
+ const publicTab = [...document.querySelectorAll('[role="tab"]')]
+ .find((entry) => ['公开', 'Public'].includes(entry.textContent?.trim() ?? ''));
+ if (publicTab?.getAttribute('aria-selected') !== 'true') publicTab?.click();
+ })()`);
+ await ctx.waitFor(`[...document.querySelectorAll('[role="tab"][aria-selected="true"]')]
+ .some((entry) => ['公开', 'Public'].includes(entry.textContent?.trim() ?? ''))`, {
+ timeoutMs: 5_000,
+ label: "public plugin source",
+ });
+ await clearForcedHover(ctx);
+ await ctx.client.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 0, y: 0 });
+ await ctx.waitFor(`[...document.querySelectorAll('[data-testid="plugin-installed-tile"]')]
+ .every((entry) => Math.round(entry.getBoundingClientRect().width) === 48)`, {
+ timeoutMs: 5_000,
+ label: "collapsed installed plugin previews",
+ });
+ const libraryStructure = await ctx.eval(`(() => {
const activeTabs = [...document.querySelectorAll('[role="tab"][aria-selected="true"]')]
.map((entry) => entry.textContent?.trim());
const shellHeader = document.querySelector('main > header');
- const installedIcons = [...document.querySelectorAll('button[aria-label^="打开"], button[aria-label^="Open"]')];
+ const navigationActions = shellHeader?.querySelector('[data-testid="plugin-library-navigation-actions"]');
+ const navigationTabs = shellHeader?.querySelector('[role="tablist"]');
+ const navigationButtons = [...(navigationActions?.querySelectorAll('button') ?? [])];
+ const refreshButton = navigationButtons.find((entry) => ['刷新', 'Refresh'].includes(entry.textContent?.trim() ?? ''));
+ const addButton = navigationButtons.find((entry) => ['添加', 'Add'].includes(entry.textContent?.trim() ?? ''));
+ const sidebarContainer = document.querySelector('[data-slot="sidebar-container"]');
+ const settingsNavigationButtons = [...(sidebarContainer?.querySelectorAll('[data-slot="sidebar-menu-button"]') ?? [])];
+ const settingsNavigationIcons = settingsNavigationButtons
+ .map((button) => button.querySelector(':scope > span[aria-hidden="true"]'))
+ .filter(Boolean);
+ const extensionsNavigationButton = settingsNavigationButtons.find((button) =>
+ ['扩展', 'Extensions'].includes(button.textContent?.trim() ?? ''));
+ const heading = document.querySelector('[data-testid="plugin-library-heading"]');
+ const installedHeading = document.querySelector('[data-testid="plugin-library-installed"] h2');
+ const settingsContent = document.querySelector('[data-settings-content]');
+ const settingsSafeArea = document.querySelector('[data-settings-safe-area]');
+ const headerSafeArea = document.querySelector('[data-settings-header-safe-area]');
+ const sourceTabs = document.querySelector('[data-testid="plugin-library-source"] [role="tablist"]');
+ const selectedSourceTab = sourceTabs?.querySelector('[role="tab"][aria-selected="true"]');
+ const defaultSourceTab = sourceTabs?.querySelector('[role="tab"][aria-selected="false"]');
+ const selectedNavigationTab = navigationTabs?.querySelector('[role="tab"][aria-selected="true"]');
+ const installedTiles = [...document.querySelectorAll('[data-testid="plugin-installed-tile"]')];
+ const installedSection = document.querySelector('[data-testid="plugin-library-installed"]');
+ const installedIconSources = installedTiles.map((entry) => entry.querySelector('img')?.getAttribute('src') ?? '');
+ const ipolloIcons = installedTiles
+ .map((entry) => entry.querySelector('img'))
+ .filter((image) => image?.getAttribute('src')?.includes('ipollowork-mark.svg'));
+ const verticalCenter = (entry) => {
+ const rect = entry.getBoundingClientRect();
+ return rect.top + rect.height / 2;
+ };
+ const hasNoVisibleShadow = (value) => {
+ if (value === 'none') return true;
+ const colors = [...value.matchAll(/rgba\\([^)]*,\\s*([\\d.]+)\\)/g)];
+ return colors.length > 0 && colors.every(([, alpha]) => Number(alpha) === 0);
+ };
return {
- personalFirst: activeTabs.includes('个人') || activeTabs.includes('Personal'),
- noExtensionTitle: ![...(shellHeader?.querySelectorAll('h1') ?? [])]
- .some((entry) => ['扩展', 'Extensions'].includes(entry.textContent?.trim() ?? '')),
- compactIcons: installedIcons.every((entry) => entry.getBoundingClientRect().width <= 37),
+ publicFirst: activeTabs.includes('公开') || activeTabs.includes('Public'),
+ actionsInSettingsHeader: Boolean(shellHeader && navigationTabs && navigationActions),
+ notificationRemoved: !document.querySelector('button[aria-label^="通知"], button[aria-label^="Notifications"]'),
+ navigationDividersRemoved: shellHeader && sidebarContainer
+ && getComputedStyle(shellHeader).borderBottomWidth === '0px'
+ && getComputedStyle(sidebarContainer).borderRightWidth === '0px'
+ && getComputedStyle(sidebarContainer).borderLeftWidth === '0px',
+ navigationSingleRow: navigationTabs && refreshButton && addButton
+ && Math.abs(verticalCenter(navigationTabs) - verticalCenter(refreshButton)) < 2
+ && Math.abs(verticalCenter(refreshButton) - verticalCenter(addButton)) < 2,
+ typographyHierarchy: heading && installedHeading
+ && getComputedStyle(heading).fontSize === '24px'
+ && getComputedStyle(heading).lineHeight === '32px'
+ && getComputedStyle(heading).fontWeight === '600'
+ && getComputedStyle(installedHeading).fontSize === '14px'
+ && getComputedStyle(installedHeading).lineHeight === '20px'
+ && getComputedStyle(installedHeading).fontWeight === '600',
+ navigationTabsMatchDesign: navigationTabs && selectedNavigationTab
+ && getComputedStyle(navigationTabs).height === '28px'
+ && getComputedStyle(navigationTabs).backgroundColor === 'rgba(0, 0, 0, 0)'
+ && getComputedStyle(selectedNavigationTab).height === '28px'
+ && getComputedStyle(selectedNavigationTab).borderRadius === '8px'
+ && getComputedStyle(selectedNavigationTab).fontWeight === '500',
+ sourceTabsMatchDesign: selectedSourceTab && defaultSourceTab
+ && getComputedStyle(selectedSourceTab).height === '28px'
+ && getComputedStyle(selectedSourceTab).borderRadius === '8px'
+ && getComputedStyle(selectedSourceTab).fontWeight === '500'
+ && getComputedStyle(defaultSourceTab).height === '28px'
+ && getComputedStyle(defaultSourceTab).backgroundColor === 'rgba(0, 0, 0, 0)',
+ settingsSafeArea: settingsContent && settingsSafeArea && headerSafeArea && heading
+ && getComputedStyle(settingsContent).paddingLeft === (innerWidth >= 1920 ? '64px' : innerWidth >= 1600 ? '48px' : '32px')
+ && getComputedStyle(settingsContent).paddingRight === (innerWidth >= 1920 ? '64px' : innerWidth >= 1600 ? '48px' : '32px')
+ && settingsSafeArea.getBoundingClientRect().width <= 1280
+ && Math.abs(heading.getBoundingClientRect().left - settingsSafeArea.getBoundingClientRect().left) < 1
+ && Math.abs(headerSafeArea.getBoundingClientRect().left - settingsSafeArea.getBoundingClientRect().left) < 1
+ && Math.abs(headerSafeArea.getBoundingClientRect().right - settingsSafeArea.getBoundingClientRect().right) < 1,
+ installedTilesMatchDesign: installedTiles.every((entry) => Math.round(entry.getBoundingClientRect().width) === 48),
+ redundantManageRemoved: ![...(installedSection?.querySelectorAll('button') ?? [])]
+ .some((entry) => ['管理', 'Manage'].includes(entry.textContent?.trim() ?? '')),
+ installedOverflowMatchesDesign: (() => {
+ const row = document.querySelector('[data-testid="plugin-installed-row"]');
+ const expand = document.querySelector('[data-testid="plugin-installed-expand"]');
+ if (!expand) return true;
+ if (!row) return false;
+ const remaining = Number(expand.getAttribute('data-remaining-count') ?? '0');
+ const total = installedTiles.length + remaining;
+ const capacity = Math.max(1, Math.floor((row.getBoundingClientRect().width + 8) / 56));
+ const thumbnailCount = expand.querySelector('[data-testid="plugin-installed-overflow-thumbnails"]')?.children.length ?? 0;
+ return total > capacity
+ && expand.getBoundingClientRect().top >= row.getBoundingClientRect().bottom
+ && thumbnailCount === Math.min(3, remaining);
+ })(),
+ curatedIconsUpdated: installedIconSources.some((src) => src.includes('ext-figma.svg'))
+ && installedIconSources.some((src) => src.includes('ext-github.svg')),
+ ipolloLogoUpdated: ipolloIcons.every((image) => image.naturalWidth === 281 && image.naturalHeight === 298),
+ hasInstalledSection: Boolean(document.querySelector('[data-testid="plugin-library-installed"]')),
+ hasMarketplaceFilters: Boolean(document.querySelector('[data-testid="plugin-category-filter"]'))
+ && Boolean(document.querySelector('[data-testid="plugin-status-filter"]')),
+ controlsUseEightPixelCorners: addButton
+ && getComputedStyle(addButton).borderRadius === '8px'
+ && [...document.querySelectorAll('[data-testid="plugin-category-filter"], [data-testid="plugin-status-filter"]')]
+ .every((entry) => getComputedStyle(entry).borderRadius === '8px'),
+ outlineButtonsAreFlat: refreshButton
+ && hasNoVisibleShadow(getComputedStyle(refreshButton).boxShadow)
+ && hasNoVisibleShadow(getComputedStyle(refreshButton, '::before').boxShadow),
+ settingsNavigationIconsMatch: settingsNavigationIcons.length > 0
+ && settingsNavigationIcons.every((icon) => Math.round(icon.getBoundingClientRect().width) === 16)
+ && settingsNavigationIcons.every((icon) => Math.abs(icon.getBoundingClientRect().left - settingsNavigationIcons[0].getBoundingClientRect().left) < 1)
+ && settingsNavigationIcons.every((icon) => {
+ const artwork = icon.querySelector('svg, img');
+ if (!artwork) return false;
+ const iconRect = icon.getBoundingClientRect();
+ const artworkRect = artwork.getBoundingClientRect();
+ const expectedArtworkSize = artwork.tagName === 'IMG' ? 15 : 16;
+ return Math.round(Math.max(artworkRect.width, artworkRect.height)) === expectedArtworkSize
+ && Math.abs((iconRect.left + iconRect.width / 2) - (artworkRect.left + artworkRect.width / 2)) < 1;
+ })
+ && settingsNavigationIcons
+ .flatMap((icon) => [...icon.querySelectorAll('svg')])
+ .every((icon) => icon.getAttribute('stroke-width') === '1'
+ && [...icon.querySelectorAll('*')].every((part) => getComputedStyle(part).vectorEffect === 'non-scaling-stroke'))
+ && extensionsNavigationButton?.querySelector('img')?.getAttribute('src')?.includes('sidebar-icon/toy-brick.svg'),
+ settingsIconDetails: settingsNavigationIcons.map((icon) => {
+ const artwork = icon.querySelector('svg, img');
+ const iconRect = icon.getBoundingClientRect();
+ const artworkRect = artwork?.getBoundingClientRect();
+ return {
+ slot: [Math.round(iconRect.width), Math.round(iconRect.height)],
+ artwork: artworkRect ? [Math.round(artworkRect.width), Math.round(artworkRect.height)] : null,
+ stroke: artwork?.getAttribute('stroke-width'),
+ source: artwork?.getAttribute('src'),
+ };
+ }),
};
})()`);
- ctx.assert(compactStructure.personalFirst, "Personal plugins should be the default source.");
- ctx.assert(compactStructure.noExtensionTitle, "The shell header should show plugin tabs instead of an Extensions title.");
- ctx.assert(compactStructure.compactIcons, "Installed plugin icons should use the compact size.");
- const marketplaceSelected = await ctx.eval(`(() => {
- const tab = [...document.querySelectorAll('[role="tab"]')]
- .find((entry) => ['市场', 'Marketplace'].includes(entry.textContent?.trim() ?? ''));
- tab?.click();
- return Boolean(tab);
+ ctx.assert(libraryStructure.publicFirst, "Public plugins should be the default source.");
+ ctx.assert(libraryStructure.actionsInSettingsHeader, "Plugin tabs and actions should be mounted in the settings navigation header.");
+ ctx.assert(libraryStructure.notificationRemoved, "The notification bell should be removed from the plugin navigation header.");
+ ctx.assert(libraryStructure.navigationDividersRemoved, "Settings navigation and content should not be separated by gray divider lines.");
+ ctx.assert(libraryStructure.navigationSingleRow, "Plugin tabs and actions should share one navigation row.");
+ ctx.assert(libraryStructure.typographyHierarchy, "The plugin page should use the 24px page-title and 14px section-title hierarchy.");
+ ctx.assert(libraryStructure.navigationTabsMatchDesign, "Plugin navigation tabs should match the shared 28px Figma states.");
+ ctx.assert(libraryStructure.sourceTabsMatchDesign, "Plugin source tabs should match the shared 28px Figma states.");
+ ctx.assert(libraryStructure.settingsSafeArea, "Settings content and navigation should share the responsive 1280px safe area.");
+ ctx.assert(libraryStructure.installedTilesMatchDesign, "Installed plugin tiles should use only the 48px gray icon surface.");
+ ctx.assert(libraryStructure.redundantManageRemoved, "The installed section should not duplicate the Personal tab with a Manage button.");
+ ctx.assert(libraryStructure.installedOverflowMatchesDesign, "Installed overflow should appear only when the row is full and render below it with thumbnails.");
+ ctx.assert(libraryStructure.curatedIconsUpdated, "Featured third-party plugins should use curated SVGs.");
+ ctx.assert(libraryStructure.ipolloLogoUpdated, "Installed iPollo agents should render the current 281×298 iPollo logo asset.");
+ ctx.assert(libraryStructure.hasInstalledSection, "Installed plugin section was not found.");
+ ctx.assert(libraryStructure.hasMarketplaceFilters, "Marketplace category and status filters were not found.");
+ ctx.assert(libraryStructure.controlsUseEightPixelCorners, "Settings buttons and inputs should use 8px corners.");
+ ctx.assert(libraryStructure.outlineButtonsAreFlat, "Settings outline buttons should not render outer or inset shadows.");
+ ctx.assert(libraryStructure.settingsNavigationIconsMatch, `Settings navigation icons should share 16px slots, use 16px Lucide artwork with a 15px optically balanced Extensions icon, preserve non-scaling 1px strokes, and share the Extensions artwork (${JSON.stringify(libraryStructure.settingsIconDetails)}).`);
+ await forceHover(ctx, '[data-testid="plugin-library-source"] [role="tab"][aria-selected="false"]');
+ await ctx.waitFor(`(() => {
+ const tab = document.querySelector('[data-testid="plugin-library-source"] [role="tab"][aria-selected="false"]');
+ if (!tab) return false;
+ const background = getComputedStyle(tab).backgroundColor;
+ return document.documentElement.classList.contains('dark')
+ ? background !== 'rgba(0, 0, 0, 0)'
+ : background === 'rgb(246, 247, 251)';
+ })()`, {
+ timeoutMs: 5_000,
+ label: "plugin source hover state",
+ });
+ const hoverTargetReady = await ctx.eval(`(() => {
+ const tiles = [...document.querySelectorAll('[data-testid="plugin-installed-tile"]')];
+ const tile = tiles.at(-1);
+ if (!tile) return false;
+ tile.setAttribute('data-fraimz-hover-target', 'installed-plugin');
+ return true;
})()`);
- ctx.assert(marketplaceSelected, "Marketplace plugin source tab was not found.");
+ ctx.assert(hoverTargetReady, "No installed plugin tile was available to hover.");
+ await forceHover(ctx, '[data-fraimz-hover-target="installed-plugin"]');
+ await ctx.waitFor(`(() => {
+ const tiles = [...document.querySelectorAll('[data-testid="plugin-installed-tile"]')];
+ const gaps = tiles.slice(1).map((tile, index) =>
+ Math.round(tile.getBoundingClientRect().left - tiles[index].getBoundingClientRect().right));
+ return tiles.length > 0
+ && tiles.every((tile) => Math.round(tile.getBoundingClientRect().width) === 48)
+ && gaps.every((gap) => gap === 8)
+ && getComputedStyle(tiles.at(-1)).borderColor === 'rgb(31, 186, 192)'
+ && getComputedStyle(tiles.at(-1)).borderTopWidth === '2px'
+ && !document.querySelector('[data-testid="plugin-installed-details"]');
+ })()`, {
+ timeoutMs: 5_000,
+ label: "fixed installed plugin previews",
+ });
await ctx.waitFor(`(() => {
const text = document.body.innerText;
const loading = text.includes('正在加载市场') || text.includes('Loading marketplace');
- const settled = [
- '精选',
- 'Featured',
- 'AI Agent 与自动化',
- 'AI Agents & Automation',
- '市场暂无插件',
- 'No marketplace plugins',
- '登录后浏览插件市场',
- 'Sign in to browse the plugin marketplace',
- ].some((value) => text.includes(value));
+ const settled = Boolean(document.querySelector('[data-testid="plugin-package-list-item"]')) || [
+ '市场暂无插件', 'No marketplace plugins',
+ '登录后浏览插件市场', 'Sign in to browse the plugin marketplace',
+ ].some((value) => text.includes(value)) || Boolean(document.querySelector('[role="alert"]'));
const pluginIcons = [...document.querySelectorAll('button[aria-label^="打开"] img, button[aria-label^="Open"] img')];
const iconsLoaded = pluginIcons.length === 0 || pluginIcons.every((image) => image.complete && image.naturalWidth > 0);
return !loading && settled && iconsLoaded;
@@ -67,19 +288,95 @@ export default {
timeoutMs: 30_000,
label: "settled marketplace catalog",
});
+ await clearForcedHover(ctx);
+ await ctx.waitFor(`[...document.querySelectorAll('[data-testid="plugin-installed-tile"]')]
+ .every((entry) => Math.round(entry.getBoundingClientRect().width) === 48)`, {
+ timeoutMs: 5_000,
+ label: "collapsed installed previews before marketplace hover",
+ });
+ await ctx.client.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 0, y: 0 });
+ const marketplaceCard = await ctx.eval(`(() => {
+ const card = document.querySelector('[data-testid="plugin-package-list-item"]');
+ if (!card) return null;
+ const copy = card.querySelector('[data-testid="plugin-package-card-copy"]');
+ const rect = card.getBoundingClientRect();
+ const style = getComputedStyle(card);
+ const columns = getComputedStyle(card.parentElement).gridTemplateColumns.split(' ').filter(Boolean);
+ return {
+ height: Math.round(rect.height),
+ borderless: style.borderTopWidth === '0px'
+ && style.borderRightWidth === '0px'
+ && style.borderBottomWidth === '0px'
+ && style.borderLeftWidth === '0px',
+ transparent: style.backgroundColor === 'rgba(0, 0, 0, 0)',
+ columns: columns.length,
+ viewportWide: innerWidth >= 1024,
+ copyRows: copy?.children.length ?? 0,
+ legacyMetadataRemoved: !/\bSkills\b|\bMCP\b|\bTools\b|工具/.test(card.textContent ?? ''),
+ };
+ })()`);
+ ctx.assert(marketplaceCard, "No marketplace plugin card was available to validate.");
+ ctx.assert(marketplaceCard.height === 74, "Marketplace plugin cards should be 74px tall.");
+ ctx.assert(marketplaceCard.borderless && marketplaceCard.transparent, "Default marketplace cards should be transparent and borderless.");
+ ctx.assert(!marketplaceCard.viewportWide || marketplaceCard.columns === 2, "Wide marketplace lists should use the Figma two-column grid.");
+ ctx.assert(marketplaceCard.copyRows === 2 && marketplaceCard.legacyMetadataRemoved, "Marketplace cards should show only the Figma title and description copy.");
+ await forceHover(ctx, '[data-testid="plugin-package-list-item"]');
+ await ctx.waitFor(`(() => {
+ const card = document.querySelector('[data-testid="plugin-package-list-item"]');
+ if (!card) return false;
+ const style = getComputedStyle(card);
+ const backgroundMatches = document.documentElement.classList.contains('dark')
+ ? style.backgroundColor !== 'rgba(0, 0, 0, 0)'
+ : style.backgroundColor === 'rgb(246, 247, 251)';
+ return backgroundMatches
+ && style.borderTopWidth === '0px'
+ && style.borderRightWidth === '0px'
+ && style.borderBottomWidth === '0px'
+ && style.borderLeftWidth === '0px';
+ })()`, {
+ timeoutMs: 5_000,
+ label: "borderless marketplace card hover",
+ });
+
+ await clearForcedHover(ctx);
+ const viewport = await ctx.eval(`({ width: innerWidth, height: innerHeight })`);
+ await ctx.client.send("Emulation.setDeviceMetricsOverride", {
+ width: 900,
+ height: viewport.height,
+ deviceScaleFactor: 1,
+ mobile: false,
+ });
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="plugin-installed-expand"]'))`, {
+ timeoutMs: 5_000,
+ label: "installed plugin overflow control",
+ });
+ const overflowPreview = await ctx.eval(`(() => {
+ const row = document.querySelector('[data-testid="plugin-installed-row"]');
+ const expand = document.querySelector('[data-testid="plugin-installed-expand"]');
+ if (!row || !expand) return null;
+ const remaining = Number(expand.getAttribute('data-remaining-count') ?? '0');
+ return {
+ belowRow: expand.getBoundingClientRect().top >= row.getBoundingClientRect().bottom,
+ remaining,
+ thumbnailCount: expand.querySelector('[data-testid="plugin-installed-overflow-thumbnails"]')?.children.length ?? 0,
+ };
+ })()`);
+ ctx.assert(overflowPreview?.belowRow, "The installed overflow preview should sit below the plugin row.");
+ ctx.assert(overflowPreview?.remaining > 0, "The installed overflow preview should represent hidden plugins.");
+ ctx.assert(overflowPreview?.thumbnailCount === Math.min(3, overflowPreview.remaining), "The installed overflow preview should show up to three plugin thumbnails.");
},
assert: async () => {
await ctx.expectText("插件");
await ctx.expectText("技能");
await ctx.expectText("已安装");
- await ctx.expectText("市场");
+ await ctx.expectText("公开");
await ctx.expectText("个人");
await ctx.expectNoText("添加自定义应用");
await ctx.expectNoText("你的应用");
},
screenshot: {
name: "plugin-library-marketplace",
- requireText: ["插件", "技能", "已安装", "市场", "个人"],
+ requireText: ["插件", "技能", "已安装", "公开", "个人"],
rejectText: ["添加自定义应用", "你的应用", "Something went wrong"],
hashIncludes: "/settings/extensions",
},
@@ -91,6 +388,19 @@ export default {
run: async (ctx) => {
await ctx.prove("Installed marketplace entries open the canonical installed plugin detail", {
action: async () => {
+ const collapsedPluginCount = await ctx.eval(`document.querySelectorAll('[data-testid="plugin-installed-tile"]').length`);
+ const expanded = await ctx.eval(`(() => {
+ const control = document.querySelector('[data-testid="plugin-installed-expand"]');
+ control?.click();
+ return Boolean(control);
+ })()`);
+ ctx.assert(expanded, "Installed plugin overflow control was not available to expand.");
+ await ctx.waitFor(`document.querySelector('[data-testid="plugin-installed-expand"]')?.getAttribute('aria-expanded') === 'true'
+ && document.querySelectorAll('[data-testid="plugin-installed-tile"]').length > ${collapsedPluginCount}`, {
+ timeoutMs: 5_000,
+ label: "expanded installed plugins",
+ });
+ await ctx.client.send("Emulation.clearDeviceMetricsOverride");
const opened = await ctx.eval(`(() => {
const installedLabels = ['已安装', 'Installed'];
const rows = [...document.querySelectorAll('main div')]
@@ -135,7 +445,7 @@ export default {
return Boolean(tab);
})()`);
ctx.assert(clicked, "Personal plugin source tab was not found.");
- await ctx.waitFor(`document.body.innerText.includes('个人插件') || document.body.innerText.includes('Personal plugins')`, {
+ await ctx.waitFor(`document.body.innerText.includes('管理当前工作区安装或导入的完整插件能力包。') || document.body.innerText.includes('Manage complete plugin packages installed or imported into this workspace.')`, {
timeoutMs: 30_000,
label: "personal plugin packages",
});
@@ -146,7 +456,7 @@ export default {
},
screenshot: {
name: "plugin-library-personal",
- requireText: ["个人插件", "已安装"],
+ requireText: ["个人", "已安装"],
rejectText: ["可用应用", "你的应用", "Something went wrong"],
},
});
@@ -157,18 +467,17 @@ export default {
run: async (ctx) => {
await ctx.prove("Installed plugins share one detail page with enablement and authorization state", {
action: async () => {
+ await ctx.navigateHash("/settings/extensions");
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="plugin-installed-tile"]'))`, {
+ timeoutMs: 30_000,
+ label: "installed plugin previews",
+ });
const opened = await ctx.eval(`(() => {
- const authorizationLabels = ['需要授权', 'Authorization required'];
- const rows = [...document.querySelectorAll('main div')]
- .filter((entry) => authorizationLabels.some((label) => entry.innerText?.includes(label)))
- .sort((left, right) => left.querySelectorAll('button').length - right.querySelectorAll('button').length);
- const authorizationButton = rows.find((entry) => entry.querySelectorAll('button').length >= 2)?.querySelector('button');
- const fallbackButton = document.querySelector('button[aria-label^="打开"], button[aria-label^="Open"]');
- const target = authorizationButton ?? fallbackButton;
+ const target = document.querySelector('[data-testid="plugin-installed-tile"]');
target?.click();
- return { clicked: Boolean(target), authorizationExpected: Boolean(authorizationButton) };
+ return Boolean(target);
})()`);
- ctx.assert(opened.clicked, "No installed plugin could be opened.");
+ ctx.assert(opened, "No installed plugin could be opened.");
await ctx.waitFor(`location.hash.includes('/settings/extensions/plugin/')`, {
timeoutMs: 30_000,
label: "canonical plugin detail route",
@@ -205,25 +514,365 @@ export default {
{
name: "Switch to the skills index",
run: async (ctx) => {
- await ctx.prove("Skills remain a first-class index inside the same extension surface", {
+ await ctx.prove("Skills use the Figma library hierarchy with compact actions, filters, and responsive cards", {
action: async () => {
await ctx.navigateHash("/settings/extensions/skills");
- await ctx.waitFor(`Boolean(document.querySelector('input[placeholder="搜索已安装、团队和中心skills"], input[placeholder="Search installed, team, and hub skills"]'))`, {
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="skills-library-search"]'))`, {
timeoutMs: 30_000,
label: "skills index",
});
+ const skillsLayout = await ctx.eval(`(() => {
+ const section = document.querySelector('[data-testid="settings-standard-page"]');
+ const safeArea = document.querySelector('[data-settings-safe-area]');
+ const headerActions = document.querySelector('[data-testid="skills-library-navigation-actions"]');
+ const heading = section?.querySelector('h2');
+ const description = heading?.nextElementSibling;
+ const search = document.querySelector('[data-testid="skills-library-search"]');
+ const source = document.querySelector('[data-testid="skills-library-source"]');
+ const selectedSource = source?.querySelector('[role="tab"][aria-selected="true"]');
+ const defaultSource = source?.querySelector('[role="tab"][aria-selected="false"]');
+ const status = document.querySelector('[data-testid="skills-status-filter"]');
+ const installed = document.querySelector('[data-testid="skills-installed-section"]');
+ const installedHeading = installed?.querySelector('h3');
+ const grid = document.querySelector('[data-testid="skills-installed-grid"]');
+ const card = document.querySelector('[data-testid="skill-library-card"]');
+ if (!section || !safeArea || !heading || !description || !search || !source || !selectedSource || !defaultSource || !status || !installed || !installedHeading || !grid || !card) return null;
+ const sectionRect = section.getBoundingClientRect();
+ const safeAreaRect = safeArea.getBoundingClientRect();
+ const actionLabels = [...(headerActions?.querySelectorAll('button') ?? [])]
+ .map((button) => button.textContent?.trim());
+ return {
+ centered: sectionRect.width <= 960
+ && Math.abs((sectionRect.left + sectionRect.width / 2)
+ - (safeAreaRect.left + safeAreaRect.width / 2)) < 1,
+ actionsInHeader: Boolean(headerActions)
+ && ['刷新', 'Refresh'].some((label) => actionLabels.includes(label))
+ && ['导入', 'Import'].some((label) => actionLabels.includes(label))
+ && ['在聊天中创建', 'Create in chat'].some((label) => actionLabels.includes(label)),
+ typography: getComputedStyle(heading).fontSize === '24px'
+ && getComputedStyle(heading).fontWeight === '600'
+ && getComputedStyle(description).fontSize === '13px'
+ && getComputedStyle(description).lineHeight === '20px'
+ && getComputedStyle(installedHeading).fontSize === '14px'
+ && getComputedStyle(installedHeading).lineHeight === '20px',
+ searchMatches: Math.round(search.getBoundingClientRect().height) === 34
+ && getComputedStyle(search).borderRadius === '8px',
+ filtersPresent: source.querySelectorAll('button').length === 3
+ && Math.round(status.getBoundingClientRect().height) === 34,
+ sourceTabsMatchPlugins: Math.round(selectedSource.getBoundingClientRect().height) === 28
+ && getComputedStyle(selectedSource).borderRadius === '8px'
+ && getComputedStyle(selectedSource).fontWeight === '500'
+ && getComputedStyle(defaultSource).backgroundColor === 'rgba(0, 0, 0, 0)',
+ installedDivider: getComputedStyle(installed).borderBottomWidth === '1px',
+ twoColumnCards: innerWidth < 1024 || getComputedStyle(grid).gridTemplateColumns.split(' ').length === 2,
+ flatCards: getComputedStyle(card).borderTopWidth === '0px'
+ && getComputedStyle(card).boxShadow === 'none'
+ && getComputedStyle(card).borderRadius === '8px',
+ cardUninstallRemoved: ![...card.querySelectorAll('button')].some((button) =>
+ ['卸载', 'Uninstall'].includes(button.getAttribute('aria-label') ?? '')
+ || ['卸载', 'Uninstall'].includes(button.getAttribute('title') ?? '')),
+ };
+ })()`);
+ ctx.assert(skillsLayout?.centered, "Skills should use the centered 960px settings content width.");
+ ctx.assert(skillsLayout?.actionsInHeader, "Skills actions should share the settings navigation row.");
+ ctx.assert(skillsLayout?.typography, "Skills should share the plugin page typography hierarchy.");
+ ctx.assert(skillsLayout?.searchMatches, "Skills search should use the 34px borderless Figma field.");
+ ctx.assert(skillsLayout?.filtersPresent, "Skills source and status filters should be present.");
+ ctx.assert(skillsLayout?.sourceTabsMatchPlugins, "Skills source tabs should reuse the plugin tab states.");
+ ctx.assert(skillsLayout?.installedDivider, "Installed skills should use the section divider from the design.");
+ ctx.assert(skillsLayout?.twoColumnCards, "Installed skills should use a responsive two-column grid.");
+ ctx.assert(skillsLayout?.flatCards, "Skill cards should be flat, borderless, and 8px rounded.");
+ ctx.assert(skillsLayout?.cardUninstallRemoved, "Installed skill cards should not expose uninstall outside the editor.");
},
assert: async () => {
- await ctx.expectText("导入本地skill");
+ await ctx.expectText("在聊天中创建");
await ctx.expectNoText("添加自定义应用");
},
screenshot: {
name: "plugin-library-skills",
- requireText: ["技能", "导入本地skill"],
+ requireText: ["技能", "已安装"],
rejectText: ["添加自定义应用", "Something went wrong"],
},
});
},
},
+ {
+ name: "Open an installed skill editor",
+ run: async (ctx) => {
+ await ctx.prove("Skill editing keeps save and uninstall in a clear bottom action area", {
+ action: async () => {
+ const opened = await ctx.eval(`(() => {
+ const card = document.querySelector('[data-testid="skill-library-card"]');
+ if (!card) return false;
+ const edit = [...card.querySelectorAll('button')]
+ .find((button) => ['编辑', 'Edit'].includes(button.textContent?.trim() ?? ''));
+ edit?.click();
+ return Boolean(edit);
+ })()`);
+ ctx.assert(opened, "No installed skill editor could be opened.");
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="skill-editor-actions"]'))`, {
+ timeoutMs: 30_000,
+ label: "skill editor bottom actions",
+ });
+ await ctx.waitFor(`Boolean(document.querySelector('[data-slot="dialog-content"] textarea'))`, {
+ timeoutMs: 30_000,
+ label: "loaded skill editor",
+ });
+ const editorLayout = await ctx.eval(`(() => {
+ const dialog = document.querySelector('[data-slot="dialog-content"]');
+ const close = dialog?.querySelector('[data-slot="dialog-close"]');
+ const actions = dialog?.querySelector('[data-testid="skill-editor-actions"]');
+ const uninstall = dialog?.querySelector('[data-testid="skill-editor-uninstall"]');
+ const save = dialog?.querySelector('[data-testid="skill-editor-save"]');
+ const textArea = dialog?.querySelector('textarea');
+ if (!dialog || !close || !actions || !uninstall || !save || !textArea) return null;
+ const dialogRect = dialog.getBoundingClientRect();
+ const closeRect = close.getBoundingClientRect();
+ const actionsRect = actions.getBoundingClientRect();
+ const uninstallRect = uninstall.getBoundingClientRect();
+ const saveRect = save.getBoundingClientRect();
+ const overlaps = (left, right) => !(
+ left.right <= right.left
+ || left.left >= right.right
+ || left.bottom <= right.top
+ || left.top >= right.bottom
+ );
+ return {
+ actionsAtBottom: actionsRect.top > textArea.getBoundingClientRect().top
+ && actionsRect.bottom <= dialogRect.bottom + 1,
+ actionsSeparated: uninstallRect.right < saveRect.left,
+ closeClear: !overlaps(closeRect, uninstallRect)
+ && !overlaps(closeRect, saveRect)
+ && closeRect.bottom < actionsRect.top,
+ labels: [uninstall.textContent?.trim(), save.textContent?.trim()],
+ };
+ })()`);
+ ctx.assert(editorLayout?.actionsAtBottom, "Skill editor actions should stay in the modal footer.");
+ ctx.assert(editorLayout?.actionsSeparated, "Uninstall should stay left while Save stays right.");
+ ctx.assert(editorLayout?.closeClear, "Footer actions should not overlap the dialog close button.");
+ ctx.assert(editorLayout?.labels.some((label) => ['卸载', 'Uninstall'].includes(label)), "The editor footer should include Uninstall.");
+ ctx.assert(editorLayout?.labels.some((label) => ['保存', 'Save'].includes(label)), "The editor footer should include Save.");
+ },
+ assert: async () => {
+ await ctx.expectText("卸载");
+ await ctx.expectText("保存");
+ },
+ screenshot: {
+ name: "plugin-library-skill-editor-actions",
+ requireText: ["卸载", "保存"],
+ rejectText: ["Something went wrong"],
+ hashIncludes: "/settings/extensions/skills",
+ },
+ });
+ },
+ },
+ {
+ name: "Handle an unavailable team skill catalog",
+ run: async (ctx) => {
+ await ctx.prove("Team skill failures use a localized message instead of raw request text", {
+ action: async () => {
+ await ctx.eval(`(() => {
+ document.querySelector('[data-slot="dialog-content"] [data-slot="dialog-close"]')?.click();
+ })()`);
+ await ctx.waitFor(`!document.querySelector('[data-slot="dialog-content"]')`, {
+ timeoutMs: 5_000,
+ label: "closed skill editor",
+ });
+ const installedHeader = await ctx.eval(`(() => {
+ const section = document.querySelector('[data-testid="skills-installed-section"]');
+ const heading = section?.querySelector('h3');
+ const description = section?.querySelector('p');
+ if (!section || !heading || !description) return null;
+ return {
+ headingFontSize: getComputedStyle(heading).fontSize,
+ headingLineHeight: getComputedStyle(heading).lineHeight,
+ headingWeight: getComputedStyle(heading).fontWeight,
+ descriptionFontSize: getComputedStyle(description).fontSize,
+ descriptionLineHeight: getComputedStyle(description).lineHeight,
+ borderBottomWidth: getComputedStyle(section).borderBottomWidth,
+ };
+ })()`);
+ ctx.assert(installedHeader, "The installed skills section header was not available for comparison.");
+ const openedTeam = await ctx.eval(`(() => {
+ const tab = [...document.querySelectorAll('[data-testid="skills-library-source"] [role="tab"]')]
+ .find((entry) => ['团队', 'Team'].includes(entry.textContent?.trim() ?? ''));
+ tab?.click();
+ return Boolean(tab);
+ })()`);
+ ctx.assert(openedTeam, "The Team skills tab was not found.");
+ await ctx.waitFor(`(() => {
+ const text = document.body.innerText;
+ return text.includes('团队 Skill 暂时无法加载,请稍后重试。')
+ || text.includes('Team skills are temporarily unavailable. Please try again later.');
+ })()`, {
+ timeoutMs: 30_000,
+ label: "localized team skill error",
+ });
+ const teamLayout = await ctx.eval(`(() => {
+ const section = document.querySelector('[data-testid="skills-cloud-section"]');
+ const heading = section?.querySelector('h3');
+ const description = section?.querySelector('p');
+ const workspace = section?.querySelector('span');
+ const error = document.querySelector('[data-testid="skills-cloud-error"]');
+ const topRefresh = [...(document.querySelector('[data-testid="skills-library-navigation-actions"]')?.querySelectorAll('button') ?? [])]
+ .find((button) => ['刷新', 'Refresh'].includes(button.textContent?.trim() ?? ''));
+ const duplicateRefresh = [...document.querySelectorAll('main button')]
+ .some((button) => ['刷新团队skills', 'Refresh team skills'].includes(button.textContent?.trim() ?? ''));
+ if (!section || !heading || !description || !workspace || !error) return null;
+ const sectionRect = section.getBoundingClientRect();
+ const workspaceRect = workspace.getBoundingClientRect();
+ return {
+ headingFontSize: getComputedStyle(heading).fontSize,
+ headingLineHeight: getComputedStyle(heading).lineHeight,
+ headingWeight: getComputedStyle(heading).fontWeight,
+ descriptionFontSize: getComputedStyle(description).fontSize,
+ descriptionLineHeight: getComputedStyle(description).lineHeight,
+ borderBottomWidth: getComputedStyle(section).borderBottomWidth,
+ workspaceRightAligned: Math.abs(workspaceRect.right - sectionRect.right) < 1,
+ errorHeight: Math.round(error.getBoundingClientRect().height),
+ errorColor: getComputedStyle(error).color,
+ errorBackground: getComputedStyle(error).backgroundColor,
+ topRefreshAvailable: Boolean(topRefresh),
+ duplicateRefreshRemoved: !duplicateRefresh,
+ };
+ })()`);
+ ctx.assert(teamLayout, "The Team skills section layout was not available.");
+ ctx.assert(
+ teamLayout.headingFontSize === installedHeader.headingFontSize
+ && teamLayout.headingLineHeight === installedHeader.headingLineHeight
+ && teamLayout.headingWeight === installedHeader.headingWeight
+ && teamLayout.descriptionFontSize === installedHeader.descriptionFontSize
+ && teamLayout.descriptionLineHeight === installedHeader.descriptionLineHeight
+ && teamLayout.borderBottomWidth === installedHeader.borderBottomWidth,
+ "Team skills should use the same section hierarchy as All skills.",
+ );
+ ctx.assert(teamLayout.workspaceRightAligned, "The active workspace should align to the right of the organization header.");
+ ctx.assert(teamLayout.errorHeight === 36, "The Team skills error should be 36px high.");
+ ctx.assert(teamLayout.errorColor === 'rgb(229, 72, 77)', "The Team skills error should use #E5484D text.");
+ ctx.assert(teamLayout.errorBackground !== 'rgba(0, 0, 0, 0)', "The Team skills error should use the shared light error background.");
+ ctx.assert(teamLayout.topRefreshAvailable, "The settings navigation should keep the shared Refresh action.");
+ ctx.assert(teamLayout.duplicateRefreshRemoved, "The Team skills section should not duplicate the Refresh action.");
+ },
+ assert: async () => {
+ await ctx.expectText("团队 Skill 暂时无法加载,请稍后重试。");
+ await ctx.expectNoText("Request failed with 404");
+ await ctx.expectNoText("刷新团队skills");
+ },
+ screenshot: {
+ name: "plugin-library-team-skills-unavailable",
+ requireText: ["团队 Skill 暂时无法加载,请稍后重试。"],
+ rejectText: ["Request failed with 404", "Something went wrong"],
+ hashIncludes: "/settings/extensions/skills",
+ },
+ });
+ },
+ },
+ {
+ name: "Keep the Skill Hub hierarchy consistent",
+ run: async (ctx) => {
+ await ctx.prove("The Skill Hub uses the shared section header and top Refresh action", {
+ action: async () => {
+ const openedAll = await ctx.eval(`(() => {
+ const tab = [...document.querySelectorAll('[data-testid="skills-library-source"] [role="tab"]')]
+ .find((entry) => ['全部', 'All'].includes(entry.textContent?.trim() ?? ''));
+ tab?.click();
+ return Boolean(tab);
+ })()`);
+ ctx.assert(openedAll, "The All skills tab was not found.");
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="skills-installed-section"]'))`, {
+ timeoutMs: 5_000,
+ label: "installed skills section",
+ });
+ const installedHeader = await ctx.eval(`(() => {
+ const section = document.querySelector('[data-testid="skills-installed-section"]');
+ const heading = section?.querySelector('h3');
+ const description = section?.querySelector('p');
+ if (!section || !heading || !description) return null;
+ return {
+ headingFontSize: getComputedStyle(heading).fontSize,
+ headingLineHeight: getComputedStyle(heading).lineHeight,
+ headingWeight: getComputedStyle(heading).fontWeight,
+ descriptionFontSize: getComputedStyle(description).fontSize,
+ descriptionLineHeight: getComputedStyle(description).lineHeight,
+ borderBottomWidth: getComputedStyle(section).borderBottomWidth,
+ };
+ })()`);
+ ctx.assert(installedHeader, "The installed skills header was not available for comparison.");
+
+ const openedHub = await ctx.eval(`(() => {
+ const tab = [...document.querySelectorAll('[data-testid="skills-library-source"] [role="tab"]')]
+ .find((entry) => ['技能中心', 'Hub'].includes(entry.textContent?.trim() ?? ''));
+ tab?.click();
+ return Boolean(tab);
+ })()`);
+ ctx.assert(openedHub, "The Skill Hub tab was not found.");
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="skills-hub-section"]'))`, {
+ timeoutMs: 5_000,
+ label: "Skill Hub section",
+ });
+
+ const hubLayout = await ctx.eval(`(() => {
+ const section = document.querySelector('[data-testid="skills-hub-section"]');
+ const heading = section?.querySelector('h3');
+ const description = section?.querySelector('p');
+ const actions = section?.querySelector('[data-testid="skills-hub-header-actions"]');
+ const error = document.querySelector('[data-testid="skills-hub-error"]');
+ const topRefresh = [...(document.querySelector('[data-testid="skills-library-navigation-actions"]')?.querySelectorAll('button') ?? [])]
+ .find((button) => ['刷新', 'Refresh'].includes(button.textContent?.trim() ?? ''));
+ const duplicateRefresh = [...document.querySelectorAll('main button')]
+ .some((button) => ['刷新Hub', 'Refresh hub'].includes(button.textContent?.trim() ?? ''));
+ if (!section || !heading || !description || !actions) return null;
+ const sectionRect = section.getBoundingClientRect();
+ const actionsRect = actions.getBoundingClientRect();
+ return {
+ headingFontSize: getComputedStyle(heading).fontSize,
+ headingLineHeight: getComputedStyle(heading).lineHeight,
+ headingWeight: getComputedStyle(heading).fontWeight,
+ descriptionFontSize: getComputedStyle(description).fontSize,
+ descriptionLineHeight: getComputedStyle(description).lineHeight,
+ borderBottomWidth: getComputedStyle(section).borderBottomWidth,
+ actionsRightAligned: Math.abs(actionsRect.right - sectionRect.right) < 1,
+ headerSourceLabelRemoved: !section.textContent?.includes('different-ai/ipollowork-hub@main'),
+ topRefreshAvailable: Boolean(topRefresh),
+ duplicateRefreshRemoved: !duplicateRefresh,
+ rawErrorHidden: !/Failed to fetch hub catalog|Request failed with 404/.test(document.body.innerText),
+ errorStyleValid: !error || (
+ Math.round(error.getBoundingClientRect().height) >= 36
+ && getComputedStyle(error).color === 'rgb(229, 72, 77)'
+ && getComputedStyle(error).backgroundColor !== 'rgba(0, 0, 0, 0)'
+ ),
+ };
+ })()`);
+ ctx.assert(hubLayout, "The Skill Hub section layout was not available.");
+ ctx.assert(
+ hubLayout.headingFontSize === installedHeader.headingFontSize
+ && hubLayout.headingLineHeight === installedHeader.headingLineHeight
+ && hubLayout.headingWeight === installedHeader.headingWeight
+ && hubLayout.descriptionFontSize === installedHeader.descriptionFontSize
+ && hubLayout.descriptionLineHeight === installedHeader.descriptionLineHeight
+ && hubLayout.borderBottomWidth === installedHeader.borderBottomWidth,
+ "The Skill Hub should use the same section hierarchy as All skills.",
+ );
+ ctx.assert(hubLayout.actionsRightAligned, "The Hub actions should align to the section's right edge.");
+ ctx.assert(hubLayout.headerSourceLabelRemoved, "The active Hub source should not be repeated in the section header.");
+ ctx.assert(hubLayout.topRefreshAvailable, "The settings navigation should keep the shared Refresh action.");
+ ctx.assert(hubLayout.duplicateRefreshRemoved, "The Skill Hub should not duplicate the Refresh action.");
+ ctx.assert(hubLayout.rawErrorHidden, "The Skill Hub should not expose raw request failures.");
+ ctx.assert(hubLayout.errorStyleValid, "Any Skill Hub error should reuse the shared light error style.");
+ },
+ assert: async () => {
+ await ctx.expectNoText("刷新Hub");
+ await ctx.expectNoText("Request failed with 404");
+ await ctx.expectNoText("Failed to fetch hub catalog");
+ },
+ screenshot: {
+ name: "plugin-library-skill-hub-hierarchy",
+ requireText: ["从Hub获取"],
+ rejectText: ["刷新Hub", "Request failed with 404", "Something went wrong"],
+ hashIncludes: "/settings/extensions/skills",
+ },
+ });
+ },
+ },
],
};
diff --git a/evals/flows/project-managed-folder.flow.mjs b/evals/flows/project-managed-folder.flow.mjs
new file mode 100644
index 000000000..83df9c3f3
--- /dev/null
+++ b/evals/flows/project-managed-folder.flow.mjs
@@ -0,0 +1,166 @@
+export default {
+ id: "project-managed-folder",
+ title: "Create a local project without selecting a source folder",
+ kind: "user-facing",
+ steps: [
+ {
+ name: "Open the new project dialog",
+ run: async (ctx) => {
+ await ctx.prove("The source folder is a compact optional control", {
+ action: async () => {
+ await ctx.navigateHash("/");
+ await ctx.eval(`(() => {
+ const dialog = document.querySelector('[data-testid="create-project-dialog"]');
+ dialog?.querySelector('[data-slot="dialog-close"]')?.click();
+ return true;
+ })()`);
+ await ctx.waitFor(`!document.querySelector('[data-testid="create-project-dialog"]')`, {
+ timeoutMs: 5_000,
+ label: "closed stale new project dialog",
+ });
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="new-project-button"]'))`, {
+ timeoutMs: 30_000,
+ label: "new project action",
+ });
+ const opened = await ctx.eval(`(() => {
+ const button = [...document.querySelectorAll('[data-testid="new-project-button"]')]
+ .find((entry) => entry.getClientRects().length > 0);
+ if (!button) return false;
+ const propsKey = Object.keys(button).find((key) => key.startsWith('__reactProps$'));
+ const onClick = propsKey ? button[propsKey]?.onClick : null;
+ if (typeof onClick === 'function') {
+ onClick({ currentTarget: button, target: button, preventDefault() {}, stopPropagation() {} });
+ } else {
+ button.click();
+ }
+ return true;
+ })()`);
+ ctx.assert(opened, "The visible new project action was not available.");
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="create-project-dialog"]'))`, {
+ timeoutMs: 5_000,
+ label: "new project dialog",
+ });
+
+ const initialLayout = await ctx.eval(`(() => {
+ const dialog = document.querySelector('[data-testid="create-project-dialog"]');
+ const picker = dialog?.querySelector('[data-testid="project-folder-picker"]');
+ const nameInput = dialog?.querySelector('#create-project-name');
+ const nameIcon = dialog?.querySelector('[data-testid="project-name-icon"]');
+ const folderIcon = dialog?.querySelector('[data-testid="project-folder-icon"]');
+ const folderLabel = dialog?.querySelector('[data-testid="project-folder-label"]');
+ const create = [...(dialog?.querySelectorAll('button') ?? [])]
+ .find((button) => ['新建项目', 'Create project'].includes(button.textContent?.trim() ?? ''));
+ if (!dialog || !picker || !nameInput || !nameIcon || !folderIcon || !folderLabel || !create) return null;
+ const style = getComputedStyle(picker);
+ const nameInputStyle = getComputedStyle(nameInput);
+ const placeholderStyle = getComputedStyle(nameInput, '::placeholder');
+ const folderLabelStyle = getComputedStyle(folderLabel);
+ const center = (entry) => {
+ const rect = entry.getBoundingClientRect();
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+ };
+ const nameIconCenter = center(nameIcon);
+ const folderIconCenter = center(folderIcon);
+ const nameInputCenter = center(nameInput);
+ const pickerCenter = center(picker);
+ return {
+ pickerHeight: Math.round(picker.getBoundingClientRect().height),
+ solidBorder: style.borderTopStyle === 'solid',
+ createDisabledBeforeName: create.disabled,
+ compactLabel: ['选择项目文件夹', 'Choose project folder'].includes(picker.textContent?.trim() ?? ''),
+ iconColumnsAligned: Math.abs(nameIconCenter.x - folderIconCenter.x) <= 1,
+ iconsVerticallyCentered: Math.abs(nameIconCenter.y - nameInputCenter.y) < 1
+ && Math.abs(folderIconCenter.y - pickerCenter.y) < 1,
+ secondaryCopyAligned: nameInputStyle.fontSize === '13px'
+ && nameInputStyle.lineHeight === '20px'
+ && nameInputStyle.fontWeight === '400'
+ && folderLabelStyle.fontSize === '13px'
+ && folderLabelStyle.lineHeight === '20px'
+ && folderLabelStyle.fontWeight === '400'
+ && placeholderStyle.color === folderLabelStyle.color,
+ flatInput: nameInputStyle.boxShadow === 'none'
+ || !/(?:[1-9]\d*|0?\.\d+)px/.test(nameInputStyle.boxShadow),
+ secondaryCopyStyles: {
+ placeholder: {
+ fontSize: nameInputStyle.fontSize,
+ lineHeight: nameInputStyle.lineHeight,
+ fontWeight: nameInputStyle.fontWeight,
+ color: placeholderStyle.color,
+ },
+ folder: {
+ fontSize: folderLabelStyle.fontSize,
+ lineHeight: folderLabelStyle.lineHeight,
+ fontWeight: folderLabelStyle.fontWeight,
+ color: folderLabelStyle.color,
+ },
+ },
+ };
+ })()`);
+ ctx.assert(
+ initialLayout?.pickerHeight >= 38 && initialLayout.pickerHeight <= 42,
+ `The source folder control should be approximately 40px high (actual: ${initialLayout?.pickerHeight ?? 'missing'}px).`,
+ );
+ ctx.assert(initialLayout?.solidBorder, "The source folder control should not look like a drag-and-drop target.");
+ ctx.assert(initialLayout?.createDisabledBeforeName, "A project name should remain required.");
+ ctx.assert(initialLayout?.compactLabel, "The source folder control should use a concise selection label.");
+ ctx.assert(initialLayout?.iconColumnsAligned, "The name and folder icons should share one horizontal center line.");
+ ctx.assert(initialLayout?.iconsVerticallyCentered, "Each project icon should be vertically centered in its control.");
+ ctx.assert(initialLayout?.flatInput, "The project name input should not have a drop shadow.");
+ ctx.assert(
+ initialLayout?.secondaryCopyAligned,
+ `Empty project controls should share the 13px secondary-copy style (${JSON.stringify(initialLayout?.secondaryCopyStyles)}).`,
+ );
+
+ await ctx.eval(`(() => {
+ const input = document.querySelector('#create-project-name');
+ if (!input) return false;
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
+ setter?.call(input, 'Managed local project');
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ return true;
+ })()`);
+ await ctx.waitFor(`(() => {
+ const dialog = document.querySelector('[data-testid="create-project-dialog"]');
+ const create = [...(dialog?.querySelectorAll('button') ?? [])]
+ .find((button) => ['新建项目', 'Create project'].includes(button.textContent?.trim() ?? ''));
+ return Boolean(create && !create.disabled);
+ })()`, {
+ timeoutMs: 5_000,
+ label: "create enabled without a selected folder",
+ });
+ },
+ assert: async () => {
+ await ctx.expectText("源文件夹");
+ await ctx.expectText("选择项目文件夹");
+ await ctx.expectNoText("添加可读取和可编辑的文件夹");
+ },
+ screenshot: {
+ name: "project-managed-folder-optional",
+ requireText: ["新建项目", "项目名称", "源文件夹", "选择项目文件夹"],
+ rejectText: ["添加可读取和可编辑的文件夹", "Something went wrong"],
+ },
+ });
+ },
+ },
+ {
+ name: "Close without creating test data",
+ run: async (ctx) => {
+ await ctx.prove("Closing the dialog leaves the current project unchanged", {
+ action: async () => {
+ const closed = await ctx.eval(`(() => {
+ const dialog = document.querySelector('[data-testid="create-project-dialog"]');
+ const close = dialog?.querySelector('[data-slot="dialog-close"]');
+ close?.click();
+ return Boolean(close);
+ })()`);
+ ctx.assert(closed, "The new project dialog close control was not found.");
+ await ctx.waitFor(`!document.querySelector('[data-testid="create-project-dialog"]')`, {
+ timeoutMs: 5_000,
+ label: "closed new project dialog",
+ });
+ },
+ });
+ },
+ },
+ ],
+};
diff --git a/packages/types/src/desktop-ipc.ts b/packages/types/src/desktop-ipc.ts
index 5233b277a..c07f8d6a7 100644
--- a/packages/types/src/desktop-ipc.ts
+++ b/packages/types/src/desktop-ipc.ts
@@ -305,7 +305,7 @@ export type DesktopFetchResult = {
};
export type WorkspaceCreateInput = {
- folderPath: string;
+ folderPath?: string | null;
name?: string | null;
preset?: string | null;
workContextId?: `enterprise:${string}` | null;