mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* Add template social sharing menu * Update plugin share e2e expectations * Add additional template social share targets * Remove Bilibili template share target * Open social share destinations in new tabs * Address template share review feedback * Use canonical public plugin share URLs * Gate public plugin share links by marketplace provenance * Update plugin share e2e for local-only badges * Limit public share URLs to official marketplace
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ExamplesTab } from '../../src/components/ExamplesTab';
|
|
import { fetchSkillExample } from '../../src/providers/registry';
|
|
import {
|
|
exportAsHtml,
|
|
exportAsPdf,
|
|
exportAsZip,
|
|
openSandboxedPreviewInNewTab,
|
|
} from '../../src/runtime/exports';
|
|
import type { SkillSummary } from '../../src/types';
|
|
|
|
vi.mock('../../src/providers/registry', () => ({
|
|
fetchSkillExample: vi.fn(async (id: string) => ({
|
|
html: `<main><h1>${id} preview</h1></main>`,
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../../src/runtime/exports', () => ({
|
|
exportAsHtml: vi.fn(),
|
|
exportAsPdf: vi.fn(),
|
|
exportAsZip: vi.fn(),
|
|
openSandboxedPreviewInNewTab: vi.fn(),
|
|
}));
|
|
|
|
const originalIntersectionObserver = globalThis.IntersectionObserver;
|
|
|
|
class IdleIntersectionObserver {
|
|
observe() {}
|
|
disconnect() {}
|
|
unobserve() {}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
globalThis.IntersectionObserver =
|
|
IdleIntersectionObserver as unknown as typeof IntersectionObserver;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
globalThis.IntersectionObserver = originalIntersectionObserver;
|
|
});
|
|
|
|
function skill(overrides: Partial<SkillSummary> & Pick<SkillSummary, 'id' | 'name'>): SkillSummary {
|
|
return {
|
|
id: overrides.id,
|
|
name: overrides.name,
|
|
description: overrides.description ?? `${overrides.name} example`,
|
|
triggers: overrides.triggers ?? [],
|
|
mode: overrides.mode ?? 'prototype',
|
|
surface: overrides.surface ?? 'web',
|
|
platform: overrides.platform ?? 'desktop',
|
|
scenario: overrides.scenario ?? 'general',
|
|
previewType: overrides.previewType ?? 'html',
|
|
designSystemRequired: overrides.designSystemRequired ?? false,
|
|
defaultFor: overrides.defaultFor ?? [],
|
|
upstream: overrides.upstream ?? null,
|
|
featured: overrides.featured ?? null,
|
|
fidelity: overrides.fidelity ?? null,
|
|
speakerNotes: overrides.speakerNotes ?? null,
|
|
animations: overrides.animations ?? null,
|
|
craftRequires: overrides.craftRequires ?? [],
|
|
hasBody: overrides.hasBody ?? true,
|
|
examplePrompt: overrides.examplePrompt ?? `Build ${overrides.name}.`,
|
|
aggregatesExamples: overrides.aggregatesExamples ?? false,
|
|
};
|
|
}
|
|
|
|
const skills: SkillSummary[] = [
|
|
skill({
|
|
id: 'live-dashboard',
|
|
name: 'live-dashboard',
|
|
description: 'Notion style workspace dashboard',
|
|
examplePrompt: 'Build me a Notion-style team dashboard.',
|
|
scenario: 'operations',
|
|
featured: 1,
|
|
}),
|
|
skill({
|
|
id: 'open-design-landing',
|
|
name: 'open-design-landing',
|
|
description: 'Editorial marketing landing page',
|
|
examplePrompt: 'Produce a world-class single-page editorial landing site.',
|
|
scenario: 'marketing',
|
|
featured: 2,
|
|
}),
|
|
skill({
|
|
id: 'mobile-checkout',
|
|
name: 'mobile-checkout',
|
|
description: 'Mobile checkout prototype',
|
|
mode: 'prototype',
|
|
platform: 'mobile',
|
|
scenario: 'product',
|
|
}),
|
|
skill({
|
|
id: 'brand-deck',
|
|
name: 'brand-deck',
|
|
description: 'Slides for brand strategy',
|
|
mode: 'deck',
|
|
scenario: 'marketing',
|
|
}),
|
|
skill({
|
|
id: 'hero-image',
|
|
name: 'hero-image',
|
|
description: 'Image generation prompt',
|
|
mode: 'image',
|
|
surface: 'image',
|
|
platform: null,
|
|
scenario: 'design',
|
|
}),
|
|
skill({
|
|
id: 'launch-video',
|
|
name: 'launch-video',
|
|
description: 'Video generation prompt',
|
|
mode: 'video',
|
|
surface: 'video',
|
|
platform: null,
|
|
scenario: 'marketing',
|
|
}),
|
|
skill({
|
|
id: 'brief-template',
|
|
name: 'brief-template',
|
|
description: 'Reusable project brief template',
|
|
examplePrompt: 'Create a reusable project brief from this template.',
|
|
mode: 'template',
|
|
surface: 'web',
|
|
platform: null,
|
|
scenario: 'operations',
|
|
}),
|
|
];
|
|
|
|
function renderExamples(onUsePrompt = vi.fn()) {
|
|
render(<ExamplesTab skills={skills} onUsePrompt={onUsePrompt} />);
|
|
return { onUsePrompt };
|
|
}
|
|
|
|
function filterRow(name: string) {
|
|
return screen.getByRole('tablist', { name });
|
|
}
|
|
|
|
describe('ExamplesTab', () => {
|
|
it('shows the empty skills state when the catalog is unavailable', () => {
|
|
render(<ExamplesTab skills={[]} onUsePrompt={vi.fn()} />);
|
|
|
|
expect(screen.getByText('No skills available. Is the daemon running?')).toBeTruthy();
|
|
});
|
|
|
|
it('deduplicates duplicate skill ids so each example card renders once (#2889)', () => {
|
|
const onUsePrompt = vi.fn();
|
|
render(
|
|
<ExamplesTab
|
|
skills={[
|
|
skill({
|
|
id: 'xhs-white-editorial',
|
|
name: 'XHS editorial',
|
|
examplePrompt: 'First prompt',
|
|
}),
|
|
skill({
|
|
id: 'xhs-white-editorial',
|
|
name: 'Duplicate XHS editorial',
|
|
examplePrompt: 'Duplicate prompt',
|
|
}),
|
|
skill({
|
|
id: 'open-design-landing',
|
|
name: 'Open Design landing',
|
|
examplePrompt: 'Unique prompt',
|
|
}),
|
|
]}
|
|
onUsePrompt={onUsePrompt}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getAllByTestId('example-card-xhs-white-editorial')).toHaveLength(1);
|
|
expect(screen.getByTestId('example-card-open-design-landing')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByTestId('example-use-prompt-xhs-white-editorial'));
|
|
expect(onUsePrompt).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: 'xhs-white-editorial',
|
|
examplePrompt: 'First prompt',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('filters examples by free-text search and shows an empty match state', () => {
|
|
renderExamples();
|
|
|
|
fireEvent.change(screen.getByRole('searchbox', { name: 'Search examples by name' }), {
|
|
target: { value: 'notion' },
|
|
});
|
|
|
|
expect(screen.getByTestId('example-card-live-dashboard')).toBeTruthy();
|
|
expect(screen.queryByTestId('example-card-open-design-landing')).toBeNull();
|
|
|
|
fireEvent.change(screen.getByRole('searchbox', { name: 'Search examples by name' }), {
|
|
target: { value: 'no matching example' },
|
|
});
|
|
|
|
expect(screen.getByText('No examples match these filters.')).toBeTruthy();
|
|
});
|
|
|
|
it('narrows by surface, type, and scenario filter pills', () => {
|
|
renderExamples();
|
|
|
|
fireEvent.click(within(filterRow('Surface')).getByRole('tab', { name: /Image1/ }));
|
|
expect(screen.getByTestId('example-card-hero-image')).toBeTruthy();
|
|
expect(screen.queryByTestId('example-card-live-dashboard')).toBeNull();
|
|
|
|
fireEvent.click(within(filterRow('Surface')).getByRole('tab', { name: /All7/ }));
|
|
fireEvent.click(within(filterRow('Type')).getByRole('tab', { name: /Prototypes · Mobile1/ }));
|
|
expect(screen.getByTestId('example-card-mobile-checkout')).toBeTruthy();
|
|
expect(screen.queryByTestId('example-card-live-dashboard')).toBeNull();
|
|
|
|
fireEvent.click(within(filterRow('Type')).getByRole('tab', { name: /All7/ }));
|
|
fireEvent.click(within(filterRow('Scenario')).getByRole('button', { name: /Marketing3/ }));
|
|
expect(screen.getByTestId('example-card-open-design-landing')).toBeTruthy();
|
|
expect(screen.getByTestId('example-card-brand-deck')).toBeTruthy();
|
|
expect(screen.getByTestId('example-card-launch-video')).toBeTruthy();
|
|
expect(screen.queryByTestId('example-card-live-dashboard')).toBeNull();
|
|
});
|
|
|
|
it('filters Docs & templates examples and uses the selected template prompt', () => {
|
|
const { onUsePrompt } = renderExamples();
|
|
|
|
fireEvent.click(within(filterRow('Type')).getByRole('tab', { name: /Docs & templates1/ }));
|
|
|
|
expect(screen.getByTestId('example-card-brief-template')).toBeTruthy();
|
|
expect(screen.getByText('Template')).toBeTruthy();
|
|
expect(screen.queryByTestId('example-card-live-dashboard')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByTestId('example-use-prompt-brief-template'));
|
|
|
|
expect(onUsePrompt).toHaveBeenCalledTimes(1);
|
|
expect(onUsePrompt).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: 'brief-template',
|
|
mode: 'template',
|
|
examplePrompt: 'Create a reusable project brief from this template.',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('passes the selected example to the Use this prompt callback', () => {
|
|
const { onUsePrompt } = renderExamples();
|
|
|
|
fireEvent.click(screen.getByTestId('example-use-prompt-open-design-landing'));
|
|
|
|
expect(onUsePrompt).toHaveBeenCalledTimes(1);
|
|
expect(onUsePrompt).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: 'open-design-landing',
|
|
examplePrompt: 'Produce a world-class single-page editorial landing site.',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('loads previews on demand and enables the share export menu', async () => {
|
|
renderExamples();
|
|
|
|
const card = screen.getByTestId('example-card-live-dashboard');
|
|
const shareButton = within(card).getByRole('button', { name: 'Share ▾' }) as HTMLButtonElement;
|
|
expect(shareButton.disabled).toBe(true);
|
|
|
|
fireEvent.mouseEnter(card);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchSkillExample).toHaveBeenCalledWith('live-dashboard', 'html');
|
|
expect(shareButton.disabled).toBe(false);
|
|
});
|
|
|
|
fireEvent.click(shareButton);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /Export as PDF/i }));
|
|
expect(exportAsPdf).toHaveBeenCalledWith(
|
|
'<main><h1>live-dashboard preview</h1></main>',
|
|
'live-dashboard',
|
|
{ deck: false },
|
|
);
|
|
|
|
fireEvent.click(shareButton);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /Download as \.zip/i }));
|
|
expect(exportAsZip).toHaveBeenCalledWith(
|
|
'<main><h1>live-dashboard preview</h1></main>',
|
|
'live-dashboard',
|
|
);
|
|
|
|
fireEvent.click(shareButton);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /Export as standalone HTML/i }));
|
|
expect(exportAsHtml).toHaveBeenCalledWith(
|
|
'<main><h1>live-dashboard preview</h1></main>',
|
|
'live-dashboard',
|
|
);
|
|
});
|
|
|
|
it('opens the full preview modal and exercises its toolbar actions', async () => {
|
|
renderExamples();
|
|
|
|
const card = screen.getByTestId('example-card-live-dashboard');
|
|
fireEvent.click(within(card).getByRole('button', { name: /Open preview/ }));
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: 'live-dashboard preview' });
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle('live-dashboard Preview')).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(within(dialog).getByRole('button', { name: /Fullscreen/i }));
|
|
const modal = dialog.querySelector('.ds-modal') as HTMLElement;
|
|
expect(modal.classList.contains('ds-modal-fullscreen')).toBe(true);
|
|
expect(within(dialog).getByRole('button', { name: /Exit/i })).toBeTruthy();
|
|
|
|
fireEvent.keyDown(document, { key: 'Escape' });
|
|
expect(modal.classList.contains('ds-modal-fullscreen')).toBe(false);
|
|
expect(screen.getByRole('dialog', { name: 'live-dashboard preview' })).toBeTruthy();
|
|
|
|
fireEvent.click(within(dialog).getByRole('button', { name: /Fullscreen/i }));
|
|
expect(modal.classList.contains('ds-modal-fullscreen')).toBe(true);
|
|
fireEvent.click(within(dialog).getByRole('button', { name: /Exit/i }));
|
|
expect(modal.classList.contains('ds-modal-fullscreen')).toBe(false);
|
|
expect(within(dialog).getByRole('button', { name: /Fullscreen/i })).toBeTruthy();
|
|
|
|
const shareButton = within(dialog).getByRole('button', { name: /Share/i });
|
|
fireEvent.click(shareButton);
|
|
fireEvent.click(within(dialog).getByRole('menuitem', { name: /Export as PDF/i }));
|
|
expect(exportAsPdf).toHaveBeenCalledWith(
|
|
'<main><h1>live-dashboard preview</h1></main>',
|
|
'live-dashboard',
|
|
{ deck: false },
|
|
);
|
|
|
|
fireEvent.click(shareButton);
|
|
fireEvent.click(within(dialog).getByRole('menuitem', { name: /Download as \.zip/i }));
|
|
expect(exportAsZip).toHaveBeenCalledWith(
|
|
'<main><h1>live-dashboard preview</h1></main>',
|
|
'live-dashboard',
|
|
);
|
|
|
|
fireEvent.click(shareButton);
|
|
fireEvent.click(within(dialog).getByRole('menuitem', { name: /Export as standalone HTML/i }));
|
|
expect(exportAsHtml).toHaveBeenCalledWith(
|
|
'<main><h1>live-dashboard preview</h1></main>',
|
|
'live-dashboard',
|
|
);
|
|
|
|
fireEvent.click(shareButton);
|
|
fireEvent.click(within(dialog).getByRole('menuitem', { name: /Open in new tab/i }));
|
|
expect(openSandboxedPreviewInNewTab).toHaveBeenCalledWith(
|
|
'<main><h1>live-dashboard preview</h1></main>',
|
|
'live-dashboard',
|
|
{ deck: false },
|
|
);
|
|
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }));
|
|
expect(screen.queryByRole('dialog', { name: 'live-dashboard preview' })).toBeNull();
|
|
});
|
|
});
|