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
58 changes: 58 additions & 0 deletions src/__tests__/components/ReadmeSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { ReadmeSection } from '@/components/ReadmeSection';

const baseProps = {
ariaLabel: 'Bundle README',
emptyMessage: 'No README is available for this bundle.',
isError: false,
isLoading: false,
testId: 'bundle-detail-readme',
};

describe('ReadmeSection', () => {
it('renders a labelled section with a README heading', () => {
render(<ReadmeSection {...baseProps} readme='# Hello' />);

const section = screen.getByTestId('bundle-detail-readme');
expect(section).toHaveAttribute('aria-label', 'Bundle README');
expect(within(section).getByRole('heading', { level: 2, name: 'README' })).toBeInTheDocument();
});

it('renders the markdown when a README is present', () => {
render(<ReadmeSection {...baseProps} readme={'# Hello\n\n- item one'} />);

const section = screen.getByTestId('bundle-detail-readme');
expect(within(section).getByTestId('markdown-renderer')).toBeInTheDocument();
expect(within(section).getByRole('heading', { level: 1, name: 'Hello' })).toBeInTheDocument();
expect(within(section).getByText('item one')).toBeInTheDocument();
expect(screen.queryByTestId('bundle-detail-readme-missing')).not.toBeInTheDocument();
});

it('shows a loading skeleton while the README is inflight', () => {
render(<ReadmeSection {...baseProps} isLoading readme={undefined} />);

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

it('shows the empty message when the README is missing', () => {
render(<ReadmeSection {...baseProps} readme={null} />);

expect(screen.getByTestId('bundle-detail-readme-missing')).toHaveTextContent(
'No README is available for this bundle.',
);
});

it('treats an empty string as a missing README', () => {
render(<ReadmeSection {...baseProps} readme='' />);

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

it('falls back to the empty message when the fetch failed', () => {
render(<ReadmeSection {...baseProps} isError readme={undefined} />);

expect(screen.getByTestId('bundle-detail-readme-missing')).toBeInTheDocument();
});
});
54 changes: 53 additions & 1 deletion src/__tests__/lib/query-hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiClient } from '@/lib/api-client';

import { useAssetFiles } from '@/hooks/useAssetFiles';
import { useBundleReadme } from '@/hooks/useBundleReadme';
import { useRegistry } from '@/hooks/useRegistry';
import { queryKeys } from '@/lib/query-keys';

import { loadFixtureRegistry } from '../fixtures';
import { apiErrorResponse, jsonResponse, makeTestApiClient, stubFetch } from '../utils/api-stub';
import { apiErrorResponse, jsonResponse, makeTestApiClient, stubFetch, textResponse } from '../utils/api-stub';

// Intercept the session so we can feed the hooks a controlled API client (or null).
const sessionValueMock: {
Expand Down Expand Up @@ -149,3 +150,54 @@ describe('useAssetFiles (TanStack Query)', () => {
expect(client.getQueryData(queryKeys.assetFiles(ref))).toBeDefined();
});
});

describe('useBundleReadme (TanStack Query)', () => {
beforeEach(() => {
sessionValueMock.api = null;
sessionValueMock.token = null;
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it('stays disabled until the registry has resolved a version', async () => {
sessionValueMock.api = makeTestApiClient('tok');
const { calls } = stubFetch(() => textResponse('# Hi'));
const { Wrapper } = makeWrapper();
const { result } = renderHook(() => useBundleReadme({ name: 'qa-bundle', org: 'cupay' }), { wrapper: Wrapper });

await waitFor(() => expect(result.current.fetchStatus).toBe('idle'));
expect(calls).toHaveLength(0);
});

it('fetches the README markdown under the bundleReadme key', async () => {
sessionValueMock.token = 'tok';
sessionValueMock.api = makeTestApiClient('tok');
const { calls } = stubFetch(() => textResponse('# Hi'));

const { client, Wrapper } = makeWrapper();
const ref = { name: 'qa-bundle', org: 'cupay', version: '1.0.0' };
const { result } = renderHook(() => useBundleReadme(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/bundles/qa-bundle/1.0.0/readme?org=cupay');
expect(client.getQueryData(queryKeys.bundleReadme(ref))).toBe('# Hi');
});

it('resolves to null when the bundle has no README', async () => {
sessionValueMock.token = 'tok';
sessionValueMock.api = makeTestApiClient('tok');
stubFetch(() => apiErrorResponse(404, 'not_found', 'No README'));

const { Wrapper } = makeWrapper();
const { result } = renderHook(() => useBundleReadme({ name: 'feature-workflow', version: '1.0.0' }), {
wrapper: Wrapper,
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toBeNull();
});
});
39 changes: 39 additions & 0 deletions src/__tests__/lib/registry-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fetchAssetManifest,
fetchAssetReadme,
fetchBundleManifest,
fetchBundleReadme,
fetchRegistry,
findExistingAsset,
findExistingBundle,
Expand Down Expand Up @@ -241,6 +242,44 @@ describe('registry-client (ATK API-backed)', () => {
});
});

describe('fetchBundleReadme', () => {
it('fetches a global bundle README as text', async () => {
const markdown = '# Quality bundle\n\nBody.';
const { calls } = stubFetch(() => textResponse(markdown));

const result = await fetchBundleReadme({ name: 'quality-bundle', version: '0.3.0' }, { client });

expect(result).toBe(markdown);
expect(calls[0]!.url).toBe(`${API_BASE}/bundles/quality-bundle/0.3.0/readme`);
});

it('passes the org as a query parameter for an org bundle', async () => {
const { calls } = stubFetch(() => textResponse('# QA'));

const result = await fetchBundleReadme({ name: 'qa-bundle', org: 'cupay', version: '1.0.0' }, { client });

expect(result).toBe('# QA');
expect(calls[0]!.url).toBe(`${API_BASE}/bundles/qa-bundle/1.0.0/readme?org=cupay`);
});

it('returns null when the README is missing (HTTP 404)', async () => {
stubFetch(() => apiErrorResponse(404, 'not_found', 'No README'));

const result = await fetchBundleReadme({ name: 'feature-workflow', version: '1.0.0' }, { client });
expect(result).toBeNull();
});

it('propagates transport errors as RegistryFetchError', async () => {
stubFetch(() => {
throw new TypeError('offline');
});

await expect(fetchBundleReadme({ name: 'x', version: '1.0.0' }, { client })).rejects.toBeInstanceOf(
RegistryFetchError,
);
});
});

describe('findExistingBundle', () => {
it('returns the latest version for an existing global bundle and undefined otherwise', () => {
const registry = loadFixtureRegistry();
Expand Down
106 changes: 105 additions & 1 deletion src/__tests__/routes/BundleDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { type ReactNode } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { Bundle, Manifest, Registry } from '@/lib/schemas';

Expand All @@ -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: null,
error: null,
isError: false,
isLoading: false,
isSuccess: true,
...state,
});
}

function setRegistry(state: RegistryQueryShape) {
useRegistryMock.mockReturnValue({
data: undefined,
Expand All @@ -94,8 +108,14 @@ const FULL_BUNDLE: Bundle = {
};

describe('BundleDetailRoute', () => {
beforeEach(() => {
// Most cases don't care about the README; default to "none" so the section renders its fallback.
setReadme({});
});

afterEach(() => {
useBundleManifestMock.mockReset();
useBundleReadmeMock.mockReset();
useRegistryMock.mockReset();
});

Expand Down Expand Up @@ -134,6 +154,86 @@ describe('BundleDetailRoute', () => {
expect(within(setup).getByRole('heading', { level: 2, name: 'Setup' })).toBeInTheDocument();
});

it('renders the bundle README markdown ahead of the setup instructions', () => {
setBundle({ data: FULL_BUNDLE, isSuccess: true });
setRegistry({ data: loadFixtureRegistry(), isSuccess: true });
setReadme({
data: [
'# Feature workflow',
'',
'## Usage',
'',
'- item one',
'- item two',
'',
'| Col A | Col B |',
'| --- | --- |',
'| a | b |',
'',
'```bash',
'atk install feature-workflow',
'```',
'',
'[Docs](https://example.com/docs)',
].join('\n'),
});

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

const readme = screen.getByTestId('bundle-detail-readme');
expect(readme).toHaveAttribute('aria-label', 'Bundle README');
expect(within(readme).getByRole('heading', { level: 2, name: 'README' })).toBeInTheDocument();
expect(within(readme).getByRole('heading', { level: 1, name: 'Feature workflow' })).toBeInTheDocument();
expect(within(readme).getByRole('heading', { level: 2, name: 'Usage' })).toBeInTheDocument();
expect(within(readme).getByText('item one')).toBeInTheDocument();
expect(within(readme).getByRole('table')).toBeInTheDocument();
expect(within(readme).getByText('atk install feature-workflow')).toBeInTheDocument();
expect(within(readme).getByRole('link', { name: 'Docs' })).toHaveAttribute('href', 'https://example.com/docs');
expect(screen.queryByTestId('bundle-detail-readme-missing')).not.toBeInTheDocument();

// README precedes the setup instructions in document order.
const setup = screen.getByTestId('bundle-detail-setup');
expect(readme.compareDocumentPosition(setup) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});

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

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

expect(screen.getByTestId('bundle-detail-readme-missing')).toHaveTextContent(
/no readme is available for this bundle/i,
);
// Setup instructions still render independently of the README.
expect(screen.getByTestId('bundle-detail-setup')).toBeInTheDocument();
});

it('renders a README skeleton without blocking the rest of the page', () => {
setBundle({ data: FULL_BUNDLE, isSuccess: true });
setRegistry({ data: loadFixtureRegistry(), isSuccess: true });
setReadme({ data: undefined, isLoading: true, isSuccess: false });

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

expect(screen.getByRole('status', { name: /loading readme/i })).toBeInTheDocument();
expect(screen.queryByRole('status', { name: /loading bundle manifest/i })).not.toBeInTheDocument();
expect(screen.getByTestId('bundle-detail-metadata')).toBeInTheDocument();
expect(screen.getByTestId('bundle-detail-assets')).toBeInTheDocument();
});

it('treats a README fetch failure as a missing README, not a page error', () => {
setBundle({ data: FULL_BUNDLE, isSuccess: true });
setRegistry({ data: loadFixtureRegistry(), isSuccess: true });
setReadme({ data: undefined, error: new Error('boom'), isError: true, isSuccess: false });

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

expect(screen.getByTestId('bundle-detail-readme-missing')).toBeInTheDocument();
expect(screen.queryByTestId('bundle-detail-error')).not.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 });
Expand Down Expand Up @@ -240,6 +340,10 @@ describe('BundleDetailRoute', () => {
expect(useBundleManifestMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'qa-bundle', org: 'cupay', version: '2.0.0' }),
);
// The README hook receives the same org-scoped, registry-resolved ref.
expect(useBundleReadmeMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'qa-bundle', org: 'cupay', version: '2.0.0' }),
);
expect(screen.getByTestId('bundle-detail-org')).toHaveTextContent('cupay');
});

Expand Down
41 changes: 41 additions & 0 deletions src/components/ReadmeSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { LoadingIndicator } from '@/components/LoadingIndicator';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';

interface ReadmeSectionProps {
/** Accessible name for the section, e.g. `Asset README`. */
ariaLabel: string;
/** Copy shown when the README is absent or failed to load. */
emptyMessage: string;
isError: boolean;
isLoading: boolean;
readme: null | string | undefined;
/** Section testid; the fallback paragraph uses `${testId}-missing`. */
testId: string;
}

/**
* The README block shared by the asset and bundle detail pages: heading,
* loading skeleton, "no README" fallback, and rendered markdown.
*/
export function ReadmeSection({ ariaLabel, emptyMessage, isError, isLoading, readme, testId }: ReadmeSectionProps) {
return (
<section aria-label={ariaLabel} data-testid={testId}>
<h2 className='mb-3 text-lg font-semibold tracking-tight text-foreground'>README</h2>
<ReadmeBody emptyMessage={emptyMessage} isError={isError} isLoading={isLoading} readme={readme} testId={testId} />
</section>
);
}

function ReadmeBody({ emptyMessage, isError, isLoading, readme, testId }: Omit<ReadmeSectionProps, 'ariaLabel'>) {
if (isLoading) {
return <LoadingIndicator label='Loading README…' variant='skeleton' />;
}
if (isError || !readme) {
return (
<p className='text-sm text-muted-foreground' data-testid={`${testId}-missing`}>
{emptyMessage}
</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
Loading
Loading