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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,17 @@ When writing unit tests (utilities, hooks, non-React modules):
- Use coverage when useful: `yarn vitest run --coverage <test-file>`
- Mock external dependencies like file system access and loggers

### Filesystem tests with `memfs`

For unit tests that touch `node:fs` / `node:fs/promises`, use [`memfs`](https://github.com/streamich/memfs) instead of real temp directories or wholesale `node:fs` mocks:

- Import `vol` from `memfs` and call `vol.reset()` in `beforeEach`
- Seed virtual files with `vol.fromNestedJSON({ '/absolute/path/file.json': '...' })` or memfs `writeFile` after redirecting spies
- Use `vi.mock('node:fs/promises', { spy: true })` and, in `beforeEach`, point `mkdir` / `writeFile` / `readFile` at `memfs.fs.promises` (see `code/core/src/shared/open-service/server.test.ts`)
- Assert disk state with `vol.toJSON()` when helpful

Do **not** use `/tmp` paths or replace `node:fs/promises` with a full async factory mock unless a test file already standardizes on the spy redirect pattern above.

## Quality and Logging

After changing files:
Expand Down
25 changes: 18 additions & 7 deletions code/core/src/core-server/build-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,24 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption
const coreServerPublicDir = join(resolvePackageDir('storybook'), 'assets/browser');
effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true, force: true }));

if (getRegisteredServices().length > 0) {
logger.info('Building open services..');
effects.push(writeOpenServiceStaticFiles(options.outputDir));
const hasRegisteredServices = getRegisteredServices().length > 0;
const shouldWriteManifests = !options.ignorePreview && features?.componentsManifest;

if (hasRegisteredServices || shouldWriteManifests) {
effects.push(
(async () => {
if (hasRegisteredServices) {
logger.info('Building open services..');
await writeOpenServiceStaticFiles(options.outputDir);
}

if (shouldWriteManifests) {
// Ref-based components.json reads docgen snapshots from outputDir/services/ — manifests
// must run after open-service static files are written.
await writeManifests(options.outputDir, presets);
}
})()
);
}

let storyIndexGeneratorPromise: Promise<StoryIndexGenerator | undefined> =
Expand All @@ -167,10 +182,6 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption
storyIndexGeneratorPromise as Promise<StoryIndexGenerator>
)
);

if (features?.componentsManifest) {
effects.push(writeManifests(options.outputDir, presets));
}
}

if (!core?.disableProjectJson) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { readFile } from 'node:fs/promises';

import { beforeEach, describe, expect, it, vi } from 'vitest';

import { vol } from 'memfs';

import { docgenManifestRef } from '../../../shared/open-service/services/docgen/paths.ts';
import type { DocgenPayload } from '../../../shared/open-service/services/docgen/types.ts';

import {
COMPONENTS_REF_MANIFEST_VERSION,
buildComponentsRefManifest,
loadDocgenPayloadsFromDisk,
toComponentManifestIndexEntries,
} from './components-ref-manifest.ts';

vi.mock('node:fs/promises', { spy: true });

beforeEach(async () => {
vol.reset();
const memfs = await vi.importActual<typeof import('memfs')>('memfs');

vi.mocked(readFile).mockImplementation(
memfs.fs.promises.readFile as unknown as typeof import('node:fs/promises').readFile
);
});

describe('components-ref-manifest', () => {
it('builds ref-based component manifest entries with nested docgen refs', () => {
expect(
buildComponentsRefManifest({
button: {
id: 'button',
name: 'Button',
docgen: { $ref: docgenManifestRef('button') },
},
card: {
id: 'card',
name: 'Card',
summary: 'A card',
},
})
).toEqual({
v: COMPONENTS_REF_MANIFEST_VERSION,
components: {
button: {
id: 'button',
name: 'Button',
docgen: { $ref: docgenManifestRef('button') },
},
card: {
id: 'card',
name: 'Card',
summary: 'A card',
},
},
});
});

it('carries meta when provided', () => {
expect(
buildComponentsRefManifest({}, { docgen: 'react-component-meta', durationMs: 42 })
).toEqual({
v: COMPONENTS_REF_MANIFEST_VERSION,
components: {},
meta: { docgen: 'react-component-meta', durationMs: 42 },
});
});

it('loads full docgen payloads from built snapshots on disk', async () => {
const payload = {
id: 'button',
name: 'Button',
description: 'A button',
summary: 'Click me',
path: './button.stories.tsx',
jsDocTags: {},
stories: [],
};
vol.fromNestedJSON({
'/output/services/core/docgen/button.json': JSON.stringify({
components: { button: payload },
}),
});

await expect(loadDocgenPayloadsFromDisk('/output', ['button'])).resolves.toEqual({
button: payload,
});
});

it('skips components without a readable snapshot', async () => {
await expect(loadDocgenPayloadsFromDisk('/output', ['button'])).resolves.toEqual({});
});

it('builds index entries with a nested docgen ref when a payload exists', () => {
const payload: DocgenPayload = {
id: 'button',
name: 'Button',
description: 'A button',
summary: 'Click me',
path: './button.stories.tsx',
jsDocTags: {},
stories: [],
};

expect(toComponentManifestIndexEntries(['button'], { button: payload })).toEqual({
button: {
id: 'button',
name: 'Button',
description: 'A button',
summary: 'Click me',
docgen: { $ref: docgenManifestRef('button') },
},
});
});

it('builds a minimal index entry (no docgen ref) when a payload is missing', () => {
expect(toComponentManifestIndexEntries(['button'], {})).toEqual({
button: { id: 'button', name: 'button' },
});
});
});
109 changes: 109 additions & 0 deletions code/core/src/core-server/utils/manifests/components-ref-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { readFile } from 'node:fs/promises';

