From 5d032aedec82d7465899e6a825133e695d4ca614 Mon Sep 17 00:00:00 2001 From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com> Date: Mon, 25 May 2026 09:19:01 +0400 Subject: [PATCH] fix(web): treat plugin preview 404 as unavailable, matching skill helper (#2840) Bundled plugins whose manifest declares `preview.entry` but ship no example HTML (e.g. example-live-artifact pointing at ./index.html that isn't in the package) made the daemon return 404 on /api/plugins/:id/preview. The web turned that into the generic "Couldn't load this example. The example HTML failed to fetch." error modal, which suggested a transient failure even though Open Design was running fine and the asset was simply absent. Mirror the symmetric treatment fetchSkillExample already has from #897: map 404 -> { unavailable: true, kind: 'html' } in both fetchPluginPreviewHtml and fetchPluginExampleHtml, and forward the typed unavailable view through PluginExampleDetail so PreviewModal renders its calm "no shipped preview" placeholder. Other HTTP failures keep their existing { error: 'HTTP N' } shape so genuine errors still surface a Retry affordance. Red test was added first (registry.test.ts) and confirmed to fail on main with the old "HTTP 404" payload before this commit was applied. --- .../plugin-details/PluginExampleDetail.tsx | 16 +++- apps/web/src/providers/registry.ts | 20 ++++- apps/web/tests/providers/registry.test.ts | 87 +++++++++++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/plugin-details/PluginExampleDetail.tsx b/apps/web/src/components/plugin-details/PluginExampleDetail.tsx index 31a773085..03c5d39ef 100644 --- a/apps/web/src/components/plugin-details/PluginExampleDetail.tsx +++ b/apps/web/src/components/plugin-details/PluginExampleDetail.tsx @@ -36,6 +36,7 @@ export function PluginExampleDetail({ const t = useT(); const [html, setHtml] = useState(undefined); const [error, setError] = useState(null); + const [unavailableKind, setUnavailableKind] = useState(null); const inFlightRef = useRef(false); const load = useCallback(async () => { @@ -44,6 +45,7 @@ export function PluginExampleDetail({ try { setHtml(null); setError(null); + setUnavailableKind(null); const result: SkillExampleResult = exampleStem ? await fetchPluginExampleHtml(record.id, exampleStem) : await fetchPluginPreviewHtml(record.id); @@ -53,9 +55,16 @@ export function PluginExampleDetail({ setError(result.error); setHtml(undefined); } else { - // unavailable: skill declares a non-HTML preview; treat as a - // calm empty state rather than an error (see registry.ts §897). - setError(null); + // unavailable: the plugin's manifest declares no shipped + // preview entry (or the daemon 404s on its /preview path — + // common for bundled plugins like example-live-artifact whose + // manifest references an example file that doesn't ship). + // Forward to PreviewModal as a typed unavailable view so it + // renders the calm "no shipped preview" placeholder instead + // of the misleading "Couldn't load this example." error. The + // skill helper has had this treatment since #897; the plugin + // helper gained it later — keep both consumers in lockstep. + setUnavailableKind(result.kind); setHtml(undefined); } } finally { @@ -86,6 +95,7 @@ export function PluginExampleDetail({ label: t('examples.previewLabel'), html, error, + unavailable: unavailableKind ? { kind: unavailableKind } : null, deck: isDeck, }, ]} diff --git a/apps/web/src/providers/registry.ts b/apps/web/src/providers/registry.ts index d1b5902dc..9bcdc649c 100644 --- a/apps/web/src/providers/registry.ts +++ b/apps/web/src/providers/registry.ts @@ -1846,6 +1846,13 @@ export async function fetchDesignSystemShowcase(id: string): Promise { @@ -1853,7 +1860,10 @@ export async function fetchPluginPreviewHtml( const resp = await fetch( `/api/plugins/${encodeURIComponent(id)}/preview`, ); - if (!resp.ok) return { error: `HTTP ${resp.status}` }; + if (!resp.ok) { + if (resp.status === 404) return { unavailable: true, kind: 'html' }; + return { error: `HTTP ${resp.status}` }; + } return { html: await resp.text() }; } catch (err) { const message = err instanceof Error ? err.message : 'network error'; @@ -1862,7 +1872,8 @@ export async function fetchPluginPreviewHtml( } // Fetch a single example output by stem (matches the basename of the -// `od.useCase.exampleOutputs[].path` minus its extension). +// `od.useCase.exampleOutputs[].path` minus its extension). 404 is +// mapped to `unavailable` for the same reason as fetchPluginPreviewHtml. export async function fetchPluginExampleHtml( pluginId: string, stem: string, @@ -1871,7 +1882,10 @@ export async function fetchPluginExampleHtml( const resp = await fetch( `/api/plugins/${encodeURIComponent(pluginId)}/example/${encodeURIComponent(stem)}`, ); - if (!resp.ok) return { error: `HTTP ${resp.status}` }; + if (!resp.ok) { + if (resp.status === 404) return { unavailable: true, kind: 'html' }; + return { error: `HTTP ${resp.status}` }; + } return { html: await resp.text() }; } catch (err) { const message = err instanceof Error ? err.message : 'network error'; diff --git a/apps/web/tests/providers/registry.test.ts b/apps/web/tests/providers/registry.test.ts index a3b3319f8..509b7554c 100644 --- a/apps/web/tests/providers/registry.test.ts +++ b/apps/web/tests/providers/registry.test.ts @@ -12,6 +12,8 @@ import { fetchAppVersionInfo, fetchConnectorDetail, fetchConnectorDiscovery, + fetchPluginExampleHtml, + fetchPluginPreviewHtml, fetchProjectDesignSystemPackageAudit, fetchProjectFileText, fetchSkillExample, @@ -124,6 +126,91 @@ describe('fetchSkillExample', () => { }); }); +// Plugin previews use the same daemon contract as skill examples (the +// daemon returns 404 when the manifest declares a preview entry but no +// file ships at that path). Skills already map that 404 to +// { unavailable: true, kind: 'html' } per #897 so the modal renders a +// calm "no shipped preview" placeholder instead of "Couldn't load this +// example. The example HTML failed to fetch." Plugins lacked the +// symmetric treatment, so bundled plugins like `example-live-artifact` +// surfaced the misleading error from the Home Community grid even +// though the catalog simply ships no example HTML for that plugin. +describe('fetchPluginPreviewHtml', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('treats missing previews as unavailable instead of an error', async () => { + const fetchMock = vi.fn( + async () => new Response('preview not found', { status: 404 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect( + fetchPluginPreviewHtml('example-live-artifact'), + ).resolves.toEqual({ unavailable: true, kind: 'html' }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/plugins/example-live-artifact/preview', + ); + }); + + it('forwards real preview fetch failures as discriminated errors', async () => { + const fetchMock = vi.fn( + async () => new Response('server error', { status: 500 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect( + fetchPluginPreviewHtml('example-live-artifact'), + ).resolves.toEqual({ error: 'HTTP 500' }); + }); + + it('returns html on success', async () => { + const fetchMock = vi.fn( + async () => + new Response('preview', { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect( + fetchPluginPreviewHtml('example-live-artifact'), + ).resolves.toEqual({ html: 'preview' }); + }); +}); + +describe('fetchPluginExampleHtml', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('treats missing example stems as unavailable instead of an error', async () => { + const fetchMock = vi.fn( + async () => new Response('example not found', { status: 404 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect( + fetchPluginExampleHtml('example-live-artifact', 'index'), + ).resolves.toEqual({ unavailable: true, kind: 'html' }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/plugins/example-live-artifact/example/index', + ); + }); + + it('forwards real example fetch failures as discriminated errors', async () => { + const fetchMock = vi.fn( + async () => new Response('server error', { status: 500 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect( + fetchPluginExampleHtml('example-live-artifact', 'index'), + ).resolves.toEqual({ error: 'HTTP 500' }); + }); +}); + describe('fetchProjectFileText', () => { afterEach(() => { vi.restoreAllMocks();