mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
394 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|