From d703a534cabbf443fcd896f1dc520fa323adaa23 Mon Sep 17 00:00:00 2001 From: sickn33 Date: Thu, 19 Mar 2026 20:06:37 +0100 Subject: [PATCH] fix(web-app): Harden skills loading under base paths --- apps/web-app/index.html | 1 + apps/web-app/public/sitemap.xml | 82 ++++++------ apps/web-app/src/context/SkillContext.tsx | 54 ++++++-- .../context/__tests__/SkillContext.test.tsx | 60 ++++++++- apps/web-app/src/pages/Home.tsx | 14 ++- apps/web-app/src/pages/SkillDetail.tsx | 101 ++++++++++++++- .../web-app/src/pages/__tests__/Home.test.tsx | 30 +++++ .../src/pages/__tests__/SkillDetail.test.tsx | 119 ++++++++++++++++++ 8 files changed, 403 insertions(+), 58 deletions(-) diff --git a/apps/web-app/index.html b/apps/web-app/index.html index a9a3c70f..bd1e36c1 100644 --- a/apps/web-app/index.html +++ b/apps/web-app/index.html @@ -2,6 +2,7 @@ + diff --git a/apps/web-app/public/sitemap.xml b/apps/web-app/public/sitemap.xml index aecf91db..dd058e43 100644 --- a/apps/web-app/public/sitemap.xml +++ b/apps/web-app/public/sitemap.xml @@ -1,247 +1,247 @@ - http://localhost/ + http://localhost/antigravity-awesome-skills/ 2026-03-19 daily 1.0 - http://localhost/skill/astro + http://localhost/antigravity-awesome-skills/skill/astro 2026-03-19 weekly 0.7 - http://localhost/skill/hono + http://localhost/antigravity-awesome-skills/skill/hono 2026-03-19 weekly 0.7 - http://localhost/skill/openclaw-github-repo-commander + http://localhost/antigravity-awesome-skills/skill/openclaw-github-repo-commander 2026-03-19 weekly 0.7 - http://localhost/skill/pydantic-ai + http://localhost/antigravity-awesome-skills/skill/pydantic-ai 2026-03-19 weekly 0.7 - http://localhost/skill/sveltekit + http://localhost/antigravity-awesome-skills/skill/sveltekit 2026-03-19 weekly 0.7 - http://localhost/skill/goldrush-api + http://localhost/antigravity-awesome-skills/skill/goldrush-api 2026-03-19 weekly 0.7 - http://localhost/skill/progressive-web-app + http://localhost/antigravity-awesome-skills/skill/progressive-web-app 2026-03-19 weekly 0.7 - http://localhost/skill/trpc-fullstack + http://localhost/antigravity-awesome-skills/skill/trpc-fullstack 2026-03-19 weekly 0.7 - http://localhost/skill/vibers-code-review + http://localhost/antigravity-awesome-skills/skill/vibers-code-review 2026-03-19 weekly 0.7 - http://localhost/skill/ai-engineering-toolkit + http://localhost/antigravity-awesome-skills/skill/ai-engineering-toolkit 2026-03-19 weekly 0.7 - http://localhost/skill/ai-native-cli + http://localhost/antigravity-awesome-skills/skill/ai-native-cli 2026-03-19 weekly 0.7 - http://localhost/skill/latex-paper-conversion + http://localhost/antigravity-awesome-skills/skill/latex-paper-conversion 2026-03-19 weekly 0.7 - http://localhost/skill/antigravity-skill-orchestrator + http://localhost/antigravity-awesome-skills/skill/antigravity-skill-orchestrator 2026-03-19 weekly 0.7 - http://localhost/skill/k6-load-testing + http://localhost/antigravity-awesome-skills/skill/k6-load-testing 2026-03-19 weekly 0.7 - http://localhost/skill/recallmax + http://localhost/antigravity-awesome-skills/skill/recallmax 2026-03-19 weekly 0.7 - http://localhost/skill/tool-use-guardian + http://localhost/antigravity-awesome-skills/skill/tool-use-guardian 2026-03-19 weekly 0.7 - http://localhost/skill/acceptance-orchestrator + http://localhost/antigravity-awesome-skills/skill/acceptance-orchestrator 2026-03-19 weekly 0.7 - http://localhost/skill/closed-loop-delivery + http://localhost/antigravity-awesome-skills/skill/closed-loop-delivery 2026-03-19 weekly 0.7 - http://localhost/skill/create-issue-gate + http://localhost/antigravity-awesome-skills/skill/create-issue-gate 2026-03-19 weekly 0.7 - http://localhost/skill/electron-development + http://localhost/antigravity-awesome-skills/skill/electron-development 2026-03-19 weekly 0.7 - http://localhost/skill/llm-structured-output + http://localhost/antigravity-awesome-skills/skill/llm-structured-output 2026-03-19 weekly 0.7 - http://localhost/skill/ai-md + http://localhost/antigravity-awesome-skills/skill/ai-md 2026-03-19 weekly 0.7 - http://localhost/skill/explain-like-socrates + http://localhost/antigravity-awesome-skills/skill/explain-like-socrates 2026-03-19 weekly 0.7 - http://localhost/skill/interview-coach + http://localhost/antigravity-awesome-skills/skill/interview-coach 2026-03-19 weekly 0.7 - http://localhost/skill/keyword-extractor + http://localhost/antigravity-awesome-skills/skill/keyword-extractor 2026-03-19 weekly 0.7 - http://localhost/skill/local-llm-expert + http://localhost/antigravity-awesome-skills/skill/local-llm-expert 2026-03-19 weekly 0.7 - http://localhost/skill/skill-check + http://localhost/antigravity-awesome-skills/skill/skill-check 2026-03-19 weekly 0.7 - http://localhost/skill/yes-md + http://localhost/antigravity-awesome-skills/skill/yes-md 2026-03-19 weekly 0.7 - http://localhost/skill/blueprint + http://localhost/antigravity-awesome-skills/skill/blueprint 2026-03-19 weekly 0.7 - http://localhost/skill/lex + http://localhost/antigravity-awesome-skills/skill/lex 2026-03-19 weekly 0.7 - http://localhost/skill/pipecat-friday-agent + http://localhost/antigravity-awesome-skills/skill/pipecat-friday-agent 2026-03-19 weekly 0.7 - http://localhost/skill/progressive-estimation + http://localhost/antigravity-awesome-skills/skill/progressive-estimation 2026-03-19 weekly 0.7 - http://localhost/skill/sankhya-dashboard-html-jsp-custom-best-pratices + http://localhost/antigravity-awesome-skills/skill/sankhya-dashboard-html-jsp-custom-best-pratices 2026-03-19 weekly 0.7 - http://localhost/skill/seek-and-analyze-video + http://localhost/antigravity-awesome-skills/skill/seek-and-analyze-video 2026-03-19 weekly 0.7 - http://localhost/skill/animejs-animation + http://localhost/antigravity-awesome-skills/skill/animejs-animation 2026-03-19 weekly 0.7 - http://localhost/skill/antigravity-design-expert + http://localhost/antigravity-awesome-skills/skill/antigravity-design-expert 2026-03-19 weekly 0.7 - http://localhost/skill/audit-skills + http://localhost/antigravity-awesome-skills/skill/audit-skills 2026-03-19 weekly 0.7 - http://localhost/skill/daily + http://localhost/antigravity-awesome-skills/skill/daily 2026-03-19 weekly 0.7 - http://localhost/skill/design-spells + http://localhost/antigravity-awesome-skills/skill/design-spells 2026-03-19 weekly 0.7 - http://localhost/skill/frontend-slides + http://localhost/antigravity-awesome-skills/skill/frontend-slides 2026-03-19 weekly 0.7 diff --git a/apps/web-app/src/context/SkillContext.tsx b/apps/web-app/src/context/SkillContext.tsx index e0893554..5dd7bb35 100644 --- a/apps/web-app/src/context/SkillContext.tsx +++ b/apps/web-app/src/context/SkillContext.tsx @@ -6,6 +6,7 @@ interface SkillContextType { skills: Skill[]; stars: StarMap; loading: boolean; + error: string | null; refreshSkills: () => Promise; } @@ -13,6 +14,7 @@ interface SkillsIndexUrlInput { baseUrl: string; origin: string; pathname: string; + documentBaseUrl?: string; } const SkillContext = createContext(undefined); @@ -30,37 +32,61 @@ function normalizeBasePath(baseUrl: string): string { return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; } +function appendBackupCandidates(urls: string[]): string[] { + const candidates = new Set(); + + urls.forEach((url) => { + candidates.add(url); + + if (url.endsWith('skills.json')) { + candidates.add(`${url}.backup`); + } + }); + + return Array.from(candidates); +} + export function getSkillsIndexCandidateUrls({ baseUrl, origin, pathname, + documentBaseUrl, }: SkillsIndexUrlInput): string[] { const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`; - const firstSegment = normalizedPathname.split('/').filter(Boolean)[0]; - const rootPath = firstSegment ? `/${firstSegment}/` : '/'; + const baseCandidate = new URL( + 'skills.json', + documentBaseUrl || new URL(normalizeBasePath(baseUrl), origin), + ).href; + const pathSegments = normalizedPathname.split('/').filter(Boolean); + const pathCandidates = pathSegments.map((_, index) => { + const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`; + return `${origin}${prefix}skills.json`; + }); - const candidates = new Set([ + return appendBackupCandidates([ + baseCandidate, new URL('skills.json', new URL(normalizeBasePath(baseUrl), origin)).href, `${origin}/skills.json`, - `${origin}${rootPath}skills.json`, + ...pathCandidates, ]); - - return Array.from(candidates); } export function SkillProvider({ children }: { children: React.ReactNode }) { const [skills, setSkills] = useState([]); const [stars, setStars] = useState({}); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const fetchSkillsAndStars = useCallback(async (silent = false) => { if (!silent) setLoading(true); + setError(null); try { // Fetch skills index const candidateUrls = getSkillsIndexCandidateUrls({ baseUrl: import.meta.env.BASE_URL, origin: window.location.origin, pathname: window.location.pathname, + documentBaseUrl: window.document.baseURI, }); let data: Skill[] | null = null; @@ -73,7 +99,16 @@ export function SkillProvider({ children }: { children: React.ReactNode }) { throw new Error(`Request failed (${res.status}) for ${url}`); } - const parsed = await res.json(); + const rawBody = await res.text(); + let parsed: unknown; + + try { + parsed = JSON.parse(rawBody); + } catch { + const contentType = res.headers.get('content-type') || 'unknown content type'; + throw new Error(`Non-JSON response from ${url} (${contentType})`); + } + if (!Array.isArray(parsed) || parsed.length === 0) { throw new Error(`Invalid or empty payload from ${url}`); } @@ -120,6 +155,8 @@ export function SkillProvider({ children }: { children: React.ReactNode }) { } } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to load the skills catalog.'; + setError(message); console.error('SkillContext: Failed to load skills', err); } finally { if (!silent) setLoading(false); @@ -138,8 +175,9 @@ export function SkillProvider({ children }: { children: React.ReactNode }) { skills, stars, loading, + error, refreshSkills - }), [skills, stars, loading, refreshSkills]); + }), [skills, stars, loading, error, refreshSkills]); return ( diff --git a/apps/web-app/src/context/__tests__/SkillContext.test.tsx b/apps/web-app/src/context/__tests__/SkillContext.test.tsx index ada0e040..689fa497 100644 --- a/apps/web-app/src/context/__tests__/SkillContext.test.tsx +++ b/apps/web-app/src/context/__tests__/SkillContext.test.tsx @@ -30,10 +30,17 @@ describe('getSkillsIndexCandidateUrls', () => { baseUrl: '/antigravity-awesome-skills/', origin: 'https://sickn33.github.io', pathname: '/antigravity-awesome-skills/skill/some-id', + documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/', }), ).toEqual([ 'https://sickn33.github.io/antigravity-awesome-skills/skills.json', + 'https://sickn33.github.io/antigravity-awesome-skills/skills.json.backup', 'https://sickn33.github.io/skills.json', + 'https://sickn33.github.io/skills.json.backup', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json.backup', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json.backup', ]); }); @@ -43,10 +50,13 @@ describe('getSkillsIndexCandidateUrls', () => { baseUrl: './', origin: 'https://sickn33.github.io', pathname: '/antigravity-awesome-skills/', + documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/', }), ).toEqual([ - 'https://sickn33.github.io/skills.json', 'https://sickn33.github.io/antigravity-awesome-skills/skills.json', + 'https://sickn33.github.io/antigravity-awesome-skills/skills.json.backup', + 'https://sickn33.github.io/skills.json', + 'https://sickn33.github.io/skills.json.backup', ]); }); }); @@ -70,13 +80,20 @@ describe('SkillProvider', () => { ]; (global.fetch as Mock) + .mockResolvedValueOnce({ + ok: false, + status: 404, + }) .mockResolvedValueOnce({ ok: false, status: 404, }) .mockResolvedValueOnce({ ok: true, - json: async () => mockSkills, + headers: { + get: () => 'application/json', + }, + text: async () => JSON.stringify(mockSkills), }); render( @@ -94,4 +111,43 @@ describe('SkillProvider', () => { await Promise.resolve(); }); }); + + it('falls back to the bundled backup catalog when the primary index is invalid', async () => { + const mockSkills = [ + { + id: 'skill-backup', + path: 'skills/skill-backup', + category: 'core', + name: 'Backup skill', + description: 'Loaded from backup', + }, + ]; + + (global.fetch as Mock) + .mockResolvedValueOnce({ + ok: true, + headers: { + get: () => 'text/html', + }, + text: async () => '', + }) + .mockResolvedValueOnce({ + ok: true, + headers: { + get: () => 'application/json', + }, + text: async () => JSON.stringify(mockSkills), + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('ready'); + expect(screen.getByTestId('count').textContent).toBe('1'); + }); + }); }); diff --git a/apps/web-app/src/pages/Home.tsx b/apps/web-app/src/pages/Home.tsx index 87555ff2..19b71f4e 100644 --- a/apps/web-app/src/pages/Home.tsx +++ b/apps/web-app/src/pages/Home.tsx @@ -9,7 +9,7 @@ import { usePageMeta } from '../hooks/usePageMeta'; import { APP_HOME_CATALOG_COUNT, buildHomeMeta } from '../utils/seo'; export function Home(): React.ReactElement { - const { skills, stars, loading, refreshSkills } = useSkills(); + const { skills, stars, loading, error, refreshSkills } = useSkills(); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); @@ -235,6 +235,18 @@ export function Home(): React.ReactElement { ))} + ) : error && skills.length === 0 ? ( +
+ +

