mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
0f5ac37c22
commit
5d032aedec
3 changed files with 117 additions and 6 deletions
|
|
@ -36,6 +36,7 @@ export function PluginExampleDetail({
|
|||
const t = useT();
|
||||
const [html, setHtml] = useState<string | null | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unavailableKind, setUnavailableKind] = useState<string | null>(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,
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -1846,6 +1846,13 @@ export async function fetchDesignSystemShowcase(id: string): Promise<string | nu
|
|||
// Mirrors fetchSkillExample's discriminated result so the modal can
|
||||
// surface a Retry button instead of staying stuck at "Loading…" when
|
||||
// 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(
|
||||
id: string,
|
||||
): Promise<SkillExampleResult> {
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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('<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', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
|
|||
Loading…
Reference in a new issue