Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/__tests__/lib/query-hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions src/__tests__/routes/AssetDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
11 changes: 8 additions & 3 deletions src/hooks/useAssetReadme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssetManifestRef>): UseQueryResult<null | string, Error> {
export function useAssetReadme(
ref: Partial<AssetManifestRef>,
options: { enabled?: boolean } = {},
): UseQueryResult<null | string, Error> {
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,
Expand Down
7 changes: 5 additions & 2 deletions src/routes/AssetDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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'
/>
Expand Down
Loading