open-design/apps/web/tests/components/examples-tab-retry.test.tsx
Nagendhra Madishetti 655d561f38
fix(web): show explicit error/retry state when example preview HTML fails to load (#863)
* fix(web): show explicit error/retry state when example preview HTML fails to load

Reporter (#860) saw the example preview modal stuck with the toolbar
buttons greyed out and only restarting the app got back to a usable
state. Lefarcen confirmed the diagnosis: when /api/skills/:id/example
fails, fetchSkillExample returns null, the modal stays at preview.loading
forever, and the share menu's disabled={!activeHtml} guard sits in the
disabled position with no recovery path.

Three changes:

1. fetchSkillExample now returns a discriminated { html } | { error }
   instead of collapsing every failure into null, so callers can tell a
   real fetch failure from a normal load.

2. PreviewView gains an optional error field. When set, PreviewModal
   renders a stacked title/body/Retry affordance instead of the
   indefinite "Loading…" placeholder. Retry re-fires onView so the
   parent can re-run its fetch.

3. ExamplesTab tracks per-skill errors alongside per-skill html, clears
   the in-flight value before each fetch, and wires onView from the
   modal into loadPreview so the Retry button actually retries.

i18n: three new keys (preview.errorTitle, preview.errorBody,
preview.retry), translated across all 17 locales. The locales-aligned
test stays green.

CSS: .ds-modal-error stacks the new content vertically inside the
existing .ds-modal-empty positioning, no other modals affected.

* fix(web): stabilize preview onView and guard parallel preview fetches

Codex caught a real bug in the round-1 fix: the inline
onView={() => loadPreview(...)} prop was recreated on every parent
render, and PreviewModal's mount effect re-fires onView whenever its
identity changes. A persistent fetch failure would update state,
recreate the prop, re-fire the effect, re-run loadPreview, and burn
through the error UI in a flash instead of waiting for a Retry click.

Pin a stable onPreviewView via a useRef-backed callback so the modal
sees a single identity for the lifetime of the panel; loadPreview is
reached through the ref, so its closure refresh on state updates no
longer leaks into the modal's effect dependencies.

While in this surface, also add lefarcen's race guard: a synchronous
inFlightRef Set so two parallel loadPreview calls (e.g. card hover
firing while the modal opens) cannot both pass the cache check before
either setState lands. The first caller adds the id pre-await; the
second sees it and exits early. try/finally clears the entry on both
success and failure paths.

Adds tests/components/preview-modal-error-state.test.tsx covering:
- error UI renders when view.error is set,
- Retry click calls onView with the active view id,
- re-rendering with the same onView identity does not re-fire the
  modal's mount effect (pins the no-auto-retry contract).

* fix(web): close Retry over the active skill id, not the modal-internal view id

mrcfps caught a real regression in round 2: PreviewModal calls
onView(activeId) where activeId is the modal-local view id ('preview'
in this component). The previous round forwarded that argument
straight into loadPreview, so the mount effect and Retry button hit
/api/skills/preview/example instead of /api/skills/{skill-id}/example.
The new error state could not actually recover.

Mirror the active skill id into a ref alongside loadPreviewRef and
have onPreviewView ignore the modal-forwarded argument, fetching the
selected skill via the ref instead. The callback identity stays
stable, so the no-auto-retry contract from round 2 still holds.

Adds tests/components/examples-tab-retry.test.tsx that mounts the
real ExamplesTab, mocks fetchSkillExample to reject, opens the
preview, clicks Retry, and asserts the second call hits the same
skill id (and explicitly never gets called with 'preview').

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-08 11:16:14 +08:00

84 lines
2.6 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#860 (round 3): the modal's
// onView fires with the modal-internal view id ('preview'), not the
// active skill id. The Retry path must close over the selected skill so
// re-fires hit /api/skills/{skill-id}/example, not /api/skills/preview/example.
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>;
const sampleSkill: SkillSummary = {
id: 'live-dashboard',
name: 'Live Dashboard',
description: 'A team dashboard live artifact.',
triggers: ['dashboard'],
mode: 'prototype',
previewType: 'web',
designSystemRequired: false,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: 'Build me a Notion-style team dashboard.',
};
async function flushPromises() {
await act(async () => {
await Promise.resolve();
});
}
describe('ExamplesTab preview retry path (#860)', () => {
beforeEach(() => {
mockedFetch.mockReset();
});
afterEach(() => {
cleanup();
});
it('Retry refetches the active skill, not the modal-internal view id', async () => {
mockedFetch.mockResolvedValue({ error: 'simulated failure' });
render(<ExamplesTab skills={[sampleSkill]} onUsePrompt={() => {}} />);
// Open the preview modal for the sample skill.
const openButtons = screen.getAllByText(/open preview/i);
fireEvent.click(openButtons[0]!);
// Initial fetch on mount.
await flushPromises();
expect(mockedFetch).toHaveBeenCalledTimes(1);
expect(mockedFetch).toHaveBeenLastCalledWith('live-dashboard');
// Error UI replaces the loading placeholder.
expect(screen.getByText("Couldn't load this example.")).toBeTruthy();
const retry = screen.getByRole('button', { name: /retry/i });
// Retry must hit the same skill id, NOT 'preview' (the modal view id).
fireEvent.click(retry);
await flushPromises();
expect(mockedFetch).toHaveBeenCalledTimes(2);
expect(mockedFetch).toHaveBeenLastCalledWith('live-dashboard');
// Defensive: a regression that wires the modal view id back into the
// fetcher would call with 'preview' here.
expect(mockedFetch).not.toHaveBeenCalledWith('preview');
});
});