import {
docgenManifestRef,
docgenStaticStorePath,
} from '../../../shared/open-service/services/docgen/paths.ts';
import type { DocgenPayload } from '../../../shared/open-service/services/docgen/types.ts';
import type { ComponentsManifest } from '../../../types/modules/core-common.ts';

import { join } from 'pathe';

/** Version for ref-based `manifests/components.json` (nested `docgen.$ref` index rows). */
export const COMPONENTS_REF_MANIFEST_VERSION = 1;

export type JsonRef = { $ref: string };

/**
* One component row in the ref-based `manifests/components.json` index.
*
* Summary fields are inlined for cheap listing; full docgen lives behind nested `docgen.$ref`.
*/
export type ComponentManifestIndexEntry = {
id: string;
name: string;
description?: string;
summary?: string;
docgen?: JsonRef;
};

export type ComponentsRefManifest = {
v: number;
components: Record<string, ComponentManifestIndexEntry>;
meta?: ComponentsManifest['meta'];
};

/** Reads one component's docgen payload out of a built snapshot document, if present. */
function readSnapshotPayload(document: unknown, id: string): DocgenPayload | undefined {
const components = (document as { components?: Record<string, unknown> } | null)?.components;
const payload = components?.[id];
return payload !== null && typeof payload === 'object' ? (payload as DocgenPayload) : undefined;
}

/**
* Reads the built docgen service snapshots for the given component ids from disk.
*
* Returns only the ids with a readable payload; missing files and empty snapshots are skipped. The
* same payloads back both `manifests/components.json` and the components HTML debugger, so the static
* build reads docgen once instead of re-extracting it from the live service.
*/
export async function loadDocgenPayloadsFromDisk(
outputDir: string,
componentIds: string[]
): Promise<Record<string, DocgenPayload>> {
const entries = await Promise.all(
componentIds.map(async (id) => {
const snapshotPath = join(outputDir, 'services', ...docgenStaticStorePath(id).split('/'));

try {
const document = JSON.parse(await readFile(snapshotPath, 'utf8')) as unknown;
const payload = readSnapshotPayload(document, id);
return payload ? ([id, payload] as const) : null;
} catch {
return null;
}
})
);

return Object.fromEntries(entries.filter((entry) => entry !== null));
}

/**
* Builds `manifests/components.json` index rows for the given component ids.
*
* Components with a docgen payload get inlined summary fields plus a nested `docgen.$ref`; components
* without one (no readable snapshot) get summary fields only.
*/
export function toComponentManifestIndexEntries(
componentIds: string[],
payloads: Record<string, DocgenPayload>
): Record<string, ComponentManifestIndexEntry> {
const entries: Record<string, ComponentManifestIndexEntry> = {};

for (const id of componentIds) {
const payload = payloads[id];
entries[id] = payload
? {
id: payload.id ?? id,
name: payload.name ?? id,
...(payload.description !== undefined ? { description: payload.description } : {}),
...(payload.summary !== undefined ? { summary: payload.summary } : {}),
docgen: { $ref: docgenManifestRef(id) },
}
: { id, name: id };
}

return entries;
}

/** Builds a ref-based components manifest index (paths relative to `manifests/`). */
export function buildComponentsRefManifest(
components: Record<string, ComponentManifestIndexEntry>,
meta?: ComponentsManifest['meta']
): ComponentsRefManifest {
return {
v: COMPONENTS_REF_MANIFEST_VERSION,
components,
...(meta ? { meta } : {}),
};
}
Loading