diff --git a/src/__tests__/lib/query-hooks.test.tsx b/src/__tests__/lib/query-hooks.test.tsx index 1590194..ac436ec 100644 --- a/src/__tests__/lib/query-hooks.test.tsx +++ b/src/__tests__/lib/query-hooks.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ApiClient } from '@/lib/api-client'; import { useAssetFiles } from '@/hooks/useAssetFiles'; +import { useAssetReadme } from '@/hooks/useAssetReadme'; import { useBundleReadme } from '@/hooks/useBundleReadme'; import { useRegistry } from '@/hooks/useRegistry'; import { queryKeys } from '@/lib/query-keys'; @@ -151,6 +152,41 @@ describe('useAssetFiles (TanStack Query)', () => { }); }); +describe('useAssetReadme (TanStack Query)', () => { + beforeEach(() => { + sessionValueMock.api = null; + sessionValueMock.token = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + const ref = { name: 'bare-skill', type: 'skill' as const, version: '0.1.0' }; + + it('fetches the README markdown by default', async () => { + sessionValueMock.api = makeTestApiClient('tok'); + const { calls } = stubFetch(() => textResponse('# Hi')); + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useAssetReadme(ref), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe('# Hi'); + expect(calls[0]!.url).toBe('http://localhost:7071/assets/skill/bare-skill/0.1.0/readme'); + }); + + it('makes no request when disabled by the caller', async () => { + sessionValueMock.api = makeTestApiClient('tok'); + const { calls } = stubFetch(() => textResponse('# Hi')); + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useAssetReadme(ref, { enabled: false }), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.fetchStatus).toBe('idle')); + expect(calls).toHaveLength(0); + }); +}); + describe('useBundleReadme (TanStack Query)', () => { beforeEach(() => { sessionValueMock.api = null; diff --git a/src/__tests__/routes/AssetDetail.test.tsx b/src/__tests__/routes/AssetDetail.test.tsx index 4031de7..effbe9b 100644 --- a/src/__tests__/routes/AssetDetail.test.tsx +++ b/src/__tests__/routes/AssetDetail.test.tsx @@ -270,6 +270,45 @@ describe('AssetDetailRoute', () => { expect(screen.getByTestId('files-card-loading')).toBeInTheDocument(); }); + describe('README request gating', () => { + const listing = (paths: string[]): AssetFileList => ({ + files: paths.map((path) => ({ path, sha: 'x', size: 1 })), + name: 'bare-skill', + type: 'skill', + version: '0.1.0', + }); + + beforeEach(() => { + setManifest({ data: MINIMAL_MANIFEST, isSuccess: true }); + setReadme({ data: undefined, isSuccess: false }); + }); + + it('requests the README when the listing includes README.md', () => { + setFiles({ data: listing(['SKILL.md', 'README.md']), isSuccess: true }); + renderAt('/assets/skill/bare-skill/0.1.0'); + expect(useAssetReadmeMock).toHaveBeenLastCalledWith(expect.anything(), { enabled: true }); + }); + + it('skips the README request and shows the empty state when the listing has no README.md', () => { + setFiles({ data: listing(['SKILL.md', 'manifest.json']), isSuccess: true }); + renderAt('/assets/skill/bare-skill/0.1.0'); + expect(useAssetReadmeMock).toHaveBeenLastCalledWith(expect.anything(), { enabled: false }); + expect(screen.getByText('No README is available for this asset.')).toBeInTheDocument(); + }); + + it('waits for the listing before requesting the README', () => { + setFiles({ isLoading: true }); + renderAt('/assets/skill/bare-skill/0.1.0'); + expect(useAssetReadmeMock).toHaveBeenLastCalledWith(expect.anything(), { enabled: false }); + }); + + it('falls back to requesting the README when the listing fails', () => { + setFiles({ error: new Error('boom'), isError: true }); + renderAt('/assets/skill/bare-skill/0.1.0'); + expect(useAssetReadmeMock).toHaveBeenLastCalledWith(expect.anything(), { enabled: true }); + }); + }); + it('renders every field of a fully populated manifest', () => { setManifest({ data: FULL_MANIFEST, isSuccess: true }); setReadme({ data: '# Ready', isSuccess: true }); diff --git a/src/hooks/useAssetReadme.ts b/src/hooks/useAssetReadme.ts index 127392f..8f9905f 100644 --- a/src/hooks/useAssetReadme.ts +++ b/src/hooks/useAssetReadme.ts @@ -6,11 +6,16 @@ import { type AssetManifestRef, fetchAssetReadme } from '@/lib/registry-client'; /** * Fetch and cache an asset's README.md. Requires an authenticated session. - * Resolves to `null` when the README is missing (HTTP 404). + * Resolves to `null` when the README is missing (HTTP 404). Pass + * `enabled: false` to skip the request when the caller already knows there is + * no README. */ -export function useAssetReadme(ref: Partial): UseQueryResult { +export function useAssetReadme( + ref: Partial, + options: { enabled?: boolean } = {}, +): UseQueryResult { const { api } = useSession(); - const enabled = Boolean(api && ref.name && ref.type && ref.version); + const enabled = Boolean(api && ref.name && ref.type && ref.version) && (options.enabled ?? true); return useQuery({ enabled, diff --git a/src/routes/AssetDetail.tsx b/src/routes/AssetDetail.tsx index 5cd2577..1fc2147 100644 --- a/src/routes/AssetDetail.tsx +++ b/src/routes/AssetDetail.tsx @@ -43,8 +43,11 @@ export function AssetDetailRoute() { const ref = { name, org, type: assetType, version }; const manifestQuery = useAssetManifest(ref); - const readmeQuery = useAssetReadme(ref); const filesQuery = useAssetFiles(ref); + // Ask for the README only once the listing shows one: the API answers 404 otherwise, and those 404s + // count as failed requests in App Insights. If the listing fails, fall back to asking. + const readmeListed = filesQuery.data?.files.some((file) => file.path === 'README.md'); + const readmeQuery = useAssetReadme(ref, { enabled: readmeListed ?? !filesQuery.isLoading }); const registryQuery = useRegistry(); const { download, isDownloading } = useDownloadAsset(); @@ -234,7 +237,7 @@ export function AssetDetailRoute() { ariaLabel='Asset README' emptyMessage='No README is available for this asset.' isError={readmeQuery.isError} - isLoading={readmeQuery.isLoading} + isLoading={filesQuery.isLoading || readmeQuery.isLoading} readme={readmeQuery.data} testId='asset-detail-readme' />