diff --git a/README.md b/README.md index b8c8d51..a1c5989 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,46 @@ await describePackagesApiSnapshots({ Run `vitest -u` to update snapshots when you intentionally change the API. +#### Per-entry hooks + +Three hooks let you intervene per entry point, at increasing depth. They live on `ApiSnapshotOptions`, so they work with `snapshotApiPerEntry`, `describePackagesApiSnapshots`, `generateApiSnapshot`, the CLI helpers, and the rolldown plugin alike. + +`entryFilter` skips entries entirely — useful when a package exports large default-data objects (themes, color palettes) that are low-signal for an API guard: + +```ts +import { describePackagesApiSnapshots } from 'tsnapi/vitest' + +await describePackagesApiSnapshots({ + entryFilter: ({ packageName, entryName }) => + entryName !== './theme' && entryName !== './colors', +}) +``` + +`transformEntries` modifies the structural representation before it is serialized: each snapshot surface is a list of entries (`{ name, kind, text }` — one per export or referenced declaration). Mutate the array in place, or return a replacement array: + +```ts +await describePackagesApiSnapshots({ + transformEntries(entries, { entryName, surface }) { + for (const entry of entries) { + // Collapse a huge data export to an opaque declaration, + // while still guarding everything else + if (entry.name === 'theme' && surface === 'dts') + entry.text = 'export declare const theme: Record' + } + // or filter: return entries.filter(e => e.kind !== 'variable') + }, +}) +``` + +`transformSnapshot` rewrites the final snapshot string (header excluded) before it is written or compared: + +```ts +await describePackagesApiSnapshots({ + transformSnapshot: ({ entryName, surface, content }) => + surface === 'dts' ? content.replaceAll('\u00A0', ' ') : null, // null keeps content unchanged +}) +``` + #### Low-level You can also use `generateApiSnapshot` directly with Vitest's built-in snapshot system: @@ -379,9 +419,17 @@ interface ApiSnapshotOptions { * @default false */ allowBreaking?: boolean + /** Skip entry points: return false to skip. */ + entryFilter?: (ctx: SnapshotEntryContext) => boolean | void + /** Modify the structural entries of a surface before serialization. */ + transformEntries?: (entries: Entry[], ctx: TransformEntriesContext) => Entry[] | null | void + /** Rewrite snapshot content before write/compare. */ + transformSnapshot?: (ctx: TransformSnapshotContext) => string | null | void } ``` +See [Per-entry hooks](#per-entry-hooks) for details on the three hooks. + ### `typeWidening` When `typeWidening` is `true` (default), literal values are widened to hide implementation details: diff --git a/__snapshots__/tsnapi/index.snapshot.d.ts b/__snapshots__/tsnapi/index.snapshot.d.ts index 49163f5..3fe5af4 100644 --- a/__snapshots__/tsnapi/index.snapshot.d.ts +++ b/__snapshots__/tsnapi/index.snapshot.d.ts @@ -13,6 +13,9 @@ export interface ApiSnapshotOptions { referenceTracingDepth?: number; update?: boolean; allowBreaking?: boolean; + entryFilter?: (_: SnapshotEntryContext) => boolean | void; + transformEntries?: (_: Entry[], _: TransformEntriesContext) => Entry[] | null | void; + transformSnapshot?: (_: TransformSnapshotContext) => string | null | void; } export interface BreakingChange { entryName: string; @@ -21,11 +24,20 @@ export interface BreakingChange { widened: string[]; added: string[]; } +export interface Entry { + name: string; + text: string; + kind: EntryKind; +} export interface ResolvedEntry { name: string; runtime: string | null; dts: string | null; } +export interface SnapshotEntryContext { + packageName: string; + entryName: string; +} export interface SnapshotExtensions { runtime: string; dts: string; @@ -49,6 +61,17 @@ export interface SnapshotResult { diff: string | null; breaking: BreakingChange[]; } +export interface TransformEntriesContext extends SnapshotEntryContext { + surface: SnapshotSurface; +} +export interface TransformSnapshotContext extends TransformEntriesContext { + content: string; +} +// #endregion + +// #region Types +export type EntryKind = 'interface' | 'type' | 'enum' | 'class' | 'namespace' | 'function' | 'variable' | 'default' | 're-export' | 'referenced' | 'other'; +export type SnapshotSurface = 'runtime' | 'dts'; // #endregion // #region Functions @@ -82,5 +105,6 @@ interface ExtractOptions { typeWidening?: boolean; categorizedExports?: boolean; referenceTracingDepth?: number; + transformEntries?: (_: Entry[]) => Entry[] | null | void; } // #endregion \ No newline at end of file diff --git a/__snapshots__/tsnapi/vitest.snapshot.d.ts b/__snapshots__/tsnapi/vitest.snapshot.d.ts index 5be1508..c8d6d2a 100644 --- a/__snapshots__/tsnapi/vitest.snapshot.d.ts +++ b/__snapshots__/tsnapi/vitest.snapshot.d.ts @@ -9,6 +9,11 @@ export interface DescribePackagesApiSnapshotsOptions extends SnapshotApiOptions beforeEach?: (_: PackageContext) => void | Promise; afterEach?: (_: PackageContext) => void | Promise; } +export interface Entry { + name: string; + text: string; + kind: EntryKind; +} export interface PackageContext { cwd: string; workspaceRoot: string; @@ -16,9 +21,24 @@ export interface PackageContext { packageName: string; outputDir: string; } -export interface SnapshotApiOptions extends Pick { +export interface SnapshotApiOptions extends Pick { outputDir?: string; } +export interface SnapshotEntryContext { + packageName: string; + entryName: string; +} +export interface TransformEntriesContext extends SnapshotEntryContext { + surface: SnapshotSurface; +} +export interface TransformSnapshotContext extends TransformEntriesContext { + content: string; +} +// #endregion + +// #region Types +export type EntryKind = 'interface' | 'type' | 'enum' | 'class' | 'namespace' | 'function' | 'variable' | 'default' | 're-export' | 'referenced' | 'other'; +export type SnapshotSurface = 'runtime' | 'dts'; // #endregion // #region Functions diff --git a/src/core/extract-dts.ts b/src/core/extract-dts.ts index a39a5ab..1b2cefc 100644 --- a/src/core/extract-dts.ts +++ b/src/core/extract-dts.ts @@ -122,11 +122,12 @@ export async function extractDts(fileName: string, code: string, options?: impor traceReferencedDeclarations(s, program, declMap, entries, referenceTracingDepth) } + const finalEntries = options?.transformEntries?.(entries) ?? entries if (categorized) { - return formatGroupedEntries(entries) + return formatGroupedEntries(finalEntries) } - entries.sort((a, b) => a.name.localeCompare(b.name)) - return `${entries.map(e => e.text).join('\n')}\n` + finalEntries.sort((a, b) => a.name.localeCompare(b.name)) + return `${finalEntries.map(e => e.text).join('\n')}\n` } /** diff --git a/src/core/extract-runtime.ts b/src/core/extract-runtime.ts index 82e037d..ae34cd6 100644 --- a/src/core/extract-runtime.ts +++ b/src/core/extract-runtime.ts @@ -67,6 +67,8 @@ export interface ExtractOptions { typeWidening?: boolean categorizedExports?: boolean referenceTracingDepth?: number + /** Transform the extracted entries before serialization; mutate in place or return a replacement array. */ + transformEntries?: (entries: Entry[]) => Entry[] | null | void } /** Minimal marker prepended above declarations that carry an `@deprecated` tag. */ @@ -218,11 +220,12 @@ export async function extractRuntime(fileName: string, code: string, options?: E applyDeprecated(entries, entriesBefore) } + const finalEntries = options?.transformEntries?.(entries) ?? entries if (categorized) { - return formatGroupedEntries(entries) + return formatGroupedEntries(finalEntries) } - entries.sort((a, b) => a.name.localeCompare(b.name)) - return `${entries.map(e => e.text).join('\n')}\n` + finalEntries.sort((a, b) => a.name.localeCompare(b.name)) + return `${finalEntries.map(e => e.text).join('\n')}\n` } /** diff --git a/src/core/hooks.ts b/src/core/hooks.ts new file mode 100644 index 0000000..6c69fb1 --- /dev/null +++ b/src/core/hooks.ts @@ -0,0 +1,30 @@ +import type { Entry } from './kind.ts' +import type { ApiSnapshotOptions, SnapshotSurface } from './types.ts' + +export interface EntryHooks { + /** Whether an entry passes the user's `entryFilter`. */ + includeEntry: (entryName: string) => boolean + /** Bind the user's `transformEntries` hook to one entry + surface, for `ExtractOptions`. */ + transformEntriesFor: (entryName: string, surface: SnapshotSurface) => ((entries: Entry[]) => Entry[] | null | void) | undefined + /** Apply the user's `transformSnapshot` hook to generated content. */ + transformSnapshot: (entryName: string, surface: SnapshotSurface, content: string) => string +} + +/** + * Bind the per-entry hooks from {@link ApiSnapshotOptions} to a package, so + * every integration (core, Vitest, rolldown) applies them identically. + */ +export function createEntryHooks( + packageName: string, + options?: Pick, +): EntryHooks { + const { entryFilter, transformEntries, transformSnapshot } = options ?? {} + return { + includeEntry: entryName => entryFilter?.({ packageName, entryName }) !== false, + transformEntriesFor: (entryName, surface) => transformEntries + ? entries => transformEntries(entries, { packageName, entryName, surface }) + : undefined, + transformSnapshot: (entryName, surface, content) => + transformSnapshot?.({ packageName, entryName, surface, content }) ?? content, + } +} diff --git a/src/core/index.ts b/src/core/index.ts index 80972ec..642c273 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -7,6 +7,7 @@ import { hasArgvFlag } from './argv.ts' import { analyzeApiChanges, formatBreakingChanges, isBreakingChange } from './breaking.ts' import { extractDts } from './extract-dts.ts' import { extractRuntime } from './extract-runtime.ts' +import { createEntryHooks } from './hooks.ts' import { resolvePackageEntries } from './resolve.ts' import { compareSnapshots, @@ -20,14 +21,16 @@ export type { BreakingChange } from './breaking.ts' export { analyzeApiChanges, formatBreakingChanges, isBreakingChange } from './breaking.ts' export { extractDts } from './extract-dts.ts' export { extractRuntime } from './extract-runtime.ts' -export type { EntryKind } from './kind.ts' +export type { EntryHooks } from './hooks.ts' +export { createEntryHooks } from './hooks.ts' +export type { Entry, EntryKind } from './kind.ts' export { KIND_LABELS, KIND_ORDER } from './kind.ts' export type { DiffMember, DiffStatus, Member } from './members.ts' export { diffMembers, displayName, parseMembers } from './members.ts' export { resolvePackageEntries, resolvePackageEntriesSync } from './resolve.ts' export type { SnapshotExtensions, SnapshotFile, SnapshotMismatch } from './snapshot.ts' export { compareSnapshots, formatMismatchError, generateHeader, readSnapshot, stripHeader, writeSnapshot } from './snapshot.ts' -export type { ApiSnapshotOptions, ResolvedEntry, SnapshotResult } from './types.ts' +export type { ApiSnapshotOptions, ResolvedEntry, SnapshotEntryContext, SnapshotResult, SnapshotSurface, TransformEntriesContext, TransformSnapshotContext } from './types.ts' export { discoverPackages, isPrivatePackage, readPackageName, readWorkspacePatterns, resolveWorkspacePackages } from './workspace.ts' async function readPackageName(cwd: string): Promise { @@ -165,20 +168,23 @@ export async function generateApiSnapshot(cwd: string, options?: ApiSnapshotOpti const result: Record = {} const extractOptions = { omitArgumentNames: options?.omitArgumentNames, typeWidening: options?.typeWidening, categorizedExports: options?.categorizedExports, referenceTracingDepth: options?.referenceTracingDepth } const showHeader = options?.header ?? true - const packageName = showHeader ? await readPackageName(cwd) : '' + const packageName = await readPackageName(cwd) + const hooks = createEntryHooks(packageName, options) const chunkSourcesFor = createChunkSourceLoader() for (const entry of entries) { + if (!hooks.includeEntry(entry.name)) + continue const runtime = entry.runtime - ? await extractRuntime(entry.runtime, await readFile(entry.runtime, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.runtime)).runtime }) + ? await extractRuntime(entry.runtime, await readFile(entry.runtime, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.runtime)).runtime, transformEntries: hooks.transformEntriesFor(entry.name, 'runtime') }) : '' const dts = entry.dts - ? await extractDts(entry.dts, await readFile(entry.dts, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.dts)).dts }) + ? await extractDts(entry.dts, await readFile(entry.dts, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.dts)).dts, transformEntries: hooks.transformEntriesFor(entry.name, 'dts') }) : '' const prefix = showHeader ? generateHeader(packageName, entry.name) : '' result[entry.name] = { - runtime: prefix + (runtime.trim() || '/* no exports */'), - dts: prefix + (dts.trim() || '/* no exports */'), + runtime: prefix + hooks.transformSnapshot(entry.name, 'runtime', runtime.trim() || '/* no exports */'), + dts: prefix + hooks.transformSnapshot(entry.name, 'dts', dts.trim() || '/* no exports */'), } } @@ -218,7 +224,8 @@ async function snapshotEntries( const resolvedOutputDir = resolve(cwd, outputDir) const extractOptions = { omitArgumentNames: options?.omitArgumentNames, typeWidening: options?.typeWidening, categorizedExports: options?.categorizedExports, referenceTracingDepth: options?.referenceTracingDepth } const showHeader = options?.header ?? true - const packageName = showHeader ? await readPackageName(cwd) : '' + const packageName = await readPackageName(cwd) + const hooks = createEntryHooks(packageName, options) const chunkSourcesFor = createChunkSourceLoader() const mismatches: SnapshotResult['mismatches'] = [] @@ -226,17 +233,22 @@ async function snapshotEntries( const breaking: SnapshotResult['breaking'] = [] for (const entry of entries) { + if (!hooks.includeEntry(entry.name)) + continue const stem = entryNameToStem(entry.name) const runtime = entry.runtime - ? await extractRuntime(entry.runtime, await readFile(entry.runtime, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.runtime)).runtime }) + ? await extractRuntime(entry.runtime, await readFile(entry.runtime, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.runtime)).runtime, transformEntries: hooks.transformEntriesFor(entry.name, 'runtime') }) : '' const dts = entry.dts - ? await extractDts(entry.dts, await readFile(entry.dts, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.dts)).dts }) + ? await extractDts(entry.dts, await readFile(entry.dts, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.dts)).dts, transformEntries: hooks.transformEntriesFor(entry.name, 'dts') }) : '' const header = showHeader ? generateHeader(packageName, entry.name) : undefined - const current = { runtime, dts } + const current = { + runtime: hooks.transformSnapshot(entry.name, 'runtime', runtime), + dts: hooks.transformSnapshot(entry.name, 'dts', dts), + } const existing = await readSnapshot(resolvedOutputDir, stem, ext) if (!existing) { diff --git a/src/core/types.ts b/src/core/types.ts index a667a67..58b0c65 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,3 +1,26 @@ +import type { Entry } from './kind.ts' + +/** Which snapshot surface a hook is operating on. */ +export type SnapshotSurface = 'runtime' | 'dts' + +/** Identifies the entry point a per-entry hook is running for. */ +export interface SnapshotEntryContext { + /** Package name from `package.json` (`'unknown'` when unavailable). */ + packageName: string + /** Export path of the entry, e.g. `'.'`, `'./utils'`. */ + entryName: string +} + +export interface TransformEntriesContext extends SnapshotEntryContext { + /** Surface being generated. */ + surface: SnapshotSurface +} + +export interface TransformSnapshotContext extends TransformEntriesContext { + /** Generated snapshot content for the surface (header excluded). */ + content: string +} + export interface ApiSnapshotOptions { /** * Snapshot output directory, relative to the project root. @@ -89,6 +112,44 @@ export interface ApiSnapshotOptions { * @default false */ allowBreaking?: boolean + + /** + * Filter entry points before snapshotting. + * Return `false` to skip the entry entirely. Keep it pure — integrations + * may call it more than once per entry (e.g. Vitest filters both at test + * registration and at generation time). + * @example + * ```ts + * entryFilter: ({ entryName }) => entryName !== './theme' + * ``` + */ + entryFilter?: (ctx: SnapshotEntryContext) => boolean | void + + /** + * Transform the structural representation of an entry's exports before it + * is serialized into snapshot text. Runs once per surface with the full + * list of extracted entries (each an export or referenced declaration with + * `name`, `kind`, and rendered `text`). Mutate the array in place, or + * return a replacement array; return `null`/`undefined` to keep it as-is. + * @example + * ```ts + * // Collapse a large data export to an opaque declaration + * transformEntries(entries, { surface }) { + * for (const entry of entries) { + * if (entry.name === 'theme' && surface === 'dts') + * entry.text = 'export declare const theme: Record' + * } + * } + * ``` + */ + transformEntries?: (entries: Entry[], ctx: TransformEntriesContext) => Entry[] | null | void + + /** + * Rewrite snapshot content before it is written or compared. + * Receives the generated content for one surface (header excluded); + * return the replacement string, or `null`/`undefined` to leave it unchanged. + */ + transformSnapshot?: (ctx: TransformSnapshotContext) => string | null | void } export interface SnapshotResult { diff --git a/src/index.ts b/src/index.ts index 0a78afa..b88b1b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,9 +18,15 @@ export { export type { ApiSnapshotOptions, BreakingChange, + Entry, + EntryKind, ResolvedEntry, + SnapshotEntryContext, SnapshotExtensions, SnapshotFile, SnapshotMismatch, SnapshotResult, + SnapshotSurface, + TransformEntriesContext, + TransformSnapshotContext, } from './core/index.ts' diff --git a/src/rolldown.ts b/src/rolldown.ts index 0e0317f..d3d4cbc 100644 --- a/src/rolldown.ts +++ b/src/rolldown.ts @@ -6,7 +6,7 @@ import process from 'node:process' import { analyzeApiChanges, formatBreakingChanges, isBreakingChange } from './core/breaking.ts' import { extractDts } from './core/extract-dts.ts' import { extractRuntime } from './core/extract-runtime.ts' -import { resolveAllowBreaking, resolveUpdateMode } from './core/index.ts' +import { createEntryHooks, resolveAllowBreaking, resolveUpdateMode } from './core/index.ts' import { compareSnapshots, formatMismatchError, @@ -84,26 +84,31 @@ export default function rolldownPlugin(options: ApiSnapshotOptions = {}): { } } - // Read package name for header + // Read package name for headers and hook contexts let packageName = 'unknown' - if (showHeader) { - const pkgPath = join(projectRoot, 'package.json') - try { - await access(pkgPath) - packageName = JSON.parse(await readFile(pkgPath, 'utf-8')).name ?? 'unknown' - } - catch {} + const pkgPath = join(projectRoot, 'package.json') + try { + await access(pkgPath) + packageName = JSON.parse(await readFile(pkgPath, 'utf-8')).name ?? 'unknown' } + catch {} + const hooks = createEntryHooks(packageName, options) const mismatches: SnapshotMismatch[] = [] const breaking: import('./core/breaking.ts').BreakingChange[] = [] for (const [stem, jsChunk] of jsChunks) { + const entryName = stem === 'index' ? '.' : `./${stem}` + if (!hooks.includeEntry(entryName)) + continue const dtsChunk = dtsChunks.get(stem) - const runtime = await extractRuntime(jsChunk.fileName, jsChunk.code, { chunkSources: jsChunkSources, ...extractOptions }) - const dts = dtsChunk ? await extractDts(dtsChunk.fileName, dtsChunk.code, { chunkSources: dtsChunkSources, ...extractOptions }) : '' - const header = showHeader ? generateHeader(packageName, stem === 'index' ? '.' : `./${stem}`) : undefined - const current = { runtime, dts } + const runtime = await extractRuntime(jsChunk.fileName, jsChunk.code, { chunkSources: jsChunkSources, ...extractOptions, transformEntries: hooks.transformEntriesFor(entryName, 'runtime') }) + const dts = dtsChunk ? await extractDts(dtsChunk.fileName, dtsChunk.code, { chunkSources: dtsChunkSources, ...extractOptions, transformEntries: hooks.transformEntriesFor(entryName, 'dts') }) : '' + const header = showHeader ? generateHeader(packageName, entryName) : undefined + const current = { + runtime: hooks.transformSnapshot(entryName, 'runtime', runtime), + dts: hooks.transformSnapshot(entryName, 'dts', dts), + } const existing = await readSnapshot(resolvedOutputDir, stem, ext) if (!existing) { diff --git a/src/vitest.ts b/src/vitest.ts index d0a1a2e..763b75e 100644 --- a/src/vitest.ts +++ b/src/vitest.ts @@ -3,11 +3,25 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, isAbsolute, join, resolve } from 'node:path' import process from 'node:process' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { analyzeApiChanges, formatBreakingChanges, generateApiSnapshot, isBreakingChange, resolveAllowBreaking } from './core/index.ts' +import { analyzeApiChanges, createEntryHooks, formatBreakingChanges, generateApiSnapshot, isBreakingChange, resolveAllowBreaking } from './core/index.ts' import { resolvePackageEntriesSync } from './core/resolve.ts' import { readPackageName, resolveWorkspacePackages } from './core/workspace.ts' -export interface SnapshotApiOptions extends Pick { +export type { Entry, EntryKind } from './core/kind.ts' +export type { SnapshotEntryContext, SnapshotSurface, TransformEntriesContext, TransformSnapshotContext } from './core/types.ts' + +export interface SnapshotApiOptions extends Pick< + ApiSnapshotOptions, + | 'omitArgumentNames' + | 'header' + | 'allowBreaking' + | 'referenceTracingDepth' + | 'typeWidening' + | 'categorizedExports' + | 'entryFilter' + | 'transformEntries' + | 'transformSnapshot' +> { /** * Snapshot output directory, relative to the test file. * @default '__snapshots__/tsnapi' @@ -73,7 +87,8 @@ export function snapshotApiPerEntry(cwd: string, options?: SnapshotApiOptions): const outputDir = options?.outputDir ?? '__snapshots__/tsnapi' const pkgName = readPackageName(cwd) ?? 'unknown' const allowBreaking = resolveAllowBreaking(options?.allowBreaking) - const entries = resolvePackageEntriesSync(cwd) + const hooks = createEntryHooks(pkgName, options) + const entries = resolvePackageEntriesSync(cwd).filter(entry => hooks.includeEntry(entry.name)) if (entries.length === 0) { it.skip('no exports', () => {}) diff --git a/test/__snapshots__/tsnapi-hooks/fixture-sub-exports/index.snapshot.d.ts b/test/__snapshots__/tsnapi-hooks/fixture-sub-exports/index.snapshot.d.ts new file mode 100644 index 0000000..86a6d87 --- /dev/null +++ b/test/__snapshots__/tsnapi-hooks/fixture-sub-exports/index.snapshot.d.ts @@ -0,0 +1,34 @@ +/** + * Generated by tsnapi — public API snapshot of `fixture-sub-exports` + */ +// #region Interfaces +export interface AppOptions { + name: string; + version?: string; + debug?: boolean; +} +export interface Route { + path: string; + handler: () => void; +} +export interface RouterOptions { + prefix?: string; + routes?: Route[]; +} +// #endregion + +// #region Functions +export declare function createApp(_: AppOptions): { + name: string; + version: string; + start: () => void; +}; +export declare function createRouter(_?: RouterOptions): { + add: (_: Route) => void; + match: (_: string) => Route | undefined; +}; +// #endregion + +// #region Variables +export declare const VERSION: string /* semver */; +// #endregion \ No newline at end of file diff --git a/test/__snapshots__/tsnapi-hooks/fixture-sub-exports/index.snapshot.js b/test/__snapshots__/tsnapi-hooks/fixture-sub-exports/index.snapshot.js new file mode 100644 index 0000000..cdfb59a --- /dev/null +++ b/test/__snapshots__/tsnapi-hooks/fixture-sub-exports/index.snapshot.js @@ -0,0 +1,12 @@ +/** + * Generated by tsnapi — public API snapshot of `fixture-sub-exports` + */ +// #region Functions +export function createApp(_) {} +export function createRouter(_) {} +// #endregion + +// #region Variables +export var VERSION /* const */ +// #endregion +/* transformed by hook */ \ No newline at end of file diff --git a/test/hooks.test.ts b/test/hooks.test.ts new file mode 100644 index 0000000..29530b5 --- /dev/null +++ b/test/hooks.test.ts @@ -0,0 +1,110 @@ +import type { SnapshotEntryContext } from '../src/core/types.ts' +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { generateApiSnapshot, snapshotPackage } from '../src/core/index.ts' + +const FIXTURES_DIR = join(import.meta.dirname, 'fixtures') +const SUB_EXPORTS = join(FIXTURES_DIR, 'sub-exports') + +describe('entryFilter', () => { + it('skips entries and receives package/entry context', async () => { + const seen: SnapshotEntryContext[] = [] + const api = await generateApiSnapshot(SUB_EXPORTS, { + entryFilter: (ctx) => { + seen.push({ ...ctx }) + return ctx.entryName !== './utils' + }, + }) + + expect(Object.keys(api)).toEqual(['.']) + expect(seen).toEqual([ + { packageName: 'fixture-sub-exports', entryName: '.' }, + { packageName: 'fixture-sub-exports', entryName: './utils' }, + ]) + }) + + it('keeps entries when the filter returns void', async () => { + const api = await generateApiSnapshot(SUB_EXPORTS, { + entryFilter: () => {}, + }) + expect(Object.keys(api).sort()).toEqual(['.', './utils']) + }) +}) + +describe('transformEntries', () => { + it('supports in-place mutation of the structural entries', async () => { + const api = await generateApiSnapshot(SUB_EXPORTS, { + header: false, + transformEntries: (entries, { entryName, surface }) => { + if (entryName !== '.' || surface !== 'dts') + return + for (const entry of entries) { + if (entry.name === 'VERSION') + entry.text = 'export declare const VERSION: string /* semver */;' + } + }, + }) + + expect(api['.'].dts).toContain('export declare const VERSION: string /* semver */;') + // Other surfaces/entries untouched + expect(api['.'].runtime).toContain('export var VERSION /* const */') + expect(api['./utils'].dts).toContain('export declare function slugify(_: string): string;') + }) + + it('supports returning a replacement array', async () => { + const api = await generateApiSnapshot(SUB_EXPORTS, { + header: false, + transformEntries: entries => entries.filter(entry => entry.kind !== 'variable'), + }) + + expect(api['.'].runtime).not.toContain('VERSION') + expect(api['.'].runtime).not.toContain('// #region Variables') + expect(api['.'].runtime).toContain('export function createApp(_) {}') + expect(api['.'].dts).not.toContain('VERSION') + }) +}) + +describe('transformSnapshot', () => { + it('rewrites content per surface, keeping the header', async () => { + const api = await generateApiSnapshot(SUB_EXPORTS, { + transformSnapshot: ({ entryName, surface, content }) => { + if (entryName === './utils' && surface === 'dts') + return '/* redacted */' + expect(content).toBeTypeOf('string') + return null + }, + }) + + expect(api['./utils'].dts).toBe( + '/**\n * Generated by tsnapi — public API snapshot of `fixture-sub-exports/utils`\n */\n/* redacted */', + ) + expect(api['./utils'].runtime).toContain('export function slugify') + }) +}) + +describe('snapshotPackage with hooks', () => { + let outputDir: string + + afterEach(async () => { + if (outputDir) + await rm(outputDir, { recursive: true, force: true }) + }) + + it('applies entryFilter and transformSnapshot when writing snapshots', async () => { + outputDir = await mkdtemp(join(tmpdir(), 'tsnapi-hooks-')) + const result = await snapshotPackage(SUB_EXPORTS, { + outputDir, + entryFilter: ({ entryName }) => entryName !== './utils', + transformSnapshot: ({ surface, content }) => + surface === 'dts' ? `${content}\n/* transformed */\n` : null, + }) + + expect(result.hasChanges).toBe(false) + const files = await readdir(outputDir) + expect(files.sort()).toEqual(['index.snapshot.d.ts', 'index.snapshot.js']) + const dts = await readFile(join(outputDir, 'index.snapshot.d.ts'), 'utf-8') + expect(dts).toContain('/* transformed */') + }) +}) diff --git a/test/vitest-integration.test.ts b/test/vitest-integration.test.ts index 2ab8080..729dcae 100644 --- a/test/vitest-integration.test.ts +++ b/test/vitest-integration.test.ts @@ -11,3 +11,20 @@ for (const fixture of fixtures) { snapshotApiPerEntry(join(FIXTURES_DIR, fixture)) }) } + +describe('sub-exports with per-entry hooks', () => { + snapshotApiPerEntry(join(FIXTURES_DIR, 'sub-exports'), { + outputDir: '__snapshots__/tsnapi-hooks', + entryFilter: ({ entryName }) => entryName !== './utils', + transformEntries: (entries, { surface }) => { + if (surface !== 'dts') + return + for (const entry of entries) { + if (entry.name === 'VERSION') + entry.text = 'export declare const VERSION: string /* semver */;' + } + }, + transformSnapshot: ({ surface, content }) => + surface === 'runtime' ? `${content}\n/* transformed by hook */` : null, + }) +})