open-design/e2e/ui/entry-chrome-flows.test.ts
lefarcen 9a4816b101
feat(plugins): site-wide plugin detail pages, share-to-site links, landing deploy trigger (#2999)
* feat(plugins): site-wide plugin detail pages, share-to-site links, landing deploy trigger

Why: a merged plugin PR didn't redeploy the landing site (plugins/** was missing from the deploy paths), and the desktop Share menu copied a local/404 link instead of the public marketplace URL. The landing plugin routing left by the detail-page rework also 404'd: the locale listing's cards used a multi-segment href while detail pages were single-segment, and only 388 bundled _official plugins had pages.

What changed:
- Deploy: landing-page deploy/ci trigger on plugins/**, and skip the slow previews step on an exact cache hit (cache key aligned across both workflows so a PR-built cache is reused by main).
- Share URL: packages/contracts/plugin-url.ts owns the single-segment plugin URL scheme; the web Share menu and the landing site both derive links from it. Web links now point at https://open-design.ai/plugins/<slug>/.
- Full detail coverage: detail pages now cover all 403 local plugins (_official incl. atoms + community), each rendered from its local manifest. Fixes the locale-listing 404s and the community manifest-name/catalog-id (- vs /) mismatch.
- Self-host: daemon exposes OD_SITE_ORIGIN via /api/app-config; web falls back to the canonical origin until the daemon answers.

Validation: pnpm guard, pnpm typecheck (all packages), contracts + web tests green, and a full build E2E confirming all 403 catalog ids and locale-listing cards resolve to built detail pages (0 missing).

* chore: retrigger CI

* ci(landing): carry plugins/** trigger + previews cache-hit into #2994 split workflows

Merged origin/main, which split landing deploy into staging + manual production (#2994). git auto-migrated my landing-page-deploy.yml changes into landing-page-staging.yml via rename detection (plugins/** path, fallback-preview-card.ts cache key, cache-hit skip all carried). The new manual landing-page-production.yml didn't have them, so add the previews cache-key alignment + cache-hit skip there too (plugins/** path is N/A — production is workflow_dispatch only).

* fix(ci): wrangler-action uses pnpm so it tolerates landing's workspace dep

This PR added @open-design/contracts (workspace:*) to apps/landing-page/package.json so the landing site can share the plugin-url slug rules. But the landing deploy/preview steps run cloudflare/wrangler-action with packageManager: npm in workingDirectory apps/landing-page, and 'npm i wrangler' chokes on the workspace: protocol (EUNSUPPORTEDPROTOCOL), failing 'Validate landing page'. Switch all three landing wrangler-action steps (staging / ci preview / production) to packageManager: pnpm, which is workspace-aware.

* test(e2e): bundled plugins now offer the README badge

After this branch, buildPluginShareUrl returns a public open-design.ai link for bundled plugins (not just official-marketplace ones), so the home-starter share menu now shows 'Copy README badge'. Update the assertion from toHaveCount(0) to toBeVisible().

* fix(landing): drop @open-design/contracts dep, use a landing-local slug helper

Per review on #2999: the marketing site must not import @open-design/contracts (AGENTS.md boundary — it's the web/daemon product-runtime contract layer). Move the slug/path helpers into landing-local app/_lib/plugin-slug.ts; the web client keeps contracts' plugin-url. The two derive the same scheme and are verified in lockstep by the e2e route check (403 share URLs -> 403 detail pages, 0 missing). landing no longer has a workspace dep, so revert the wrangler-action packageManager back to npm.

* fix(landing): include plugins/_official in previews cache key

Per review on #2999: generate-previews.ts builds bundled-plugin preview jobs from plugins/_official/**/open-design.json and renders fallback cards from manifest fields (title/description/mode/scenario/tags). With plugins/** now triggering the workflow but the cache key not hashing plugin inputs, a plugin-only PR/merge could exact-hit an old cache and skip the preview regen, shipping with a stale or missing /previews/plugins/<manifest-id>.png. Add plugins/_official/** to the cache key in all three landing workflows (ci, staging, production). community is not currently covered by generate-previews so its glob is omitted.

* fix(plugins): include community marketplace installs in share gate

hasPublicPage now covers sourceMarketplaceId === 'community' so the
README badge and public detail link surface for community installs.
Community manifest names carry a community- prefix that diverges from
the landing-page route slug, so URL derivation uses sourceMarketplaceEntryName
(community/<folder>) instead — pluginDetailSlug takes the last segment,
matching the /plugins/<folder>/ route the landing page emits.
Adds component tests for buildPluginShareUrl, badge copy, and the
Open-in-marketplace link for a community/registry-starter record.

Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-28 09:07:12 +00:00

1091 lines
42 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Page, Request } from '@playwright/test';
import { applyStandardMocks, STORAGE_KEY } from '@/playwright/mock-factory';
const LOCAL_CLI_LABEL = /Local CLI|本机 CLI|本地 CLI/i;
const STARTER_PLUGIN = makeStarterPlugin({
id: 'localized-plugin',
title: 'Localized Plugin',
mode: 'prototype',
featured: true,
query: 'Make a {{topic}} brief.',
inputs: [{ name: 'topic', type: 'string', default: 'design systems' }],
});
const STARTER_PLUGINS = [
STARTER_PLUGIN,
makeStarterPlugin({
id: 'deck-writer',
title: 'Deck Writer',
mode: 'deck',
query: 'Draft a {{topic}} deck.',
inputs: [{ name: 'topic', type: 'string', default: 'quarterly review' }],
}),
makeStarterPlugin({
id: 'hyperframes-video',
title: 'Hyperframes Video',
mode: 'video',
featured: true,
tags: ['hyperframes'],
query: 'Create a {{topic}} video.',
inputs: [{ name: 'topic', type: 'string', default: 'product teaser' }],
}),
makeStarterPlugin({
id: 'figma-importer',
title: 'Figma Importer',
taskKind: 'figma-migration',
description: 'Import a Figma file into a project.',
}),
] as const;
const DESIGN_SYSTEMS = [
{
id: 'agentic',
title: 'Agentic',
category: 'Productivity & SaaS',
summary: 'Conversational AI-first interface with minimal controls.',
surface: 'web',
swatches: ['#ff5a1f', '#111827'],
},
{
id: 'airbnb',
title: 'Airbnb',
category: 'E-Commerce & Retail',
summary: 'Travel marketplace with warm coral accents.',
surface: 'web',
swatches: ['#a3165b', '#ff385c'],
},
{
id: 'motion-poster',
title: 'Motion Poster',
category: 'Design & Creative',
summary: 'Motion-first visual system for video concepts.',
surface: 'video',
swatches: ['#111827', '#38bdf8'],
},
] as const;
test.beforeEach(async ({ page }) => {
await applyStandardMocks(page);
});
test('entry chrome exposes the primary home creation surface and settings entry', async ({ page }) => {
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.continue();
});
await gotoEntryHome(page);
await expect(page.getByTestId('entry-star-badge')).toBeVisible();
await expect(page.getByTestId('entry-use-everywhere-button')).toBeVisible();
await expect(page.getByTestId('entry-nav-logo')).toBeVisible();
await expect(page.getByTestId('recent-projects-strip')).toHaveCount(0);
await expect(page.locator('.entry-nav-rail')).toBeVisible();
await expect(page.locator('.entry-brand')).toHaveCount(0);
await expect(page.getByTestId('home-hero-input')).toBeVisible();
await expect(page.getByTestId('home-hero-attach')).toBeVisible();
await expect(page.getByTestId('home-hero-submit')).toBeDisabled();
const createTabs = page.getByTestId('home-hero-type-tabs');
await expect(createTabs).toBeVisible();
await expect(page.getByTestId('home-hero-rail-prototype')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-live-artifact')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-deck')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-image')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-video')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-hyperframes')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-audio')).toBeVisible();
// The pet picker rail was removed; pet adoption now lives in
// Settings → Pet exclusively. Make sure no rail leaks back into the
// entry layout.
await expect(page.locator('.pet-rail')).toHaveCount(0);
await page.getByRole('button', { name: 'Open settings' }).click();
const settingsDialog = page.getByRole('dialog');
await expect(settingsDialog).toBeVisible();
await expect(settingsDialog.getByRole('heading', { name: 'Execution mode' })).toBeVisible();
await expect(settingsDialog.getByRole('button', { name: /hide pet picker/i })).toHaveCount(0);
await expect(settingsDialog.getByRole('button', { name: /show pet picker/i })).toHaveCount(0);
});
test('entry top navigation matches the current home tab structure', async ({ page }) => {
await gotoEntryHome(page);
await expect(page.getByTestId('entry-nav-logo')).toBeVisible();
await expect(page.getByTestId('entry-nav-home')).toHaveAttribute('aria-current', 'page');
await expect(page.getByTestId('entry-nav-new-project')).toBeVisible();
await expect(page.getByTestId('entry-nav-projects')).toBeVisible();
await expect(page.getByTestId('entry-nav-tasks')).toBeVisible();
await expect(page.getByTestId('entry-nav-design-systems')).toBeVisible();
await expect(page.locator('.entry-nav-rail__group').getByTestId('entry-nav-plugins')).toBeVisible();
await expect(page.locator('.entry-nav-rail__group').getByTestId('entry-nav-integrations')).toBeVisible();
await expect(page.locator('.entry-nav-rail__footer').getByTestId('entry-nav-plugins')).toHaveCount(0);
await expect(page.locator('.entry-nav-rail__footer').getByTestId('entry-nav-integrations')).toHaveCount(0);
await expect(page.getByTestId('home-hero-type-tabs')).toBeVisible();
await expect(page.getByTestId('home-hero-active-type-chip')).toHaveCount(0);
await expect(page.getByTestId('home-hero-rail-prototype')).toHaveAttribute('aria-selected', 'false');
await expect(page.getByTestId('home-hero-footer-options')).toHaveCount(0);
await expect(page.getByTestId('home-hero-plugin-presets')).toHaveCount(0);
await expect(page.getByTestId('plugins-home-pill-category-all')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByTestId('plugins-home-pill-category-prototype')).toHaveAttribute('aria-selected', 'false');
await expect(page.getByTestId('plugins-home-row-subcategory-prototype')).toHaveCount(0);
});
test('home view exposes the redesigned hero, recent projects, and starters', async ({ page }) => {
await createProject(page, 'Home structure recent project');
await gotoEntryHome(page);
await expect(page.getByTestId('recent-projects-strip')).toBeVisible();
await expect(page.getByTestId('recent-projects-view-all')).toBeVisible();
await expect(page.getByTestId('plugins-home-section')).toBeVisible();
await expect(page.getByTestId('plugins-home-browse-registry')).toBeVisible();
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('entry-nav-home')).toHaveAttribute('aria-current', 'page');
await page.getByTestId('entry-nav-projects').click();
await expect(page).toHaveURL(/\/projects$/);
await expect(page.getByTestId('entry-nav-projects')).toHaveAttribute('aria-current', 'page');
});
test('design systems page is reachable from entry nav and supports search, preview, and default selection', async ({ page }) => {
const persistedConfigs: Array<{ designSystemId?: string | null }> = [];
await routeDesignSystems(page);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON() as { designSystemId?: string | null };
persistedConfigs.push(body);
await route.fulfill({ json: { ok: true } });
return;
}
if (route.request().method() === 'GET') {
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: 'agentic',
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
return;
}
await route.continue();
});
await gotoEntryHome(page);
await page.getByTestId('entry-nav-design-systems').click();
await expect(page).toHaveURL(/\/design-systems$/);
await expect(page.getByTestId('entry-nav-design-systems')).toHaveAttribute('aria-current', 'page');
await expect(page.getByRole('heading', { name: 'Design systems' })).toBeVisible();
await expect(page.getByTestId('design-systems-tab')).toBeVisible();
await page.getByRole('tab', { name: 'Official presets' }).click();
await expect(page.getByTestId('design-system-card-agentic')).toBeVisible();
await expect(page.getByTestId('design-system-card-agentic')).toContainText(/default/i);
await expect(page.getByTestId('design-system-card-airbnb')).toBeVisible();
await page.getByTestId('design-systems-search').fill('air');
await expect(page.getByTestId('design-system-card-airbnb')).toBeVisible();
await expect(page.getByTestId('design-system-card-agentic')).toHaveCount(0);
await page.getByTestId('design-systems-search').fill('no matching system');
await expect(page.getByTestId('design-systems-empty')).toBeVisible();
await page.getByTestId('design-systems-search').fill('');
await page.getByTestId('design-systems-surface-video').click();
await expect(page.getByTestId('design-system-card-motion-poster')).toBeVisible();
await expect(page.getByTestId('design-system-card-agentic')).toHaveCount(0);
await page.getByTestId('design-systems-surface-all').click();
await page.getByTestId('design-system-preview-airbnb').click();
const preview = page.getByRole('dialog', { name: /Airbnb preview/i });
await expect(preview).toBeVisible();
await expect(preview.getByRole('tab', { name: /showcase/i })).toHaveAttribute('aria-selected', 'true');
await expect(preview.getByRole('tab', { name: /tokens/i })).toBeVisible();
await expect(preview.getByRole('button', { name: 'DESIGN.md', exact: true })).toBeVisible();
await page.keyboard.press('Escape');
await expect(preview).toHaveCount(0);
await page.getByTestId('design-system-select-airbnb').click();
await expect(page.getByTestId('design-system-card-airbnb')).toContainText(/default/i);
await expect
.poll(() => persistedConfigs.at(-1)?.designSystemId)
.toBe('airbnb');
});
test('entry chrome avoids horizontal overflow on compact desktop width', async ({ page }) => {
await page.setViewportSize({ width: 820, height: 900 });
await gotoEntryHome(page);
await expect(page.locator('.entry-main__topbar')).toBeVisible();
const { pageOverflow, topbarOverflow } = await page.evaluate(() => {
const topbar = document.querySelector('.entry-main__topbar');
return {
pageOverflow: Math.max(
0,
document.documentElement.scrollWidth - document.documentElement.clientWidth,
),
topbarOverflow:
topbar instanceof HTMLElement
? Math.max(0, topbar.scrollWidth - topbar.clientWidth)
: null,
};
});
expect(topbarOverflow).not.toBeNull();
expect(topbarOverflow!).toBeLessThanOrEqual(2);
expect(pageOverflow).toBeLessThanOrEqual(2);
});
test('entry execution pill opens the Local CLI and BYOK switcher from Home', 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: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: { codex: { model: 'default' } },
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
await page.route('**/api/agents', async (route) => {
await route.fulfill({
json: {
agents: [
{
id: 'claude',
name: 'Claude Code',
bin: 'claude',
available: true,
version: '1.0.0',
models: [{ id: 'default', label: 'Default' }],
},
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.80.0',
models: [{ id: 'default', label: 'Default' }],
},
{
id: 'opencode',
name: 'OpenCode',
bin: 'opencode',
available: true,
version: '0.5.0',
models: [{ id: 'default', label: 'Default' }],
},
{
id: 'hermes',
name: 'Hermes',
bin: 'hermes',
available: true,
version: '0.5.0',
models: [{ id: 'default', label: 'Default' }],
},
{
id: 'cursor-agent',
name: 'Cursor Agent',
bin: 'cursor-agent',
available: true,
version: '0.5.0',
models: [{ id: 'default', label: 'Default' }],
},
],
},
});
});
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'codex',
skillId: null,
designSystemId: null,
agentModels: { codex: { model: 'default' } },
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
await gotoEntryHome(page);
const pill = page.getByTestId('inline-model-switcher-chip');
await expect(pill).toContainText(LOCAL_CLI_LABEL);
await expect(pill).toContainText('Codex CLI');
await pill.click();
const popover = page.getByTestId('inline-model-switcher-popover');
await expect(popover).toBeVisible();
await expect(page.getByTestId('inline-model-switcher-mode-daemon')).toHaveAttribute(
'aria-selected',
'true',
);
await expect(page.getByTestId('inline-model-switcher-mode-api')).toBeVisible();
await expect(page.getByTestId('inline-model-switcher-agent-claude')).toBeVisible();
await expect(page.getByTestId('inline-model-switcher-agent-codex')).toBeVisible();
await expect(page.getByTestId('inline-model-switcher-agent-opencode')).toBeVisible();
await expect(page.getByTestId('inline-model-switcher-agent-hermes')).toBeVisible();
await expect(page.getByTestId('inline-model-switcher-agent-cursor-agent')).toBeVisible();
await page.getByTestId('inline-model-switcher-open-settings').click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('tab', { name: LOCAL_CLI_LABEL })).toBeVisible();
});
test('entry help menu exposes community links and topbar routes Use everywhere', async ({ page }) => {
await gotoEntryHome(page);
await page.getByTestId('entry-help-trigger').click();
const menu = page.locator('.entry-help-popover[role="menu"]');
await expect(menu).toBeVisible();
await expect(menu.getByRole('menuitem', { name: /Follow @nexudotio on X/i })).toHaveAttribute(
'href',
'https://x.com/nexudotio',
);
await expect(menu.getByRole('menuitem', { name: /Join Discord/i })).toHaveAttribute(
'href',
'https://discord.gg/mHAjSMV6gz',
);
await page.getByTestId('entry-use-everywhere-button').click();
await expect(page.getByRole('heading', { name: 'Integrations' })).toBeVisible();
await expect(page.getByTestId('integrations-tab-use-everywhere')).toHaveAttribute(
'aria-selected',
'true',
);
await page.getByTestId('entry-nav-logo').click();
await expect(page.getByTestId('home-hero')).toBeVisible();
await page.getByTestId('entry-help-trigger').click();
await expect(menu).toBeVisible();
await page.keyboard.press('Escape');
await expect(menu).toHaveCount(0);
});
test('home topbar overlays close on outside click, Escape, and Settings open', async ({ page }) => {
await gotoEntryHome(page);
const pill = page.getByTestId('inline-model-switcher-chip');
const executionPopover = page.getByTestId('inline-model-switcher-popover');
const settingsButton = page.getByRole('button', { name: 'Open settings' });
await pill.click();
await expect(executionPopover).toBeVisible();
await settingsButton.click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(executionPopover).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
await pill.click();
await expect(executionPopover).toBeVisible();
await page.getByTestId('home-hero').click();
await expect(executionPopover).toHaveCount(0);
await pill.click();
await expect(executionPopover).toBeVisible();
await page.keyboard.press('Escape');
await expect(executionPopover).toHaveCount(0);
});
test('entry execution pill remains available across secondary entry pages', async ({ page }) => {
await routeDesignSystems(page);
await gotoEntryHome(page);
const destinations = [
{ nav: 'entry-nav-projects', heading: 'Projects' },
{ nav: 'entry-nav-tasks', heading: 'Automations' },
{ nav: 'entry-nav-plugins', heading: 'Plugins' },
{ nav: 'entry-nav-design-systems', heading: 'Design systems' },
{ nav: 'entry-nav-integrations', heading: 'Integrations' },
];
for (const destination of destinations) {
await page.getByTestId(destination.nav).click();
await expect(
page.locator('h1').filter({ hasText: destination.heading }).first(),
).toBeVisible();
const pill = page.getByTestId('inline-model-switcher-chip');
await expect(pill).toBeVisible();
await pill.click();
await expect(page.getByTestId('inline-model-switcher-popover')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByTestId('inline-model-switcher-popover')).toHaveCount(0);
}
});
test('home starters can browse registry and use a starter query from Home', async ({ page }) => {
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
plugins: [STARTER_PLUGIN],
},
});
});
await gotoEntryHome(page);
await expect(page.getByTestId('plugins-home-browse-registry')).toBeVisible();
await page.getByTestId('plugins-home-browse-registry').click();
await expect(page).toHaveURL(/\/plugins$/);
await expect(page.getByTestId('entry-nav-plugins')).toHaveAttribute('aria-current', 'page');
await expect(page.locator('h1').filter({ hasText: 'Plugins' })).toBeVisible();
await expect(page.getByTestId('plugins-tab-installed')).toBeVisible();
await expect(page.getByTestId('plugins-tab-available')).toBeVisible();
await expect(page.getByTestId('plugins-tab-sources')).toBeVisible();
await expect(page.getByTestId('plugins-create-button')).toBeVisible();
await expect(page.getByTestId('plugins-import-button')).toBeVisible();
await page.getByTestId('entry-nav-logo').click();
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('plugins-home-use-menu-localized-plugin')).toBeVisible();
await page.getByTestId('plugins-home-use-menu-localized-plugin').click({ force: true });
await page.getByTestId('plugins-home-use-with-query-localized-plugin').click();
const input = page.getByTestId('home-hero-input');
await expect(input).toHaveValue('Make a design systems brief.');
});
test('home starters shows the empty catalog state when no plugins are available', async ({ page }) => {
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
plugins: [],
},
});
});
await gotoEntryHome(page);
await expect(page.getByTestId('plugins-home-section')).toContainText('Catalog is empty.');
});
test('home starters search and facet filters narrow the visible gallery', async ({ page }) => {
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
plugins: STARTER_PLUGINS,
},
});
});
await gotoEntryHome(page);
await expect(page.getByTestId('plugins-home-chip-saved')).toBeVisible();
await expect(page.getByTestId('plugins-home-chip-saved')).toContainText('0');
await expect(page.getByTestId('plugins-home-pill-category-all')).toContainText('4');
await page.getByTestId('plugins-home-pill-category-deck').click();
await expect(page.locator('[data-plugin-id="deck-writer"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="figma-importer"]')).toHaveCount(0);
await expect(page.locator('[data-plugin-id="localized-plugin"]')).toHaveCount(0);
await expect(page.locator('[data-plugin-id="hyperframes-video"]')).toHaveCount(0);
await page.getByTestId('plugins-home-pill-category-all').click();
await expect(page.locator('[data-plugin-id="figma-importer"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="localized-plugin"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="hyperframes-video"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="deck-writer"]')).toBeVisible();
const search = page.getByTestId('plugins-home-search');
await search.fill('Deck Writer');
await expect(page.locator('[data-plugin-id="deck-writer"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="localized-plugin"]')).toHaveCount(0);
await expect(page.locator('[data-plugin-id="hyperframes-video"]')).toHaveCount(0);
await page.getByTestId('plugins-home-search-clear').click();
await page.getByTestId('plugins-home-save-localized-plugin').click();
await page.getByTestId('plugins-home-save-hyperframes-video').click();
await expect(page.getByTestId('plugins-home-chip-saved')).toContainText('2');
await page.getByTestId('plugins-home-chip-saved').click();
await expect(page.locator('[data-plugin-id="localized-plugin"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="hyperframes-video"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="deck-writer"]')).toHaveCount(0);
await expect(page.locator('[data-plugin-id="figma-importer"]')).toHaveCount(0);
});
test('home starters can jump into plugin creation through the registry browse flow', async ({ page }) => {
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
plugins: STARTER_PLUGINS,
},
});
});
await gotoEntryHome(page);
await page.getByTestId('plugins-home-browse-registry').click();
await expect(page).toHaveURL(/\/plugins$/);
await expect(page.locator('h1').filter({ hasText: 'Plugins' })).toBeVisible();
await page.getByTestId('plugins-create-button').click();
await expect(page.getByTestId('home-hero-input')).toHaveValue(/Create an Open Design plugin/i);
});
test('home starters search can enter a no-results state and recover with clear', async ({ page }) => {
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
plugins: STARTER_PLUGINS,
},
});
});
await gotoEntryHome(page);
await page.getByTestId('plugins-home-pill-category-all').click();
await page.getByTestId('plugins-home-search').fill('no-such-starter');
await expect(page.getByTestId('plugins-home-section')).toContainText(
'No plugins match the current filters.',
);
await page.getByRole('button', { name: /Clear filters/i }).click();
await expect(page.locator('[data-plugin-id="localized-plugin"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="deck-writer"]')).toBeVisible();
});
test('home starters details modal opens from a gallery card and closes on Escape', async ({ page }) => {
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
plugins: [STARTER_PLUGIN],
},
});
});
await gotoEntryHome(page);
const card = page.locator('[data-plugin-id="localized-plugin"]').first();
await expect(card).toBeVisible();
await card.hover();
await page.getByTestId('plugins-home-details-localized-plugin').click({ force: true });
const dialog = page.getByTestId('plugin-details-modal');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('Localized Plugin');
await expect(page.getByTestId('plugin-details-use-localized-plugin')).toBeVisible();
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
});
test('home starters html details modal exposes header actions and closes from the close button', async ({ page }) => {
const htmlPlugin = makeStarterPlugin({
id: 'html-details-plugin',
title: 'HTML Details Plugin',
description: 'A richly described HTML starter.',
mode: 'deck',
featured: true,
query: 'Draft a {{topic}} deck.',
inputs: [{ name: 'topic', type: 'string', default: 'warm paper' }],
previewEntry: './example.html',
});
await page.route('**/api/plugins', async (route) => {
await route.fulfill({ json: { plugins: [htmlPlugin] } });
});
await page.route('**/api/plugins/html-details-plugin/preview', async (route) => {
await route.fulfill({
status: 200,
headers: { 'content-type': 'text/html' },
body: '<!doctype html><html><body><h1>HTML Details Preview</h1></body></html>',
});
});
await gotoEntryHome(page);
await page.locator('article.plugins-home__card[data-plugin-id="html-details-plugin"]').hover();
await page.getByTestId('plugins-home-details-html-details-plugin').click({ force: true });
const dialog = page.getByRole('dialog', { name: /HTML Details Plugin preview/i });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('HTML Details Plugin');
await expect(page.getByTestId('plugin-details-use-html-details-plugin')).toBeVisible();
await expect(page.getByRole('button', { name: 'Plugin info', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /Fullscreen|全屏/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Share/i }).first()).toBeVisible();
await expect(page.getByTestId('plugin-share-html-details-plugin')).toBeVisible();
await page.getByRole('button', { name: 'Plugin info', exact: true }).click();
await expect(page.locator('.ds-modal-sidebar')).toHaveCount(0);
await page.getByRole('button', { name: 'Plugin info', exact: true }).click();
await expect(page.locator('.ds-modal-sidebar')).toBeVisible();
await dialog.locator('.ds-modal-close').click();
await expect(dialog).toHaveCount(0);
});
test('home starters html details modal shows metadata links, supports copy query, and opens the plugin share menu', async ({ page }) => {
const htmlPlugin = makeStarterPlugin({
id: 'html-metadata-plugin',
title: 'HTML Metadata Plugin',
description: 'A richly described HTML starter.',
mode: 'deck',
featured: true,
query: 'Use the {{topic}} template for a polished launch deck.',
inputs: [{ name: 'topic', type: 'string', default: 'editorial systems' }],
previewEntry: './example.html',
tags: ['deck', 'marketing'],
authorName: 'Open Design',
authorUrl: 'https://github.com/nexu-io/open-design',
homepage: 'https://example.com/html-metadata-plugin',
context: {
skills: [{ path: './SKILL.md' }],
assets: ['./example.html'],
},
pipeline: {
stages: [{ id: 'draft', atoms: ['outline', 'compose'] }],
},
});
await page.addInitScript(() => {
const store: string[] = [];
const clipboard = {
writeText(text: string) {
store.push(text);
return Promise.resolve();
},
readText() {
return Promise.resolve(store.at(-1) ?? '');
},
};
Object.defineProperty(window, '__copiedTexts', {
value: store,
configurable: true,
});
Object.defineProperty(navigator, 'clipboard', {
value: clipboard,
configurable: true,
});
});
await page.route('**/api/plugins', async (route) => {
await route.fulfill({ json: { plugins: [htmlPlugin] } });
});
await page.route('**/api/plugins/html-metadata-plugin/preview', async (route) => {
await route.fulfill({
status: 200,
headers: { 'content-type': 'text/html' },
body: '<!doctype html><html><body><h1>HTML Metadata Preview</h1></body></html>',
});
});
await gotoEntryHome(page);
await page.locator('article.plugins-home__card[data-plugin-id="html-metadata-plugin"]').hover();
await page.getByTestId('plugins-home-details-html-metadata-plugin').click({ force: true });
const dialog = page.getByRole('dialog', { name: /HTML Metadata Plugin preview/i });
await expect(dialog).toBeVisible();
await expect(page.getByTestId('plugin-details-author')).toContainText('Open Design');
await expect(page.getByTestId('plugin-details-author-profile')).toHaveAttribute(
'href',
'https://github.com/nexu-io/open-design',
);
await expect(page.getByTestId('plugin-details-author-homepage')).toHaveAttribute(
'href',
'https://github.com/nexu-io/open-design',
);
await expect(dialog).toContainText('Context bundles');
await expect(dialog).toContainText('./SKILL.md');
await expect(dialog).toContainText('./example.html');
await expect(dialog).toContainText('Workflow');
await expect(dialog).toContainText('draft');
await expect(dialog).toContainText('outline');
const copyButton = dialog.getByRole('button', { name: /^Copy$/i }).first();
await copyButton.click();
await expect(dialog.getByRole('button', { name: /^Copied$/i })).toBeVisible();
const copied = await page.evaluate(() => (window as typeof window & { __copiedTexts?: string[] }).__copiedTexts ?? []);
expect(copied.at(-1)).toBe('Use the {{topic}} template for a polished launch deck.');
await page.getByTestId('plugin-share-html-metadata-plugin').getByRole('button', { name: /^More$/i }).click();
const shareMenu = page.locator('.plugin-share-popover[role="menu"]');
await expect(shareMenu).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Copy install command/i })).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Copy plugin ID/i })).toBeVisible();
// Bundled plugins now have a public open-design.ai detail page, so the
// README badge (which links to it) is offered.
await expect(shareMenu.getByRole('menuitem', { name: /Copy README badge/i })).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Open source on GitHub/i })).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Open homepage/i })).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Open in marketplace/i })).toBeVisible();
});
test('home starters Use plugin from the details modal applies the plugin to the home hero', async ({ page }) => {
const htmlPlugin = makeStarterPlugin({
id: 'detail-use-plugin',
title: 'Detail Use Plugin',
description: 'A detail-apply fixture.',
mode: 'prototype',
query: 'Make a {{topic}} brief.',
inputs: [{ name: 'topic', type: 'string', default: 'detail modal' }],
previewEntry: './example.html',
});
await page.route('**/api/plugins', async (route) => {
await route.fulfill({ json: { plugins: [htmlPlugin] } });
});
await page.route('**/api/plugins/detail-use-plugin/preview', async (route) => {
await route.fulfill({
status: 200,
headers: { 'content-type': 'text/html' },
body: '<!doctype html><html><body><h1>Detail Use Preview</h1></body></html>',
});
});
await gotoEntryHome(page);
await page.locator('article.plugins-home__card[data-plugin-id="detail-use-plugin"]').hover();
await page.getByTestId('plugins-home-details-detail-use-plugin').click({ force: true });
const dialog = page.getByRole('dialog', { name: /Detail Use Plugin preview/i });
await expect(dialog).toBeVisible();
await page.getByTestId('plugin-details-use-detail-use-plugin').click();
await expect(dialog).toHaveCount(0);
await expect(page.getByTestId('home-hero-context-plugin-detail-use-plugin')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toHaveValue('');
});
test('home starters direct Use keeps prompt empty and still allows a freeform submit', async ({ page }) => {
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
plugins: [STARTER_PLUGIN],
},
});
});
await gotoEntryHome(page);
const input = page.getByTestId('home-hero-input');
await expect(input).toHaveValue('');
await page.getByTestId('plugins-home-use-localized-plugin').click({ force: true });
await expect(input).toHaveValue('');
await input.fill('Use the selected starter as context');
const projectRequestPromise = page.waitForRequest(isCreateProjectRequest);
const runRequestPromise = page.waitForRequest(isCreateRunRequest);
await page.getByTestId('home-hero-submit').click();
const projectRequest = await projectRequestPromise;
const projectBody = projectRequest.postDataJSON() as {
pluginId?: string;
pendingPrompt?: string;
};
expect(projectBody.pendingPrompt).toBe('Use the selected starter as context');
expect(projectBody.pluginId).toBe('od-default');
const runRequest = await runRequestPromise;
const runBody = runRequest.postDataJSON() as { message?: string };
expect(runBody.message).toContain('Use the selected starter as context');
await expect(page).toHaveURL(/\/projects\//);
});
test('home starters Use with query hydrates the prompt and keeps plugin context visible', async ({ page }) => {
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
plugins: [STARTER_PLUGIN],
},
});
});
await gotoEntryHome(page);
const input = page.getByTestId('home-hero-input');
await expect(input).toHaveValue('');
const starterCard = page.locator('[data-plugin-id="localized-plugin"]').first();
await starterCard.scrollIntoViewIfNeeded();
await starterCard.hover();
await expect(page.getByTestId('plugins-home-use-menu-localized-plugin')).toBeVisible();
await page.getByTestId('plugins-home-use-menu-localized-plugin').click();
await page.getByTestId('plugins-home-use-with-query-localized-plugin').click();
await expect(page.getByTestId('home-hero-context-plugin-localized-plugin')).toBeVisible();
await expect(input).toHaveValue('Make a design systems brief.');
});
test('home hero input keeps Shift+Enter as a newline and submits on Enter', async ({ page }) => {
await gotoEntryHome(page);
const input = page.getByTestId('home-hero-input');
const submit = page.getByTestId('home-hero-submit');
await expect(submit).toBeDisabled();
await input.click();
await input.fill('Line one');
await input.press('Shift+Enter');
await input.type('Line two');
await expect(input).toHaveValue('Line one\nLine two');
await expect(page).toHaveURL(/\/$/);
await expect(submit).toBeEnabled();
const projectRequestPromise = page.waitForRequest(isCreateProjectRequest);
const runRequestPromise = page.waitForRequest(isCreateRunRequest);
await input.press('Enter');
const projectRequest = await projectRequestPromise;
const projectBody = projectRequest.postDataJSON() as { pendingPrompt?: string };
expect(projectBody.pendingPrompt).toBe('Line one\nLine two');
const runRequest = await runRequestPromise;
const runBody = runRequest.postDataJSON() as { message?: string };
expect(runBody.message).toContain('Line one\nLine two');
await expect(page).toHaveURL(/\/projects\//);
});
test('home hero @ mention picker opens and Enter applies the highlighted plugin', async ({ page }) => {
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
plugins: [STARTER_PLUGIN],
},
});
});
await gotoEntryHome(page);
const input = page.getByTestId('home-hero-input');
await input.click();
await input.fill('@local');
const picker = page.getByTestId('home-hero-plugin-picker');
await expect(picker).toBeVisible();
await expect(picker.getByRole('option', { name: /Localized Plugin/i })).toBeVisible();
await input.press('Enter');
await expect(picker).toHaveCount(0);
await expect(input).toHaveValue('@Localized Plugin');
});
test('home hero attachment input stages files, enables submit, and supports removal', async ({ page }) => {
await gotoEntryHome(page);
const input = page.getByTestId('home-hero-file-input');
const submit = page.getByTestId('home-hero-submit');
await expect(submit).toBeDisabled();
await input.setInputFiles({
name: 'brief.txt',
mimeType: 'text/plain',
buffer: Buffer.from('Attachment staged from the home hero.\n', 'utf8'),
});
const staged = page.getByTestId('home-hero-staged-files');
await expect(staged).toBeVisible();
await expect(staged.getByText('brief.txt', { exact: true })).toBeVisible();
await expect(submit).toBeEnabled();
await page.getByRole('button', { name: /Remove brief\.txt/i }).click();
await expect(staged).toHaveCount(0);
await expect(submit).toBeDisabled();
});
test('home hero attachment-only submit uploads the file and sends it with the first message', async ({ page }) => {
await gotoEntryHome(page);
const uploadResponse = page.waitForResponse(
(resp) =>
/\/api\/projects\/[^/]+\/upload$/.test(new URL(resp.url()).pathname) &&
resp.request().method() === 'POST',
);
await page.getByTestId('home-hero-file-input').setInputFiles({
name: 'reference.txt',
mimeType: 'text/plain',
buffer: Buffer.from('Attachment-only home submission.\n', 'utf8'),
});
await expect(page.getByTestId('home-hero-staged-files')).toBeVisible();
await expect(page.getByTestId('home-hero-staged-files')).toContainText('reference.txt');
await expect(page.getByTestId('home-hero-submit')).toBeEnabled();
await page.getByTestId('home-hero-submit').click();
await expect((await uploadResponse).ok()).toBeTruthy();
await expect(page).toHaveURL(/\/projects\//);
await expect(page.locator('.user-attachments').getByText('reference.txt', { exact: true })).toBeVisible();
});
async function gotoEntryHome(page: Page) {
await page.goto('/');
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible()) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
}
async function createProject(page: Page, name: string) {
const id = `entry-home-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const response = await page.request.post('/api/projects', {
data: {
id,
name,
skillId: null,
designSystemId: null,
metadata: { kind: 'prototype' },
},
});
expect(response.ok(), await response.text()).toBeTruthy();
return response.json() as Promise<{ project: { id: string; name: string } }>;
}
async function routeDesignSystems(page: Page) {
await page.route('**/api/design-systems', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
return;
}
await route.continue();
});
await page.route('**/api/design-systems/*/showcase', async (route) => {
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-2) ?? '');
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html><body><main><h1>${id} showcase</h1></main></body></html>`,
});
});
await page.route('**/api/design-systems/*/preview', async (route) => {
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-2) ?? '');
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html><body><main><h1>${id} tokens</h1></main></body></html>`,
});
});
await page.route('**/api/design-systems/*', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-1) ?? '');
const system = DESIGN_SYSTEMS.find((item) => item.id === id) ?? DESIGN_SYSTEMS[0];
await route.fulfill({
json: {
designSystem: {
...system,
body: `# ${system.title}\n\nDesign guidance for ${system.title}.`,
},
},
});
});
}
function isCreateRunRequest(request: Request): boolean {
const url = new URL(request.url());
return url.pathname === '/api/runs' && request.method() === 'POST';
}
function isCreateProjectRequest(request: Request): boolean {
const url = new URL(request.url());
return url.pathname === '/api/projects' && request.method() === 'POST';
}
function makeStarterPlugin({
id,
title,
description = 'A localized fixture',
mode = 'prototype',
taskKind = 'new-generation',
featured = false,
tags = [],
query,
inputs = [],
previewEntry,
authorName,
authorUrl,
homepage,
context,
pipeline,
}: {
id: string;
title: string;
description?: string;
mode?: string;
taskKind?: 'new-generation' | 'figma-migration' | 'code-migration' | 'tune-collab';
featured?: boolean;
tags?: string[];
query?: string;
inputs?: Array<{ name: string; type: string; default?: string }>;
previewEntry?: string;
authorName?: string;
authorUrl?: string;
homepage?: string;
context?: Record<string, unknown>;
pipeline?: Record<string, unknown>;
}) {
return {
id,
title,
version: '1.0.0',
trust: 'trusted',
sourceKind: 'bundled',
source: `/tmp/${id}`,
capabilitiesGranted: ['prompt:inject'],
fsPath: `/tmp/${id}`,
installedAt: 0,
updatedAt: 0,
manifest: {
name: id,
title,
version: '1.0.0',
description,
...(authorName || authorUrl
? {
author: {
...(authorName ? { name: authorName } : {}),
...(authorUrl ? { url: authorUrl } : {}),
},
}
: {}),
...(homepage ? { homepage } : {}),
...(tags.length > 0 ? { tags } : {}),
od: {
kind: 'scenario',
taskKind,
mode,
...(featured ? { featured: true } : {}),
...(previewEntry
? {
preview: {
type: 'html',
entry: previewEntry,
},
}
: {}),
...(query
? {
useCase: {
query: {
en: query,
},
},
}
: {}),
...(inputs.length > 0 ? { inputs } : {}),
...(context ? { context } : {}),
...(pipeline ? { pipeline } : {}),
},
},
} as const;
}