Skip to content
Closed
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
45 changes: 45 additions & 0 deletions src/__tests__/routes/BundleDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { BundleDetailRoute } from '@/routes/BundleDetail';
import { loadFixtureRegistry } from '../fixtures';

const useBundleManifestMock = vi.hoisted(() => vi.fn());
const useBundleReadmeMock = vi.hoisted(() => vi.fn());
const useRegistryMock = vi.hoisted(() => vi.fn());
const useDownloadBundleMock = vi.hoisted(() =>
vi.fn(() => ({ download: vi.fn().mockResolvedValue(undefined), isDownloading: () => false })),
Expand All @@ -29,6 +30,7 @@ const useManifestGraphMock = vi.hoisted(() =>
);

vi.mock('@/hooks/useBundleManifest', () => ({ useBundleManifest: useBundleManifestMock }));
vi.mock('@/hooks/useBundleReadme', () => ({ useBundleReadme: useBundleReadmeMock }));
vi.mock('@/hooks/useRegistry', () => ({ useRegistry: useRegistryMock }));
vi.mock('@/hooks/useDownloadBundle', () => ({ useDownloadBundle: useDownloadBundleMock }));
vi.mock('@/hooks/useManifestGraph', () => ({
Expand All @@ -38,6 +40,7 @@ vi.mock('@/hooks/useManifestGraph', () => ({
}));

type BundleQueryShape = Partial<UseQueryResult<Bundle, Error>>;
type ReadmeQueryShape = Partial<UseQueryResult<null | string, Error>>;
type RegistryQueryShape = Partial<UseQueryResult<Registry, Error>>;

function renderAt(path: string) {
Expand Down Expand Up @@ -68,6 +71,17 @@ function setBundle(state: BundleQueryShape) {
});
}

function setReadme(state: ReadmeQueryShape) {
useBundleReadmeMock.mockReturnValue({
data: undefined,
error: null,
isError: false,
isLoading: false,
isSuccess: false,
...state,
});
}

function setRegistry(state: RegistryQueryShape) {
useRegistryMock.mockReturnValue({
data: undefined,
Expand Down Expand Up @@ -96,6 +110,7 @@ const FULL_BUNDLE: Bundle = {
describe('BundleDetailRoute', () => {
afterEach(() => {
useBundleManifestMock.mockReset();
useBundleReadmeMock.mockReset();
useRegistryMock.mockReset();
});

Expand All @@ -108,6 +123,7 @@ describe('BundleDetailRoute', () => {

it('renders every field of a fully populated bundle manifest', () => {
setBundle({ data: FULL_BUNDLE, isSuccess: true });
setReadme({ data: '# Feature Workflow\n\nUsage notes.', isSuccess: true });
setRegistry({ data: loadFixtureRegistry(), isSuccess: true });

renderAt('/bundles/feature-workflow');
Expand All @@ -132,11 +148,36 @@ describe('BundleDetailRoute', () => {

const setup = screen.getByTestId('bundle-detail-setup');
expect(within(setup).getByRole('heading', { level: 2, name: 'Setup' })).toBeInTheDocument();

const readme = screen.getByTestId('bundle-detail-readme');
expect(within(readme).getByRole('heading', { level: 1, name: 'Feature Workflow' })).toBeInTheDocument();
expect(within(readme).getByText('Usage notes.')).toBeInTheDocument();
});

it('shows a missing-README message when the bundle has none', () => {
setBundle({ data: FULL_BUNDLE, isSuccess: true });
setReadme({ data: null, isSuccess: true });
setRegistry({ data: loadFixtureRegistry(), isSuccess: true });

renderAt('/bundles/feature-workflow');

expect(screen.getByTestId('bundle-detail-readme-missing')).toBeInTheDocument();
});

it('shows a loading skeleton while the README is inflight', () => {
setBundle({ data: FULL_BUNDLE, isSuccess: true });
setReadme({ isLoading: true });
setRegistry({ data: loadFixtureRegistry(), isSuccess: true });

renderAt('/bundles/feature-workflow');

expect(screen.getByRole('status', { name: /loading readme/i })).toBeInTheDocument();
});

it('lists bundle.json plus each member file from the API listing under the bundle group', () => {
setBundle({ data: FULL_BUNDLE, isSuccess: true });
setRegistry({ data: loadFixtureRegistry(), isSuccess: true });
setReadme({ data: null, isSuccess: true });
const validateKey = 'agent:agentic-toolkit:validate:1.1.0';
useManifestGraphMock.mockReturnValue({
error: null,
Expand Down Expand Up @@ -179,6 +220,7 @@ describe('BundleDetailRoute', () => {
const download = vi.fn().mockResolvedValue(undefined);
useDownloadBundleMock.mockReturnValueOnce({ download, isDownloading: () => false });
setBundle({ data: FULL_BUNDLE, isSuccess: true });
setReadme({ data: null, isSuccess: true });
setRegistry({ data: loadFixtureRegistry(), isSuccess: true });

renderAt('/bundles/feature-workflow');
Expand Down Expand Up @@ -231,6 +273,7 @@ describe('BundleDetailRoute', () => {
version: '2.0.0',
});
setBundle({ data: orgBundle, isSuccess: true });
setReadme({ data: null, isSuccess: true });
setRegistry({ data: registry, isSuccess: true });

renderAt('/bundles/cupay/qa-bundle');
Expand Down Expand Up @@ -265,6 +308,7 @@ describe('BundleDetailRoute', () => {
version: '2.0.0',
});
setBundle({ data: orgBundle, isSuccess: true });
setReadme({ data: null, isSuccess: true });
setRegistry({ data: registry, isSuccess: true });

renderAt('/bundles/cupay/qa-bundle');
Expand Down Expand Up @@ -299,6 +343,7 @@ describe('BundleDetailRoute', () => {
version: '2.0.0',
});
setBundle({ data: orgBundle, isSuccess: true });
setReadme({ data: null, isSuccess: true });
setRegistry({ data: registry, isSuccess: true });

renderAt('/bundles/cupay/qa-bundle');
Expand Down
32 changes: 32 additions & 0 deletions src/components/ReadmeView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { LoadingIndicator } from '@/components/LoadingIndicator';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';

/**
* Renders a fetched README as markdown, with loading/missing states.
* Shared between the asset and bundle detail pages so both surfaces stay
* in sync — a bundle's README used to never render at all (only its
* `setupInstructions` did) even though every published bundle has one.
*/
export function ReadmeView({
isError,
isLoading,
missingTestId,
readme,
}: {
isError: boolean;
isLoading: boolean;
missingTestId?: string;
readme: null | string;
}) {
if (isLoading) {
return <LoadingIndicator label='Loading README…' variant='skeleton' />;
}
if (isError || !readme) {
return (
<p className='text-sm text-muted-foreground' data-testid={missingTestId}>
No README is available.
</p>
);
}
return <MarkdownRenderer content={readme} />;
}
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { useAssetFiles } from './useAssetFiles';
export { useAssetManifest } from './useAssetManifest';
export { useAssetReadme } from './useAssetReadme';
export { useBundleManifest } from './useBundleManifest';
export { useBundleReadme } from './useBundleReadme';
export { useDownloadAsset } from './useDownloadAsset';
export { useDownloadBundle } from './useDownloadBundle';
export { useRegistry } from './useRegistry';
Expand Down
28 changes: 28 additions & 0 deletions src/hooks/useBundleReadme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useQuery, type UseQueryResult } from '@tanstack/react-query';

import { useSession } from '@/hooks/useSession';
import { queryKeys } from '@/lib/query-keys';
import { type BundleManifestRef, fetchBundleReadme } from '@/lib/registry-client';

/**
* Fetch and cache a bundle's README.md. Requires an authenticated session.
* Resolves to `null` when the README is missing (HTTP 404) — mirrors
* {@link useAssetReadme}.
*/
export function useBundleReadme(ref: Partial<BundleManifestRef>): UseQueryResult<null | string, Error> {
const { api } = useSession();
const enabled = Boolean(api && ref.name && ref.version);

return useQuery({
enabled,
queryFn: ({ signal }) => {
if (!api) throw new Error('useBundleReadme: no authenticated API client available');
return fetchBundleReadme(ref as BundleManifestRef, { client: api, signal });
},
queryKey: queryKeys.bundleReadme({
name: ref.name ?? '',
org: ref.org,
version: ref.version ?? '',
}),
});
}
2 changes: 2 additions & 0 deletions src/lib/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const queryKeys = {
['registry', 'asset-readme', ref.type, ref.org ?? '', ref.name, ref.version] as const,
bundleManifest: (ref: { name: string; org?: string; version?: string }) =>
['registry', 'bundle-manifest', ref.org ?? '', ref.name, ref.version ?? ''] as const,
bundleReadme: (ref: { name: string; org?: string; version: string }) =>
['registry', 'bundle-readme', ref.org ?? '', ref.name, ref.version] as const,
registry: () => ['registry', 'index'] as const,
session: {
membership: (org: string, username: string) => ['session', 'membership', org, username] as const,
Expand Down
21 changes: 20 additions & 1 deletion src/lib/registry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ZodType } from 'zod';

import { z } from 'zod';

import { getAssetManifest, getAssetReadme, getBundleManifest, getRegistry, listAssetFiles } from './api';
import { getAssetManifest, getAssetReadme, getBundleManifest, getBundleReadme, getRegistry, listAssetFiles } from './api';
import { type ApiClient, ApiRequestError, type ApiResult, unwrap } from './api-client';
import { RegistryFetchError, RegistryNotFoundError, RegistryParseError } from './registry-errors';
import { AssetType, type Bundle, BundleSchema, type Manifest, ManifestSchema } from './schemas';
Expand Down Expand Up @@ -114,6 +114,25 @@ export async function fetchBundleManifest(ref: BundleManifestRef, options: Regis
return parseResponse(unwrapRegistry(result, label), BundleSchema, label);
}

/**
* Fetch a bundle's `README.md` as raw markdown. Returns null when the README
* is absent (HTTP 404) so callers can degrade gracefully — mirrors
* {@link fetchAssetReadme}.
*/
export async function fetchBundleReadme(ref: BundleManifestRef, options: RegistryClientOptions): Promise<null | string> {
const label = `bundle ${ref.org ? `@${ref.org}/` : ''}${ref.name}@${ref.version} README`;
const result = await getBundleReadme({
client: options.client,
parseAs: 'text',
path: { name: ref.name, version: ref.version },
query: orgQuery(ref.org),
signal: options.signal,
});
if (result.response?.status === 404) return null;
const data = unwrapRegistry(result, label);
return typeof data === 'string' ? data : String(data);
}

/** Fetch and validate the registry index (`registry.json`) from the ATK API. */
export async function fetchRegistry(options: RegistryClientOptions): Promise<Registry> {
const label = 'the registry index';
Expand Down
23 changes: 7 additions & 16 deletions src/routes/AssetDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { DownloadMenu } from '@/components/DownloadMenu';
import { EmptyState } from '@/components/EmptyState';
import { type FileGroup, FilesCard } from '@/components/FilesCard';
import { LoadingIndicator } from '@/components/LoadingIndicator';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { PageHeader } from '@/components/PageHeader';
import { ReadmeView } from '@/components/ReadmeView';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAssetFiles } from '@/hooks/useAssetFiles';
Expand Down Expand Up @@ -232,7 +232,12 @@ export function AssetDetailRoute() {

<section aria-label='Asset README' data-testid='asset-detail-readme'>
<h2 className='mb-3 text-lg font-semibold tracking-tight text-foreground'>README</h2>
<ReadmeView isError={readmeQuery.isError} isLoading={readmeQuery.isLoading} readme={readmeQuery.data ?? null} />
<ReadmeView
isError={readmeQuery.isError}
isLoading={readmeQuery.isLoading}
missingTestId='asset-detail-readme-missing'
readme={readmeQuery.data ?? null}
/>
</section>
</div>
);
Expand Down Expand Up @@ -318,20 +323,6 @@ function MetadataRow({ children, label }: { children: React.ReactNode; label: st
);
}

function ReadmeView({ isError, isLoading, readme }: { isError: boolean; isLoading: boolean; readme: null | string }) {
if (isLoading) {
return <LoadingIndicator label='Loading README…' variant='skeleton' />;
}
if (isError || !readme) {
return (
<p className='text-sm text-muted-foreground' data-testid='asset-detail-readme-missing'>
No README is available for this asset.
</p>
);
}
return <MarkdownRenderer content={readme} />;
}

function SecurityBlockView({ security }: { security: NonNullable<Manifest['security']> }) {
return (
<div className='flex flex-col gap-2' data-testid='security-block'>
Expand Down
13 changes: 13 additions & 0 deletions src/routes/BundleDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { type FileGroup, FilesCard } from '@/components/FilesCard';
import { LoadingIndicator } from '@/components/LoadingIndicator';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { PageHeader } from '@/components/PageHeader';
import { ReadmeView } from '@/components/ReadmeView';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useBundleManifest } from '@/hooks/useBundleManifest';
import { useBundleReadme } from '@/hooks/useBundleReadme';
import { useDownloadBundle } from '@/hooks/useDownloadBundle';
import { refKey as manifestRefKey, useManifestGraph } from '@/hooks/useManifestGraph';
import { useRegistry } from '@/hooks/useRegistry';
Expand Down Expand Up @@ -46,6 +48,7 @@ export function BundleDetailRoute() {
[registryQuery.data, bundleName, bundleOrg],
);
const manifestQuery = useBundleManifest({ name: bundleName, org: bundleOrg, version: bundleVersion });
const readmeQuery = useBundleReadme({ name: bundleName, org: bundleOrg, version: bundleVersion });
const { download, isDownloading } = useDownloadBundle();
const displayName = bundleOrg && bundleName ? `@${bundleOrg}/${bundleName}` : bundleName;

Expand Down Expand Up @@ -249,6 +252,16 @@ export function BundleDetailRoute() {
<MarkdownRenderer content={manifest.setupInstructions} />
</section>
) : null}

<section aria-label='Bundle README' data-testid='bundle-detail-readme'>
<h2 className='mb-3 text-lg font-semibold tracking-tight text-foreground'>README</h2>
<ReadmeView
isError={readmeQuery.isError}
isLoading={readmeQuery.isLoading}
missingTestId='bundle-detail-readme-missing'
readme={readmeQuery.data ?? null}
/>
</section>
</div>
);
}
Expand Down
Loading