open-design/apps/web/tests/components/examples-tab-preview-dispatch.test.tsx
Sid 5db578123e
fix(web): dispatch Examples preview on od.preview.type (#897) (#1001)
* fix(web): dispatch Examples preview on od.preview.type (#897)

The Examples gallery unconditionally fetched `/api/skills/:id/example`,
and the daemon endpoint only resolves HTML files (`example.html`,
`assets/template.html`, `assets/index.html`, `examples/*.html`). Skills
that declare `od.preview.type: image` (`hatch-pet`) or
`od.preview.type: markdown` (`dcf-valuation`, `last30days`,
`x-research`) ship no such HTML — the fetch returns 404 and the modal
landed on the misleading "Couldn't load this example. The example HTML
failed to fetch." copy.

Dispatch on `previewType` at the data layer (`fetchSkillExample`) and
at the render layer (`PreviewModal`):

- `fetchSkillExample(id, previewType)` short-circuits any non-`html`
  value to `{ unavailable: true, kind }` without firing a network call.
- `PreviewView` grows an optional `unavailable: { kind }` shape; the
  modal renders a calm "no shipped preview" placeholder distinct from
  loading and error states. The Share menu disables (no HTML to export).
- `ExamplesTab` tracks `previewUnavailable` per skill alongside the
  existing `previews` / `previewErrors` maps; the card placeholder
  swaps to "open to learn more" copy so users don't hover waiting for
  a render that won't come.
- New `preview.unavailableTitle` / `preview.unavailableBody` and
  `examples.unavailablePlaceholder` / `examples.shareUnavailable` keys
  shipped across all 17 locales. Body copy uses the raw preview kind
  (`{kind}` placeholder) so future kinds slot in without a copy change.

Tests: registry-level coverage that the dispatch never hits the network
for non-html types; PreviewModal-level coverage that the unavailable
affordance is mutually exclusive with loading/error and disables the
Share menu; ExamplesTab-level coverage that the gallery renders the
unavailable state for image/markdown skills and routes html skills
through the existing fetch path. Updated the existing `#860` retry
regression test for the new two-arg signature.

* fix(web): use neutral noun for preview.unavailableBody copy (#1001 review)

P3 from lefarcen: `'a {kind} document'` reads awkwardly when `{kind}`
is `image` ("a image document") and the article disagreement
undermined the PR body's claim that future kinds slot in without
copy changes. Drop the article and replace `document` with a more
neutral noun (`output` / `resultat` / `产物` / `出力` / etc.) so
every kind reads naturally:

- `produces {kind} output` (English)
- `produit un résultat {kind}` (French)
- `生成 {kind} 产物` (Simplified Chinese)
- … and 14 more

`{kind}` placeholder stays literal in every locale; surrounding
vocabulary for skill / prompt / chat preserved per existing
file conventions.
2026-05-09 21:32:45 +08:00

145 lines
4.9 KiB
TypeScript

// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SkillSummary } from '../../src/types';
// Regression coverage for nexu-io/open-design#897 — the Examples gallery
// dispatches on `od.preview.type` so skills that ship no HTML artifact
// (image / markdown / …) render a calm "no shipped preview" placeholder
// instead of bouncing through a doomed `/api/skills/:id/example` fetch
// and the misleading "Couldn't load this example" error state.
vi.mock('../../src/providers/registry', () => ({
fetchSkillExample: vi.fn(),
}));
import { fetchSkillExample } from '../../src/providers/registry';
import { ExamplesTab } from '../../src/components/ExamplesTab';
const mockedFetch = fetchSkillExample as unknown as ReturnType<typeof vi.fn>;
function makeSkill(overrides: Partial<SkillSummary>): SkillSummary {
return {
id: 'sample',
name: 'Sample',
description: 'A sample skill.',
triggers: [],
mode: 'prototype',
previewType: 'html',
designSystemRequired: false,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: 'Make me something nice.',
aggregatesExamples: false,
...overrides,
};
}
async function flushPromises() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('ExamplesTab preview dispatch (#897)', () => {
beforeEach(() => {
mockedFetch.mockReset();
});
afterEach(() => {
cleanup();
});
it('renders the unavailable affordance for a markdown skill without firing a network call', async () => {
// The dispatch lives in fetchSkillExample (the mocked module), so we
// mirror the production short-circuit shape here. This test pins the
// contract: ExamplesTab routes the result into the modal and the
// user sees the calm placeholder instead of the loading shimmer.
mockedFetch.mockImplementation(async (_id: string, previewType: string) => {
if (previewType !== 'html') {
return { unavailable: true, kind: previewType };
}
return { html: '<html><body>ok</body></html>' };
});
const skill = makeSkill({
id: 'dcf-valuation',
name: 'DCF Valuation',
previewType: 'markdown',
});
render(<ExamplesTab skills={[skill]} onUsePrompt={() => {}} />);
// Open the preview modal.
const openButtons = screen.getAllByText(/open preview/i);
fireEvent.click(openButtons[0]!);
await flushPromises();
// Dispatch routed through fetchSkillExample with the right kind.
expect(mockedFetch).toHaveBeenCalledWith('dcf-valuation', 'markdown');
// Modal renders the unavailable affordance (the testid is the
// contract surface — copy can be tweaked without breaking this).
expect(screen.getByTestId('preview-unavailable')).toBeTruthy();
// Loading + error copy must not appear alongside it.
expect(screen.queryByText(/loading/i)).toBeNull();
expect(screen.queryByText(/couldn't load/i)).toBeNull();
});
it('shows the unavailable card placeholder instead of the loading shimmer', async () => {
mockedFetch.mockImplementation(async (_id: string, previewType: string) => {
if (previewType !== 'html') {
return { unavailable: true, kind: previewType };
}
return { html: '<html><body>ok</body></html>' };
});
const skill = makeSkill({
id: 'hatch-pet',
name: 'Hatch Pet',
previewType: 'image',
});
render(<ExamplesTab skills={[skill]} onUsePrompt={() => {}} />);
// The card's IntersectionObserver hook fires onLoad on first paint
// (jsdom IntersectionObserver fallback short-circuits to true). Wait
// for the dispatched result to land in state.
await flushPromises();
expect(
screen.getByTestId('example-card-unavailable-hatch-pet'),
).toBeTruthy();
// The transient "Loading preview…" shimmer must NOT render for a
// non-html skill — it would never resolve, since no HTML is ever
// fetched.
expect(screen.queryByText(/loading preview/i)).toBeNull();
});
it('still routes html skills through the normal fetch path', async () => {
mockedFetch.mockResolvedValue({ html: '<html><body>ok</body></html>' });
const skill = makeSkill({
id: 'blog-post',
name: 'Blog post',
previewType: 'html',
});
render(<ExamplesTab skills={[skill]} onUsePrompt={() => {}} />);
fireEvent.click(screen.getAllByText(/open preview/i)[0]!);
await flushPromises();
// The dispatch passes the previewType through verbatim — no
// legacy single-arg signature, no implicit defaults.
expect(mockedFetch).toHaveBeenCalledWith('blog-post', 'html');
// Unavailable affordance must NOT show for an html dispatch.
expect(screen.queryByTestId('preview-unavailable')).toBeNull();
});
});