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.
This commit is contained in:
Hashem Aldhaheri 2026-05-25 09:19:01 +04:00 committed by GitHub
parent 0f5ac37c22
commit 5d032aedec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 117 additions and 6 deletions

View file

@ -36,6 +36,7 @@ export function PluginExampleDetail({
const t = useT(); const t = useT();
const [html, setHtml] = useState<string | null | undefined>(undefined); const [html, setHtml] = useState<string | null | undefined>(undefined);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [unavailableKind, setUnavailableKind] = useState<string | null>(null);
const inFlightRef = useRef(false); const inFlightRef = useRef(false);
const load = useCallback(async () => { const load = useCallback(async () => {
@ -44,6 +45,7 @@ export function PluginExampleDetail({
try { try {
setHtml(null); setHtml(null);
setError(null); setError(null);
setUnavailableKind(null);
const result: SkillExampleResult = exampleStem const result: SkillExampleResult = exampleStem
? await fetchPluginExampleHtml(record.id, exampleStem) ? await fetchPluginExampleHtml(record.id, exampleStem)
: await fetchPluginPreviewHtml(record.id); : await fetchPluginPreviewHtml(record.id);
@ -53,9 +55,16 @@ export function PluginExampleDetail({
setError(result.error); setError(result.error);
setHtml(undefined); setHtml(undefined);
} else { } else {
// unavailable: skill declares a non-HTML preview; treat as a // unavailable: the plugin's manifest declares no shipped
// calm empty state rather than an error (see registry.ts §897). // preview entry (or the daemon 404s on its /preview path —
setError(null); // 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); setHtml(undefined);
} }
} finally { } finally {
@ -86,6 +95,7 @@ export function PluginExampleDetail({
label: t('examples.previewLabel'), label: t('examples.previewLabel'),
html, html,
error, error,
unavailable: unavailableKind ? { kind: unavailableKind } : null,
deck: isDeck, deck: isDeck,
}, },
]} ]}

View file

@ -1846,6 +1846,13 @@ export async function fetchDesignSystemShowcase(id: string): Promise<string | nu
// Mirrors fetchSkillExample's discriminated result so the modal can // Mirrors fetchSkillExample's discriminated result so the modal can
// surface a Retry button instead of staying stuck at "Loading…" when // surface a Retry button instead of staying stuck at "Loading…" when
// a plugin ships no preview entry or the asset is missing on disk. // a plugin ships no preview entry or the asset is missing on disk.
//
// 404 is mapped to `unavailable` (mirroring the skill helper's #897
// behavior) because the daemon returns 404 when the manifest's
// `preview.entry` points at a file that doesn't ship — a missing
// asset for an otherwise valid plugin is not an error the user can
// retry their way out of. Surfacing the calm "no shipped preview"
// placeholder is the truthful UX.
export async function fetchPluginPreviewHtml( export async function fetchPluginPreviewHtml(
id: string, id: string,
): Promise<SkillExampleResult> { ): Promise<SkillExampleResult> {
@ -1853,7 +1860,10 @@ export async function fetchPluginPreviewHtml(
const resp = await fetch( const resp = await fetch(
`/api/plugins/${encodeURIComponent(id)}/preview`, `/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() }; return { html: await resp.text() };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'network error'; 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 // 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( export async function fetchPluginExampleHtml(
pluginId: string, pluginId: string,
stem: string, stem: string,
@ -1871,7 +1882,10 @@ export async function fetchPluginExampleHtml(
const resp = await fetch( const resp = await fetch(
`/api/plugins/${encodeURIComponent(pluginId)}/example/${encodeURIComponent(stem)}`, `/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() }; return { html: await resp.text() };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'network error'; const message = err instanceof Error ? err.message : 'network error';

View file

@ -12,6 +12,8 @@ import {
fetchAppVersionInfo, fetchAppVersionInfo,
fetchConnectorDetail, fetchConnectorDetail,
fetchConnectorDiscovery, fetchConnectorDiscovery,
fetchPluginExampleHtml,
fetchPluginPreviewHtml,
fetchProjectDesignSystemPackageAudit, fetchProjectDesignSystemPackageAudit,
fetchProjectFileText, fetchProjectFileText,
fetchSkillExample, 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('<html><body>preview</body></html>', { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchPluginPreviewHtml('example-live-artifact'),
).resolves.toEqual({ html: '<html><body>preview</body></html>' });
});
});
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', () => { describe('fetchProjectFileText', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();