Unable to load skills

+

{error}

+ +
) : filteredSkills.length === 0 ? (
diff --git a/apps/web-app/src/pages/SkillDetail.tsx b/apps/web-app/src/pages/SkillDetail.tsx index 5f12f0ed..039e1e62 100644 --- a/apps/web-app/src/pages/SkillDetail.tsx +++ b/apps/web-app/src/pages/SkillDetail.tsx @@ -11,6 +11,58 @@ import rehypeHighlight from 'rehype-highlight'; // Lazy load heavy markdown component const Markdown = lazy(() => import('react-markdown')); +interface SkillMarkdownUrlInput { + baseUrl: string; + origin: string; + pathname: string; + documentBaseUrl?: string; + skillPath: string; +} + +function normalizeBasePath(baseUrl: string): string { + const normalizedSegments = baseUrl + .trim() + .split('/') + .filter((segment) => segment.length > 0 && segment !== '.'); + + const normalizedPath = normalizedSegments.length > 0 + ? `/${normalizedSegments.join('/')}` + : '/'; + + return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; +} + +export function getSkillMarkdownCandidateUrls({ + baseUrl, + origin, + pathname, + documentBaseUrl, + skillPath, +}: SkillMarkdownUrlInput): string[] { + const normalizedSkillPath = skillPath + .replace(/^\/+/, '') + .replace(/\/SKILL\.md$/i, ''); + const assetPath = `${normalizedSkillPath}/SKILL.md`; + const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`; + const pathSegments = normalizedPathname.split('/').filter(Boolean); + const pathCandidates = pathSegments.map((_, index) => { + const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`; + return `${origin}${prefix}${assetPath}`; + }); + + return Array.from(new Set([ + new URL(assetPath, documentBaseUrl || new URL(normalizeBasePath(baseUrl), origin)).href, + new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href, + `${origin}/${assetPath}`, + ...pathCandidates, + ])); +} + +function looksLikeHtmlDocument(text: string): boolean { + const trimmed = text.trim().toLowerCase(); + return trimmed.startsWith('(null); const [customContext, setCustomContext] = useState(''); const [commandCopied, setCommandCopied] = useState(false); + const [retryToken, setRetryToken] = useState(0); const installCommand = 'npx antigravity-awesome-skills'; const skill = useMemo(() => skills.find(s => s.id === id), [skills, id]); @@ -99,17 +152,47 @@ export function SkillDetail(): React.ReactElement { const loadMarkdown = async () => { setContentLoading(true); + setError(null); try { const cleanPath = skill.path.startsWith('skills/') ? skill.path.replace('skills/', '') : skill.path; - const base = import.meta.env.BASE_URL; - const mdRes = await fetch(`${base}skills/${cleanPath}/SKILL.md`); - if (!mdRes.ok) throw new Error('Skill file not found'); + const candidateUrls = getSkillMarkdownCandidateUrls({ + baseUrl: import.meta.env.BASE_URL, + origin: window.location.origin, + pathname: window.location.pathname, + documentBaseUrl: window.document.baseURI, + skillPath: `skills/${cleanPath}`, + }); - const text = await mdRes.text(); - setContent(text); + let markdown: string | null = null; + let lastError: Error | null = null; + + for (const url of candidateUrls) { + try { + const mdRes = await fetch(url); + if (!mdRes.ok) { + throw new Error(`Request failed (${mdRes.status}) for ${url}`); + } + + const text = await mdRes.text(); + if (looksLikeHtmlDocument(text)) { + throw new Error(`HTML fallback returned instead of markdown for ${url}`); + } + + markdown = text; + break; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + + if (markdown === null) { + throw lastError || new Error('Skill file not found'); + } + + setContent(markdown); } catch (err) { console.error('Failed to load skill content', err); setError(err instanceof Error ? err.message : 'Could not load skill content.'); @@ -119,7 +202,7 @@ export function SkillDetail(): React.ReactElement { }; loadMarkdown(); - }, [skill, contextLoading]); + }, [skill, contextLoading, retryToken]); const copyToClipboard = () => { if (!skill) return; @@ -175,6 +258,12 @@ export function SkillDetail(): React.ReactElement {

Failed to Load Content

{error || 'Skill details could not be loaded.'}

+ Back to Catalog diff --git a/apps/web-app/src/pages/__tests__/Home.test.tsx b/apps/web-app/src/pages/__tests__/Home.test.tsx index 53777476..b8ed1b54 100644 --- a/apps/web-app/src/pages/__tests__/Home.test.tsx +++ b/apps/web-app/src/pages/__tests__/Home.test.tsx @@ -45,6 +45,7 @@ describe('Home', () => { skills: [], stars: {}, loading: true, + error: null, }); renderWithRouter(, { useProvider: false }); @@ -61,6 +62,7 @@ describe('Home', () => { skills: mockSkills, stars: {}, loading: false, + error: null, }); renderWithRouter(, { useProvider: false }); @@ -80,6 +82,7 @@ describe('Home', () => { skills: mockSkills, stars: {}, loading: false, + error: null, }); renderWithRouter(, { useProvider: false }); @@ -101,6 +104,7 @@ describe('Home', () => { skills: [], stars: {}, loading: false, + error: null, }); renderWithRouter(, { useProvider: false }); @@ -134,6 +138,7 @@ describe('Home', () => { skills: mockSkills, stars: {}, loading: false, + error: null, }); renderWithRouter(, { useProvider: false }); @@ -158,6 +163,7 @@ describe('Home', () => { skills: mockSkills, stars: {}, loading: false, + error: null, }); renderWithRouter(, { useProvider: false }); @@ -182,6 +188,7 @@ describe('Home', () => { skills: mockSkills, stars: { 'skill-1': 5 }, loading: false, + error: null, refreshSkills, }); @@ -201,4 +208,27 @@ describe('Home', () => { }); }); }); + + it('shows a catalog load error instead of a generic empty state', async () => { + const refreshSkills = vi.fn().mockResolvedValue(undefined); + + (useSkills as Mock).mockReturnValue({ + skills: [], + stars: {}, + loading: false, + error: 'Non-JSON response from /skills.json (text/html)', + refreshSkills, + }); + + renderWithRouter(, { useProvider: false }); + + await waitFor(() => { + expect(screen.getByText(/Unable to load skills/i)).toBeInTheDocument(); + expect(screen.getByText(/Non-JSON response/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /Retry loading catalog/i })); + + expect(refreshSkills).toHaveBeenCalled(); + }); }); diff --git a/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx b/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx index e7ad4df6..2dfadd38 100644 --- a/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx +++ b/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx @@ -4,6 +4,7 @@ import { SkillDetail } from '../SkillDetail'; import { renderWithRouter } from '../../utils/testUtils'; import { createMockSkill } from '../../factories/skill'; import { useSkills } from '../../context/SkillContext'; +import { getSkillMarkdownCandidateUrls } from '../SkillDetail'; // Mock the SkillStarButton component vi.mock('../../components/SkillStarButton', () => ({ @@ -32,6 +33,26 @@ describe('SkillDetail', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + window.history.pushState({}, '', '/'); + }); + + describe('Markdown URL resolution', () => { + it('builds stable markdown candidates for gh-pages routes', () => { + expect( + getSkillMarkdownCandidateUrls({ + baseUrl: '/antigravity-awesome-skills/', + origin: 'https://sickn33.github.io', + pathname: '/antigravity-awesome-skills/skill/react-patterns', + documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/', + skillPath: 'skills/react-patterns', + }), + ).toEqual([ + 'https://sickn33.github.io/antigravity-awesome-skills/skills/react-patterns/SKILL.md', + 'https://sickn33.github.io/skills/react-patterns/SKILL.md', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/skills/react-patterns/SKILL.md', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/react-patterns/skills/react-patterns/SKILL.md', + ]); + }); }); describe('Loading state', () => { @@ -109,6 +130,104 @@ describe('SkillDetail', () => { 'content', '@react-patterns | Antigravity Awesome Skills', ); + expect(global.fetch).toHaveBeenCalled(); + }); + }); + + it('falls back to the next markdown candidate when the first response is html', async () => { + const mockSkill = createMockSkill({ + id: 'fallback-skill', + name: 'fallback-skill', + }); + + (useSkills as Mock).mockReturnValue({ + skills: [mockSkill], + stars: {}, + loading: false, + }); + + window.history.pushState({}, '', '/skill/fallback-skill'); + + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + text: async () => '', + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => '# Loaded from fallback', + }); + + renderWithRouter(, { + route: '/skill/fallback-skill', + path: '/skill/:id', + useProvider: false + }); + + await waitFor(() => { + expect(screen.getByTestId('markdown-content')).toHaveTextContent('Loaded from fallback'); + }); + }); + + it('shows a retry action when markdown loading fails', async () => { + const mockSkill = createMockSkill({ + id: 'broken-skill', + name: 'broken-skill', + }); + + (useSkills as Mock).mockReturnValue({ + skills: [mockSkill], + stars: {}, + loading: false, + }); + + window.history.pushState({}, '', '/skill/broken-skill'); + + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '', + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => '# Recovered content', + }); + + renderWithRouter(, { + route: '/skill/broken-skill', + path: '/skill/:id', + useProvider: false + }); + + await waitFor(() => { + expect(screen.getByText(/Failed to Load Content/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /Retry loading content/i })); + + await waitFor(() => { + expect(screen.getByTestId('markdown-content')).toHaveTextContent('Recovered content'); }); });