Files
open-design/apps/web/tests/components/PluginShareMenu.test.tsx
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

394 lines
12 KiB
TypeScript

// @vitest-environment jsdom
// PluginShareMenu — plugin actions affordance contract.
//
// Locks the popover behaviour users expect from a plugin-specific
// actions button on a detail modal: copy install command / plugin id /
// README badge land on the clipboard, and the popover surfaces source +
// homepage links when the manifest carries them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
buildPluginShareUrl,
PluginShareMenu,
} from '../../src/components/plugin-details/PluginShareMenu';
import { I18nProvider, type Locale } from '../../src/i18n';
interface MakeArgs {
id: string;
title?: string;
source?: string;
sourceKind?: InstalledPluginRecord['sourceKind'];
marketplaceId?: string;
marketplaceEntryName?: string;
authorUrl?: string;
homepage?: string;
}
function make(args: MakeArgs): InstalledPluginRecord {
return {
id: args.id,
title: args.title ?? args.id,
version: '0.1.0',
sourceKind: args.sourceKind ?? 'bundled',
source: args.source ?? `plugins/${args.id}`,
sourceMarketplaceId: args.marketplaceId,
sourceMarketplaceEntryName: args.marketplaceEntryName,
trust: 'bundled',
capabilitiesGranted: [],
manifest: {
name: args.id,
version: '0.1.0',
title: args.title ?? args.id,
...(args.authorUrl ? { author: { url: args.authorUrl } } : {}),
...(args.homepage ? { homepage: args.homepage } : {}),
od: { kind: 'scenario' },
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
describe('PluginShareMenu', () => {
let container: HTMLDivElement;
let root: Root;
let writes: string[];
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
writes = [];
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn(async (value: string) => {
writes.push(value);
}),
},
});
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...window.location,
origin: 'https://example.test',
},
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function renderMenu(record: InstalledPluginRecord, locale?: Locale) {
act(() => {
root.render(
locale ? (
<I18nProvider initial={locale}>
<PluginShareMenu record={record} />
</I18nProvider>
) : (
<PluginShareMenu record={record} />
),
);
});
}
function openPopover(expectedTriggerText = 'More') {
const trigger = container.querySelector(
'.plugin-share-trigger',
) as HTMLButtonElement;
expect(trigger).toBeTruthy();
expect(trigger.textContent).toContain(expectedTriggerText);
act(() => {
trigger.click();
});
}
function clickItem(label: string) {
const items = Array.from(
container.querySelectorAll('.plugin-share-item'),
) as HTMLButtonElement[];
const match = items.find((b) => b.textContent?.includes(label));
expect(match, `expected an item labelled "${label}"`).toBeTruthy();
act(() => {
match!.click();
});
}
it('copies an install command for marketplace plugins using the registry entry name', async () => {
renderMenu(
make({
id: 'mp-plugin',
sourceKind: 'github',
source: 'github:open-design/plugins/mp-plugin',
marketplaceId: 'official',
marketplaceEntryName: 'open-design/mp-plugin',
}),
);
openPopover();
clickItem('Copy install command');
await Promise.resolve();
expect(writes).toContain('od plugin install open-design/mp-plugin');
});
it('copies the github source string for github-installed plugins', async () => {
renderMenu(
make({
id: 'gh-plugin',
sourceKind: 'github',
source: 'github:owner/repo@main/sub',
}),
);
openPopover();
clickItem('Copy install command');
await Promise.resolve();
expect(writes).toContain('od plugin install github:owner/repo@main/sub');
});
it('does not duplicate the template share link action', () => {
renderMenu(make({ id: 'live-dashboard' }));
openPopover();
const labels = Array.from(
container.querySelectorAll('.plugin-share-item'),
).map((item) => item.textContent ?? '');
expect(labels.some((label) => label.includes('Copy share link'))).toBe(false);
});
it('copies the bare plugin id for paste-into-yaml workflows', async () => {
renderMenu(make({ id: 'agentic-ds' }));
openPopover();
clickItem('Copy plugin ID');
await Promise.resolve();
expect(writes).toContain('agentic-ds');
});
it('copies a README badge that links back to the marketplace detail page', async () => {
renderMenu(make({
id: 'badge-plugin',
title: 'Badge Plugin',
marketplaceId: 'official',
marketplaceEntryName: 'open-design/badge-plugin',
}));
openPopover();
clickItem('Copy README badge');
await Promise.resolve();
expect(writes.some((value) => (
value.includes('Badge Plugin') &&
value.includes('https://open-design.ai/plugins/badge-plugin')
))).toBe(true);
});
it('does not expose public share artifacts for local-only plugins', () => {
const localOnly = make({
id: 'local-plugin',
sourceKind: 'local',
source: '/tmp/local-plugin',
});
expect(buildPluginShareUrl(localOnly)).toBeNull();
renderMenu(localOnly);
openPopover();
const labels = Array.from(
container.querySelectorAll('.plugin-share-item'),
).map((item) => item.textContent ?? '');
expect(labels.some((label) => label.includes('Copy README badge'))).toBe(false);
});
it('does not expose public share artifacts for private marketplace plugins', () => {
const privateMarketplace = make({
id: 'private-plugin',
sourceKind: 'marketplace',
source: 'private/private-plugin',
marketplaceId: 'private',
marketplaceEntryName: 'private/private-plugin',
});
expect(buildPluginShareUrl(privateMarketplace)).toBeNull();
renderMenu(privateMarketplace);
openPopover();
const labels = Array.from(
container.querySelectorAll('.plugin-share-item'),
).map((item) => item.textContent ?? '');
expect(labels.some((label) => label.includes('Copy README badge'))).toBe(false);
});
it('localizes the plugin action menu labels', () => {
renderMenu(
make({
id: 'zh-plugin',
sourceKind: 'github',
source: 'github:owner/repo',
marketplaceId: 'official',
marketplaceEntryName: 'open-design/zh-plugin',
homepage: 'https://example.test/plugin-home',
}),
'zh-CN',
);
openPopover('更多');
const labels = Array.from(
container.querySelectorAll('.plugin-share-item'),
).map((item) => item.textContent ?? '');
expect(labels).toContain('复制安装命令');
expect(labels).toContain('复制插件 ID');
expect(labels).toContain('复制 README 徽章');
expect(labels).toContain('在 GitHub 打开源码');
expect(labels).toContain('打开项目主页');
expect(labels).toContain('在插件市场打开');
expect(labels.some((label) => label.includes('Copy install command'))).toBe(false);
});
it('points Open in marketplace at the public open-design.ai page for bundled plugins', () => {
renderMenu(make({ id: 'plain' }));
openPopover();
const items = Array.from(
container.querySelectorAll('.plugin-share-item'),
) as HTMLButtonElement[];
expect(items.some((b) => b.textContent?.includes('Open in marketplace'))).toBe(
true,
);
const marketplaceLink = Array.from(
container.querySelectorAll<HTMLAnchorElement>('a.plugin-share-item'),
).find((link) => link.textContent?.includes('Open in marketplace'));
// Bundled plugins have a public detail page, so the link is the public
// open-design.ai URL — not a local /marketplace path.
expect(marketplaceLink?.getAttribute('href')).toBe(
'https://open-design.ai/plugins/plain/',
);
});
it('builds a public open-design.ai share link for bundled plugins', () => {
expect(buildPluginShareUrl(make({ id: 'simple-deck' }))).toBe(
'https://open-design.ai/plugins/simple-deck/',
);
});
it('builds a public open-design.ai share link for community marketplace plugins', () => {
// Community manifest names carry a `community-` prefix, but the landing
// page routes are keyed on the folder name via routeId=`community/<folder>`.
// buildPluginShareUrl must use sourceMarketplaceEntryName so pluginDetailSlug
// takes the last segment and matches the generated page slug.
expect(
buildPluginShareUrl(
make({
id: 'community-registry-starter',
sourceKind: 'marketplace',
source: 'community/registry-starter',
marketplaceId: 'community',
marketplaceEntryName: 'community/registry-starter',
}),
),
).toBe('https://open-design.ai/plugins/registry-starter/');
});
it('copies a README badge for community marketplace plugins', async () => {
renderMenu(
make({
id: 'community-registry-starter',
title: 'Community Registry Starter',
sourceKind: 'marketplace',
source: 'community/registry-starter',
marketplaceId: 'community',
marketplaceEntryName: 'community/registry-starter',
}),
);
openPopover();
clickItem('Copy README badge');
await Promise.resolve();
expect(
writes.some(
(value) =>
value.includes('Community Registry Starter') &&
value.includes('https://open-design.ai/plugins/registry-starter/'),
),
).toBe(true);
});
it('points Open in marketplace at the public page for community marketplace plugins', () => {
renderMenu(
make({
id: 'community-registry-starter',
sourceKind: 'marketplace',
source: 'community/registry-starter',
marketplaceId: 'community',
marketplaceEntryName: 'community/registry-starter',
}),
);
openPopover();
const marketplaceLink = Array.from(
container.querySelectorAll<HTMLAnchorElement>('a.plugin-share-item'),
).find((link) => link.textContent?.includes('Open in marketplace'));
expect(marketplaceLink?.getAttribute('href')).toBe(
'https://open-design.ai/plugins/registry-starter/',
);
});
it('surfaces the GitHub source link when sourceKind is github', () => {
renderMenu(
make({
id: 'gh-with-link',
sourceKind: 'github',
source: 'github:owner/repo',
}),
);
openPopover();
const items = Array.from(
container.querySelectorAll('.plugin-share-item'),
) as HTMLElement[];
expect(items.some((b) => b.textContent?.includes('Open source on GitHub'))).toBe(
true,
);
const sourceLink = container.querySelector<HTMLAnchorElement>(
'a.plugin-share-item[href="https://github.com/owner/repo"]',
);
expect(sourceLink).toBeTruthy();
});
it('surfaces the homepage link when manifest.homepage is set', () => {
renderMenu(
make({
id: 'with-homepage',
sourceKind: 'local',
homepage: 'https://example.test/plugin-home',
}),
);
openPopover();
const items = Array.from(
container.querySelectorAll('.plugin-share-item'),
) as HTMLElement[];
expect(items.some((b) => b.textContent?.includes('Open homepage'))).toBe(
true,
);
const homepageLink = Array.from(
container.querySelectorAll<HTMLAnchorElement>('a.plugin-share-item'),
).find((link) => link.textContent?.includes('Open homepage'));
expect(homepageLink).toBeTruthy();
expect(homepageLink?.getAttribute('href')).toBe('https://example.test/plugin-home');
});
it('renders official bundled repo links as anchors', () => {
renderMenu(
make({
id: 'official-plugin',
sourceKind: 'bundled',
source: 'plugins/_official/scenarios/official-plugin',
}),
);
openPopover();
const repoLinks = Array.from(
container.querySelectorAll<HTMLAnchorElement>(
'a.plugin-share-item[href="https://github.com/nexu-io/open-design"]',
),
);
expect(repoLinks.length).toBeGreaterThan(0);
expect(
repoLinks.some((link) => link.textContent?.includes('Open source on GitHub')),
).toBe(true);
});
});