mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(mcp): add external MCP client with daemon-managed OAuth and 17 design-focused templates
Open Design now acts as an MCP CLIENT and surfaces tools from third-party
MCP servers to the underlying agent (Claude Code, Hermes, Kimi).
Daemon
- New mcp-config / mcp-oauth / mcp-tokens modules: persist server entries
to .od/mcp-config.json, run the OAuth dance for HTTP/SSE servers
end-to-end on the daemon (so cloud deployments work and tokens
survive across turns), and inject Authorization: Bearer headers into
the per-spawn .mcp.json the daemon writes for Claude Code (or the
ACP mcpServers map for Hermes/Kimi).
- /api/mcp/servers and /api/mcp/oauth/{start,status,disconnect}
endpoints, plus spawn-time wiring in agents that hands the configured
servers to the active agent CLI.
- System-prompt directive for connected external MCPs so the model
does not chase Claude Code's synthetic *_authenticate /
*_complete_authentication tools when the Bearer is already pinned.
Web
- Settings -> External MCP servers panel with per-row OAuth Connect /
Disconnect / Refresh affordances and per-row template hints.
- New "Add server" picker categorized into 7 groups
(image-generation, image-editing, web-capture, ui-components,
data-viz, publishing, utilities) with a search box, sticky close
button, collapsible <details> sections (auto-expand on search),
60vh capped scroll region, and a pinned Custom-server footer.
- ChatComposer /mcp slash and MCP picker button forward to the new
Settings tab; AssistantMessage renders MCP tool calls inline;
markdown autolinker handles bare http(s) URLs (incl. OAuth links)
before italic markers so OAuth callback URLs do not get
italic-fragmented mid-token.
Contracts
- packages/contracts/src/api/mcp.ts owns the wire shapes
(McpServerConfig, McpTemplate with stable McpTemplateCategory
enum, McpServersResponse, OAuth start/status/disconnect bodies, the
postMessage payload from the OAuth callback).
Templates (17 built-in)
- image-generation: Higgsfield (OpenClaw, OAuth HTTP), Pollinations,
Allyson (animated SVG), AWS Bedrock Image (uvx).
- image-editing: Imagician, ImageSorcery.
- web-capture: just-every screenshot-website-fast, ScreenshotOne.
- ui-components: 21st.dev Magic, shadcn/ui, FlyonUI.
- data-viz: AntV Chart, Mermaid.
- publishing: EdgeOne Pages.
- utilities: Filesystem, GitHub, Fetch.
Tests
- apps/daemon/tests/mcp-{config,oauth,tokens,spawn}.test.ts cover
storage round-trip, OAuth helpers, token persistence, spawn-time
wiring, every template's transport / command / args / env-field
invariants, and the canonical category enum.
- apps/web/tests/runtime/markdown.test.tsx covers the new autolinker
ordering rules.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(mcp): add 21 more design-focused templates and a `design-systems` category
Expands the built-in MCP picker from 17 to 38 templates so users can compose
the full Open Design craft loop (design-system intake → generate → edit →
audit → publish) without leaving the Settings dialog. Every install spec is
verified live against the upstream README; templates that needed Go binaries,
multi-step `init` ceremonies, or massive runtime stacks (PostgreSQL + Redis
+ Ollama) are intentionally deferred so picking a template still resolves to
a working server in one click.
New `design-systems` category between `web-capture` and `ui-components`
(reflects the upstream-of-components position in the workflow). Mirrored in
`McpTemplateCategory` on both contracts and daemon, and `CATEGORY_ORDER` on
the web side.
New templates by category:
- image-generation (+4): prompt-to-asset (icons / favicons / OG / logos with
free-tier routing across Cloudflare AI / NVIDIA NIM / HF / Stable Horde),
Nano Banana (hosted streamable HTTP, virtual try-on + product placement),
Seedream (hosted streamable HTTP, ByteDance Seedream v3-v5 + SeedEdit),
fal.ai (uvx, 600+ models incl. FLUX / Kling / Hunyuan / MusicGen).
- image-editing (+3): Photopea (34 layered-editor tools — closes the PSD
gap), Topaz Labs (AI upscale / denoise / sharpen), Transloadit (86+ media
pipeline robots).
- web-capture (+1): Pagecast (browser → demo GIF / MP4 with auto-zoom).
- design-systems (+4, NEW category): Figma-Context (Framelink, designs →
code), Design Token Bridge (Tailwind ⇄ CSS ⇄ Figma ⇄ M3 / SwiftUI / W3C
DTCG + WCAG contrast), Design System Extractor (Storybook scrape),
Aesthetics Wiki (cottagecore / dark-academia / y2k / … moodboards).
- data-viz (+2): MCP Dashboards (45+ chart types + KPI dashboards),
Excalidraw Architect (hand-drawn architecture diagrams).
- publishing (+6): PageDrop, PDFSpark, OGForge, QRMint, Slideshot
(HTML → PDF / PPTX / PNG with 7 themes), Deckrun (Markdown → PDF / video,
hosted free tier with no key required).
- utilities (+1): A11y axe-core (WCAG 2.0/2.1/2.2 + color-contrast + ARIA).
Tests cover every new template's wiring (command, args, env / header
required-vs-optional, secret flag), the category enum invariant, and
in-category declaration order for image-generation, design-systems and
publishing buckets where the order is what users see in the picker. 21 new
test cases pass; full mcp-config suite is green.
Templates intentionally deferred (documented in PR body): figma-use
(needs Figma desktop with --remote-debugging-port=9222), m-moire (multi-step
`memi suite init` + daemon ceremony), gemini-media-mcp + trident-mcp (Go
binaries — no npx / uvx path), Pixelle-MCP (full app with web UI + ComfyUI
backend), storybook-addon-mcp (lives inside user's Storybook, not standalone),
primitiv (multi-step init / build / serve), ReftrixMCP (PostgreSQL + Redis +
Ollama + DINOv2), narasimhaponnada/mermaid (overlap with peng-shawn).
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(mcp): add figma-use template (write designs from chat) under design-systems
figma-use is the natural counterpart to Figma-Context already in this PR:
where Framelink reads Figma designs into the model, figma-use writes back
into the canvas (90+ tools — create frames / text / components / variants,
render JSX into Figma, export PNG/SVG, query nodes via XPath, lint for
WCAG / auto-layout / hardcoded colors, analyze design systems).
Wired as an HTTP MCP template (`http://localhost:38451/mcp`) because
`figma-use mcp serve` only exposes HTTP — there's no stdio mode in the
upstream `serve.ts`. No API key. Two prerequisites the user owns are
spelled out in the description so picking the template still resolves to
a working server: (1) start Figma with `--remote-debugging-port=9222`
(or `figma-use daemon start --pipe` on Figma 126+), and (2) leave
`npx figma-use mcp serve` running in a terminal.
Inserted between `design-system-extractor` and `aesthetics-wiki` so the
design-systems category reads as a workflow: read existing design (Figma
Context) → translate tokens (Token Bridge) → extract from Storybook
(Extractor) → write back to Figma (figma-use) → break creative block
(Aesthetics Wiki).
Tests cover the new template's transport (`http`), endpoint URL, the
empty header-fields invariant (no auth required), and bump the
design-systems group order to include it.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(settings): i18n the External MCP / MCP server / Connectors sidebar entries and make the dialog header track the active section
The External MCP sidebar entry this PR introduces was hardcoded English
("External MCP / Add MCP tools (Higgsfield, GitHub…)"). Same for the
adjacent Connectors and MCP server entries. The dialog header was also
pinned to "Execution & model" copy, so opening Settings → External MCP
showed a header that lied about which section the user was on.
Adds six translation keys — `settings.connectorsTitle/Hint`,
`settings.mcpServerTitle/Hint`, `settings.externalMcpTitle/Hint` — and
translates them across all 17 locales (ar, de, en, es-ES, fa, fr, hu, id,
ja, ko, pl, pt-BR, ru, tr, uk, zh-CN, zh-TW).
`SettingsDialog` now derives the header title/subtitle from the active
section (11 sections total) instead of a single hardcoded pair, so each
section renders an honest header.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(e2e): pin level: 3 on dialog heading lookups for Pets and Connectors
CI's Validate workspace job (#1479) failed two Playwright cases with the
strict-mode violation:
getByRole('dialog').getByRole('heading', { name: 'Pets' })
resolved to 2 elements:
1) <h2>Pets</h2>
2) <h3>Pets</h3>
Same root cause as the unit-test fix already in this PR: the dynamic
dialog `<h2>` now echoes the section's own `<h3>` because the dialog
header tracks the active section. Disambiguate to `level: 3` so each
assertion still pins the section heading specifically (which is what
the test intends to verify).
Audit of the rest of e2e/ for `dialog.getByRole('heading', ...)` —
settings-api-protocol.test.ts looks for "OpenAI API" / "Anthropic API"
section h3s which never appear in the dialog `<h2>` (always
"Execution & model"), so those stay safe.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(mcp): bind OAuth refresh to the issuing client and skip stale tokens
Persist the OAuth client context (token endpoint, client_id, client_secret,
issuer, redirect_uri, resource) alongside the bearer token so refresh hits
the same client the refresh_token was bound to (RFC 6749 §6). The previous
refresh path re-ran beginAuth with a dummy OOB redirect URI, which kept
getOrRegisterClient from finding the original DCR client and made
providers reject the refresh on the next chat turn. Refreshes now reuse
the persisted endpoint/client pair directly.
Also stop injecting expired access tokens at spawn time when refresh is
unavailable or fails. Pinning a stale Bearer made every Claude MCP call
401 while the prompt still treated the server as connected; on that path
we now skip the entry and let the UI surface a reconnect.
Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
531 lines
20 KiB
TypeScript
531 lines
20 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { Locator, Page } from '@playwright/test';
|
|
|
|
const STORAGE_KEY = 'open-design:config';
|
|
|
|
const DESIGN_SYSTEMS = [
|
|
{
|
|
id: 'nexu-soft-tech',
|
|
title: 'Nexu Soft Tech',
|
|
category: 'Product',
|
|
summary: 'Warm utility system for product interfaces.',
|
|
swatches: ['#F7F4EE', '#D6CBBF', '#1F2937', '#D97757'],
|
|
},
|
|
{
|
|
id: 'editorial-noir',
|
|
title: 'Editorial Noir',
|
|
category: 'Editorial',
|
|
summary: 'High-contrast editorial system with expressive type.',
|
|
swatches: ['#111111', '#F6EFE6', '#C44536', '#F2C14E'],
|
|
},
|
|
{
|
|
id: 'data-mist',
|
|
title: 'Data Mist',
|
|
category: 'Analytics',
|
|
summary: 'Calm dashboard system for dense data products.',
|
|
swatches: ['#EAF4F4', '#5EAAA8', '#05668D', '#0B132B'],
|
|
},
|
|
];
|
|
|
|
const TAB_SKILLS = [
|
|
skillSummary('prototype-skill', 'Prototype Skill', 'prototype', 'web', ['prototype']),
|
|
skillSummary('live-artifact', 'live-artifact', 'prototype', 'web', []),
|
|
skillSummary('deck-skill', 'Deck Skill', 'deck', 'web', ['deck']),
|
|
skillSummary('image-skill', 'Image Skill', 'image', 'image', ['image']),
|
|
];
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.addInitScript((key) => {
|
|
window.localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
mode: 'daemon',
|
|
apiKey: '',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
model: 'claude-sonnet-4-5',
|
|
agentId: 'mock',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
onboardingCompleted: true,
|
|
agentModels: {},
|
|
}),
|
|
);
|
|
}, STORAGE_KEY);
|
|
|
|
await page.route('**/api/app-config', async (route) => {
|
|
await route.fulfill({
|
|
json: {
|
|
config: {
|
|
onboardingCompleted: true,
|
|
agentId: 'mock',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
agentModels: {},
|
|
agentCliEnv: {},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/agents', async (route) => {
|
|
await route.fulfill({
|
|
json: {
|
|
agents: [
|
|
{
|
|
id: 'mock',
|
|
name: 'Mock Agent',
|
|
bin: 'mock-agent',
|
|
available: true,
|
|
version: 'test',
|
|
models: [{ id: 'default', label: 'Default' }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
test('new project tabs switch visible form sections and preserve drafts', async ({ page }) => {
|
|
await page.route('**/api/skills', async (route) => {
|
|
await route.fulfill({ json: { skills: TAB_SKILLS } });
|
|
});
|
|
await page.route('**/api/connectors', async (route) => {
|
|
await route.fulfill({ json: { connectors: [] } });
|
|
});
|
|
await page.route('**/api/connectors/status', async (route) => {
|
|
await route.fulfill({ json: { statuses: {} } });
|
|
});
|
|
|
|
await page.goto('/');
|
|
await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true');
|
|
await expect(page.locator('.newproj-title')).toContainText('New prototype');
|
|
await expect(page.getByTestId('design-system-trigger')).toBeVisible();
|
|
await expect(page.getByText('Fidelity', { exact: true })).toBeVisible();
|
|
await page.getByTestId('new-project-name').fill('Prototype draft survives');
|
|
|
|
await page.getByTestId('new-project-tab-live-artifact').click();
|
|
await expect(page.getByTestId('new-project-tab-live-artifact')).toHaveAttribute('aria-selected', 'true');
|
|
await expect(page.locator('.newproj-title')).toContainText('New live artifact');
|
|
await expect(page.locator('.newproj-title')).toContainText('Beta');
|
|
await expect(page.getByTestId('design-system-picker')).toHaveCount(0);
|
|
await expect(page.getByTestId('new-project-connectors')).toBeVisible();
|
|
await expect(page.getByTestId('create-project')).toContainText('Create live artifact');
|
|
|
|
await page.getByTestId('new-project-tab-deck').click();
|
|
await expect(page.getByTestId('new-project-tab-deck')).toHaveAttribute('aria-selected', 'true');
|
|
await expect(page.locator('.newproj-title')).toContainText('New slide deck');
|
|
await expect(page.getByTestId('design-system-trigger')).toBeVisible();
|
|
await expect(page.getByText('Use speaker notes')).toBeVisible();
|
|
await expect(page.getByTestId('new-project-connectors')).toHaveCount(0);
|
|
|
|
await page.getByTestId('new-project-tab-prototype').click();
|
|
await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true');
|
|
await expect(page.locator('.newproj-title')).toContainText('New prototype');
|
|
await expect(page.getByTestId('new-project-name')).toHaveValue('Prototype draft survives');
|
|
|
|
await page.getByRole('button', { name: 'Scroll project types right' }).click();
|
|
await page.getByTestId('new-project-tab-image').click();
|
|
await expect(page.getByTestId('new-project-tab-image')).toHaveAttribute('aria-selected', 'true');
|
|
await expect(page.locator('.newproj-title')).toContainText('New image');
|
|
await expect(page.getByTestId('design-system-picker')).toHaveCount(0);
|
|
await expect(page.getByText('Model', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('Aspect', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('design system multi-select stores primary and inspiration metadata', async ({ page }) => {
|
|
await page.route('**/api/design-systems', async (route) => {
|
|
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
|
|
});
|
|
|
|
await page.goto('/');
|
|
await page.getByTestId('new-project-tab-prototype').click();
|
|
await page.getByTestId('new-project-name').fill('Design system multi select metadata');
|
|
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
|
|
|
|
await page.getByTestId('design-system-trigger').click();
|
|
const multiTab = page.getByRole('tab', { name: /multi/i });
|
|
await multiTab.click();
|
|
await expect(multiTab).toHaveAttribute('aria-selected', 'true');
|
|
await page.getByRole('option', { name: /Editorial Noir/i }).click();
|
|
await page.getByRole('option', { name: /Data Mist/i }).click();
|
|
|
|
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
|
|
await expect(page.getByTestId('design-system-trigger')).toContainText('+2');
|
|
await page.keyboard.press('Escape');
|
|
await page.getByTestId('create-project').click();
|
|
await expectWorkspaceReady(page);
|
|
|
|
const project = await fetchCurrentProject(page);
|
|
expect(project.designSystemId).toBe('nexu-soft-tech');
|
|
expect(project.metadata?.inspirationDesignSystemIds).toEqual([
|
|
'editorial-noir',
|
|
'data-mist',
|
|
]);
|
|
});
|
|
|
|
test('design system picker searches and switches the single selected system', async ({ page }) => {
|
|
await page.route('**/api/design-systems', async (route) => {
|
|
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
|
|
});
|
|
|
|
await page.goto('/');
|
|
await page.getByTestId('new-project-tab-prototype').click();
|
|
await page.getByTestId('new-project-name').fill('Design system single switch flow');
|
|
await expect(page.getByTestId('design-system-trigger')).toBeVisible();
|
|
|
|
await page.getByTestId('design-system-trigger').click();
|
|
await page.getByTestId('design-system-search').fill('mist');
|
|
await expect(page.getByRole('option', { name: /Data Mist/i })).toBeVisible();
|
|
await expect(page.getByRole('option', { name: /Nexu Soft Tech/i })).toHaveCount(0);
|
|
await page.getByRole('option', { name: /Data Mist/i }).click();
|
|
|
|
await expect(page.getByTestId('design-system-trigger')).toContainText('Data Mist');
|
|
await expect(page.getByTestId('design-system-trigger')).toContainText('Analytics');
|
|
await page.getByTestId('create-project').click();
|
|
await expectWorkspaceReady(page);
|
|
|
|
const project = await fetchCurrentProject(page);
|
|
expect(project.designSystemId).toBe('data-mist');
|
|
expect(project.metadata?.inspirationDesignSystemIds).toBeUndefined();
|
|
});
|
|
|
|
test('project title rename persists after reload and ignores blank titles', async ({ page }) => {
|
|
await page.goto('/');
|
|
await createProject(page, 'Original rename title');
|
|
await expectWorkspaceReady(page);
|
|
|
|
const title = page.getByTestId('project-title');
|
|
await renameProjectTitle(page, title, 'Renamed persistent title');
|
|
await expect(title).toContainText('Renamed persistent title');
|
|
|
|
await page.reload();
|
|
await expectWorkspaceReady(page);
|
|
await expect(page.getByTestId('project-title')).toContainText('Renamed persistent title');
|
|
|
|
await renameProjectTitle(page, page.getByTestId('project-title'), ' ');
|
|
await page.reload();
|
|
await expectWorkspaceReady(page);
|
|
await expect(page.getByTestId('project-title')).toContainText('Renamed persistent title');
|
|
|
|
const project = await fetchCurrentProject(page);
|
|
expect(project.name).toBe('Renamed persistent title');
|
|
});
|
|
|
|
test('canceling design file deletion keeps the file and open tab', async ({ page }) => {
|
|
await page.goto('/');
|
|
await createProject(page, 'Design file delete cancel flow');
|
|
await expectWorkspaceReady(page);
|
|
|
|
const uploadedName = await uploadTinyPng(page, 'delete-cancel.png');
|
|
const fileTab = tabBySuffix(page, uploadedName);
|
|
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
|
|
|
|
page.once('dialog', async (dialog) => {
|
|
expect(dialog.message()).toContain('delete-cancel.png');
|
|
await dialog.dismiss();
|
|
});
|
|
await page.getByTestId('design-files-tab').click();
|
|
await rowByFileName(page, uploadedName).hover();
|
|
await menuByFileName(page, uploadedName).click();
|
|
await page.getByTestId(`design-file-delete-${uploadedName}`).click();
|
|
|
|
await expect(rowByFileName(page, uploadedName)).toBeVisible();
|
|
await expect(fileTab).toBeVisible();
|
|
|
|
const { projectId } = getProjectContextFromUrl(page);
|
|
const files = await listProjectFiles(page, projectId);
|
|
expect(files.map((file) => file.name)).toContain(uploadedName);
|
|
});
|
|
|
|
test('home design card deletion supports cancel and confirm flows', async ({ page }) => {
|
|
const projectName = `Home delete design flow ${Date.now()}`;
|
|
await page.goto('/');
|
|
await createProject(page, projectName);
|
|
await expectWorkspaceReady(page);
|
|
|
|
const { projectId } = getProjectContextFromUrl(page);
|
|
await page.getByRole('button', { name: /back to projects/i }).click();
|
|
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
|
|
|
const designCard = homeDesignCard(page, projectName);
|
|
await expect(designCard).toBeVisible();
|
|
|
|
page.once('dialog', async (dialog) => {
|
|
expect(dialog.message()).toContain(projectName);
|
|
await dialog.dismiss();
|
|
});
|
|
await designCard.hover();
|
|
await designCard.getByRole('button', { name: new RegExp(`delete project ${escapeRegExp(projectName)}`, 'i') }).click();
|
|
await expect(designCard).toBeVisible();
|
|
|
|
page.once('dialog', async (dialog) => {
|
|
expect(dialog.message()).toContain(projectName);
|
|
await dialog.accept();
|
|
});
|
|
await designCard.hover();
|
|
await designCard.getByRole('button', { name: new RegExp(`delete project ${escapeRegExp(projectName)}`, 'i') }).click();
|
|
await expect(homeDesignCard(page, projectName)).toHaveCount(0);
|
|
|
|
const response = await page.request.get(`/api/projects/${projectId}`);
|
|
expect(response.status()).toBe(404);
|
|
});
|
|
|
|
test('home designs view toggle switches between grid and kanban and persists', async ({ page }) => {
|
|
const projectName = `Home view toggle flow ${Date.now()}`;
|
|
await page.goto('/');
|
|
await createProject(page, projectName);
|
|
await expectWorkspaceReady(page);
|
|
|
|
await page.getByRole('button', { name: /back to projects/i }).click();
|
|
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
|
await expect(homeDesignCard(page, projectName)).toBeVisible();
|
|
await expect(page.locator('.design-grid')).toBeVisible();
|
|
await expect(page.locator('.design-kanban-board')).toHaveCount(0);
|
|
await expect(page.getByTestId('designs-view-grid')).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
await page.getByTestId('designs-view-kanban').click();
|
|
await expect(page.locator('.design-kanban-board')).toBeVisible();
|
|
await expect(page.locator('.design-grid')).toHaveCount(0);
|
|
await expect(page.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(page.locator('.design-kanban-card', { hasText: projectName })).toBeVisible();
|
|
|
|
await page.reload();
|
|
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
|
await expect(page.locator('.design-kanban-board')).toBeVisible();
|
|
await expect(page.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
await page.getByTestId('designs-view-grid').click();
|
|
await expect(page.locator('.design-grid')).toBeVisible();
|
|
await expect(homeDesignCard(page, projectName)).toBeVisible();
|
|
await expect(page.getByTestId('designs-view-grid')).toHaveAttribute('aria-pressed', 'true');
|
|
});
|
|
|
|
test('home designs search filters projects and recovers from no results', async ({ page }) => {
|
|
const stamp = Date.now();
|
|
const alphaName = `Home search alpha ${stamp}`;
|
|
const betaName = `Home search beta ${stamp}`;
|
|
await page.goto('/');
|
|
|
|
await createProject(page, alphaName);
|
|
await expectWorkspaceReady(page);
|
|
await page.getByRole('button', { name: /back to projects/i }).click();
|
|
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
|
|
|
await createProject(page, betaName);
|
|
await expectWorkspaceReady(page);
|
|
await page.getByRole('button', { name: /back to projects/i }).click();
|
|
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
|
await expect(homeDesignCard(page, alphaName)).toBeVisible();
|
|
await expect(homeDesignCard(page, betaName)).toBeVisible();
|
|
|
|
const search = page.locator('.tab-panel-toolbar .toolbar-search input');
|
|
await search.fill('alpha');
|
|
await expect(homeDesignCard(page, alphaName)).toBeVisible();
|
|
await expect(homeDesignCard(page, betaName)).toHaveCount(0);
|
|
|
|
await search.fill(`missing-${stamp}`);
|
|
await expect(homeDesignCard(page, alphaName)).toHaveCount(0);
|
|
await expect(homeDesignCard(page, betaName)).toHaveCount(0);
|
|
await expect(page.locator('.tab-empty')).toBeVisible();
|
|
|
|
await search.fill('');
|
|
await expect(homeDesignCard(page, alphaName)).toBeVisible();
|
|
await expect(homeDesignCard(page, betaName)).toBeVisible();
|
|
});
|
|
|
|
test('change pet opens pet settings and updates the custom companion draft', async ({ page }) => {
|
|
await seedAdoptedPet(page);
|
|
await page.route('**/api/codex-pets', async (route) => {
|
|
await route.fulfill({ json: { pets: [], rootDir: '' } });
|
|
});
|
|
|
|
await page.goto('/');
|
|
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
|
|
|
await page
|
|
.locator('.entry-side-foot')
|
|
.getByRole('button', { name: /change pet/i })
|
|
.click();
|
|
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog.getByRole('heading', { level: 3, name: 'Pets' })).toBeVisible();
|
|
|
|
await dialog.getByRole('tab', { name: 'Custom' }).click();
|
|
const customPanel = dialog.locator('.pet-custom');
|
|
await expect(customPanel).toBeVisible();
|
|
|
|
await customPanel.getByLabel('Name').fill('QA Turtle');
|
|
await customPanel.getByLabel('Glyph').fill('🐢');
|
|
await customPanel.getByLabel('Greeting').fill('Shell yeah, tests are green.');
|
|
await expect(customPanel.getByText('QA Turtle')).toBeVisible();
|
|
await expect(customPanel.getByText('Shell yeah, tests are green.')).toBeVisible();
|
|
|
|
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
|
await expect(dialog).toHaveCount(0);
|
|
});
|
|
|
|
async function createProject(
|
|
page: Page,
|
|
projectName: string,
|
|
) {
|
|
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
|
await page.getByTestId('new-project-tab-prototype').click();
|
|
await page.getByTestId('new-project-name').fill(projectName);
|
|
await page.getByTestId('create-project').click();
|
|
}
|
|
|
|
async function expectWorkspaceReady(page: Page) {
|
|
await expect(page).toHaveURL(/\/projects\//);
|
|
await expect(page.getByTestId('chat-composer')).toBeVisible();
|
|
await expect(page.getByTestId('file-workspace')).toBeVisible();
|
|
await expect(page.getByText('Start a conversation')).toBeVisible();
|
|
}
|
|
|
|
async function renameProjectTitle(
|
|
page: Page,
|
|
title: Locator,
|
|
nextName: string,
|
|
) {
|
|
await title.click();
|
|
await page.keyboard.press('Meta+A');
|
|
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? '');
|
|
if (selected.length === 0) {
|
|
await page.keyboard.press('Control+A');
|
|
}
|
|
await page.keyboard.type(nextName);
|
|
await page.keyboard.press('Enter');
|
|
}
|
|
|
|
async function uploadTinyPng(
|
|
page: Page,
|
|
name: string,
|
|
): Promise<string> {
|
|
const pngBytes = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
|
|
'base64',
|
|
);
|
|
await page.getByTestId('design-files-upload-input').setInputFiles({
|
|
name,
|
|
mimeType: 'image/png',
|
|
buffer: pngBytes,
|
|
});
|
|
await expect(tabBySuffix(page, name)).toBeVisible();
|
|
const { projectId } = getProjectContextFromUrl(page);
|
|
const files = await listProjectFiles(page, projectId);
|
|
const uploaded = files.find((file) => file.name.endsWith(name));
|
|
expect(uploaded?.name).toBeTruthy();
|
|
return uploaded!.name;
|
|
}
|
|
|
|
function tabBySuffix(page: Page, name: string): Locator {
|
|
return page.getByRole('tab', { name: new RegExp(`${escapeRegExp(name)}$`, 'i') });
|
|
}
|
|
|
|
function rowByFileName(page: Page, name: string): Locator {
|
|
return page.getByTestId(`design-file-row-${name}`);
|
|
}
|
|
|
|
function menuByFileName(page: Page, name: string): Locator {
|
|
return page.getByTestId(`design-file-menu-${name}`);
|
|
}
|
|
|
|
function homeDesignCard(page: Page, name: string): Locator {
|
|
return page.locator('.design-card', {
|
|
has: page.locator('.design-card-name', { hasText: name }),
|
|
});
|
|
}
|
|
|
|
async function seedAdoptedPet(page: Page) {
|
|
await page.addInitScript((key) => {
|
|
window.localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
mode: 'daemon',
|
|
apiKey: '',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
model: 'claude-sonnet-4-5',
|
|
agentId: 'mock',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
onboardingCompleted: true,
|
|
agentModels: {},
|
|
pet: {
|
|
adopted: true,
|
|
enabled: true,
|
|
petId: 'custom',
|
|
custom: {
|
|
name: 'Original Buddy',
|
|
glyph: '🦄',
|
|
accent: '#c96442',
|
|
greeting: 'Ready to pair.',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}, STORAGE_KEY);
|
|
}
|
|
|
|
async function fetchCurrentProject(page: Page) {
|
|
const { projectId } = getProjectContextFromUrl(page);
|
|
const response = await page.request.get(`/api/projects/${projectId}`);
|
|
expect(response.ok()).toBeTruthy();
|
|
const body = (await response.json()) as {
|
|
project: {
|
|
name: string;
|
|
designSystemId: string | null;
|
|
metadata?: {
|
|
inspirationDesignSystemIds?: string[];
|
|
};
|
|
};
|
|
};
|
|
return body.project;
|
|
}
|
|
|
|
async function listProjectFiles(page: Page, projectId: string) {
|
|
const response = await page.request.get(`/api/projects/${projectId}/files`);
|
|
expect(response.ok()).toBeTruthy();
|
|
const body = (await response.json()) as { files: Array<{ name: string }> };
|
|
return body.files;
|
|
}
|
|
|
|
function getProjectContextFromUrl(page: Page) {
|
|
const url = new URL(page.url());
|
|
const [, projectId] = url.pathname.match(/\/projects\/([^/]+)/) ?? [];
|
|
if (!projectId) throw new Error(`unexpected project route: ${url.pathname}`);
|
|
return { projectId };
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function skillSummary(
|
|
id: string,
|
|
name: string,
|
|
mode: 'prototype' | 'deck' | 'image',
|
|
surface: 'web' | 'image',
|
|
defaultFor: string[],
|
|
) {
|
|
return {
|
|
id,
|
|
name,
|
|
description: `${name} for tab switching coverage.`,
|
|
triggers: [],
|
|
mode,
|
|
surface,
|
|
platform: 'desktop',
|
|
scenario: 'qa',
|
|
previewType: 'html',
|
|
designSystemRequired: mode !== 'image',
|
|
defaultFor,
|
|
upstream: null,
|
|
featured: null,
|
|
fidelity: null,
|
|
speakerNotes: null,
|
|
animations: null,
|
|
hasBody: true,
|
|
examplePrompt: '',
|
|
};
|
|
}
|