import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { compile } from 'vue' import { DiagnosticCategory, ModuleKind, ScriptTarget, transpileModule, } from 'typescript' import { extractComposables } from '../../../scripts/extract-docs-composables' interface FrontendManifest { categories: { components: Array<{ name: string deprecated?: boolean replacement?: string examples?: Array<{ title: string; language: string; code: string }> props?: Array<{ name: string }> }> composables: Array<{ name: string members?: Array<{ name: string; type?: string }> examples?: Array<{ title: string; language: string; code: string }> }> templateExports: Array<{ name: string }> templatePacks: Array<{ name: string }> } exports: Array<{ name: string }> } const TEST_DIR = dirname(fileURLToPath(import.meta.url)) const SDK_ROOT = resolve(TEST_DIR, '../../..') const CORE_USAGE_NAMES = [ 'ArtifactSelectorModal', 'ControlWorkspaceView', 'ExperimentSelectorModal', 'GeneratedPluginView', 'GeneratedResultRenderer', 'JobsStatusTray', 'PluginWorkspaceView', 'useAnalysisArtifacts', 'useAppExperiment', 'usePluginJobs', ] function extractFrontendManifest(): FrontendManifest { const output = execFileSync('bun', ['run', 'scripts/extract-docs.ts'], { cwd: SDK_ROOT, encoding: 'utf8', }) return JSON.parse(output) as FrontendManifest } function parseComponentExports(): string[] { const componentsDir = join(SDK_ROOT, 'src', 'components') const content = readFileSync(join(componentsDir, 'index.ts'), 'utf8') return [...content.matchAll( /export\s+\{\s*(?:\/\*\*.*?\*\/\s*)?default\s+as\s+(\w+)(?:\s*,\s*[^}]*)?\s*\}\s+from\s+['"]\.\/([\w-]+)\.vue['"]/g, )] .filter((match) => existsSync(join(componentsDir, `${match[2]}.vue`))) .map(match => match[1]) .sort() } function parseRootComponentExports(): string[] { const content = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8') if (/export\s+\*\s+from\s+['"]\.\/components['"]/.test(content)) { return parseComponentExports() } const names = new Set() for (const match of content.matchAll(/export\s+\{([^}]*)\}\s+from\s+['"]\.\/components['"]/g)) { const body = match[1].replace(/\/\/.*$/gm, '') for (const item of body.split(',')) { const spec = item.trim() if (!spec || spec.startsWith('type ')) continue const name = spec.split(/\s+as\s+/, 1)[0].trim() if (/^[A-Z]\w*$/.test(name)) names.add(name) } } return [...names].sort() } function parseDeprecatedComponentExports(): Record { const componentsDir = join(SDK_ROOT, 'src', 'components') const content = readFileSync(join(componentsDir, 'index.ts'), 'utf8') const deprecated = new Map() let pendingJsdoc = '' let jsdocLines: string[] = [] let inJsdoc = false for (const line of content.split('\n')) { const trimmed = line.trim() if (inJsdoc) { jsdocLines.push(line) if (trimmed.includes('*/')) { pendingJsdoc = jsdocLines.join('\n') jsdocLines = [] inJsdoc = false } continue } if (trimmed.startsWith('/**')) { jsdocLines = [line] if (trimmed.includes('*/')) { pendingJsdoc = jsdocLines.join('\n') jsdocLines = [] } else { inJsdoc = true } continue } const exportMatch = line.match( /export\s+\{\s*(?:\/\*\*.*?\*\/\s*)?default\s+as\s+(\w+)(?:\s*,\s*[^}]*)?\s*\}\s+from\s+['"]\.\/([\w-]+)\.vue['"]/, ) if (!exportMatch) continue if (/@deprecated\b/i.test(pendingJsdoc)) { const replacement = pendingJsdoc.match( /@deprecated[\s\S]*?\b(?:use|prefer)\s+`?([A-Za-z_][A-Za-z0-9_]*)\(?\)?`?\s+instead/i, )?.[1] ?? '' deprecated.set(exportMatch[1], replacement) } pendingJsdoc = '' } return Object.fromEntries([...deprecated.entries()].sort(([a], [b]) => a.localeCompare(b))) } function parseDocumentedComposableExports(): string[] { const scriptContent = readFileSync(join(SDK_ROOT, 'scripts', 'extract-docs-composables.ts'), 'utf8') const helperNames = new Set( [...(scriptContent.match( /const DOCUMENTED_COMPOSABLE_EXPORTS = new Set\(\[([\s\S]*?)\]\);/, )?.[1] ?? '').matchAll(/"(\w+)"/g)].map(match => match[1]), ) const indexContent = readFileSync(join(SDK_ROOT, 'src', 'composables', 'index.ts'), 'utf8') const names: string[] = [] for (const match of indexContent.matchAll(/export\s+\{([\s\S]*?)\}\s+from\s+['"]\.\/[\w-]+['"]/g)) { for (const item of match[1].split(',')) { const spec = item.trim() if (!spec || spec.startsWith('type ')) continue const name = spec.split(/\s+as\s+/, 1)[0].trim() if (name.startsWith('use') || helperNames.has(name)) { names.push(name) } } } return [...new Set(names)].sort() } function parseRootComposableExports(): string[] { const rootContent = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8') const composableExports = parseDocumentedComposableExports() if (/export\s+\*\s+from\s+['"]\.\/composables['"]/.test(rootContent)) { return composableExports } const names: string[] = [] for (const match of rootContent.matchAll(/export\s+\{([^}]*)\}\s+from\s+['"]\.\/composables['"]/g)) { for (const item of match[1].split(',')) { const spec = item.trim() if (!spec || spec.startsWith('type ')) continue const name = spec.split(/\s+as\s+/, 1)[0].trim() if (name) names.push(name) } } return [...new Set(names)].sort() } function parseTemplateExports(): string[] { const indexContent = readFileSync(join(SDK_ROOT, 'src', 'templates', 'index.ts'), 'utf8') const names = new Set() for (const match of indexContent.matchAll(/export\s+(?:type\s+)?\{(?[\s\S]*?)\}\s+from\s+['"][^'"]+['"]/g)) { const body = match.groups?.body ?? '' for (const item of body.split(',')) { const spec = item.trim() if (!spec) continue const normalized = spec.startsWith('type ') ? spec.slice(5).trim() : spec const name = normalized.split(/\s+as\s+/, 1)[0].trim() if (/^[A-Za-z]\w*$/.test(name)) names.add(name) } } return [...names].sort() } function missingFromManifest(expected: string[], actual: string[]): string[] { const actualNames = new Set(actual) return expected.filter(name => !actualNames.has(name)) } describe('frontend docs catalog coverage', () => { it('documents every public component export', () => { const manifest = extractFrontendManifest() const publicComponents = parseComponentExports() const documentedComponents = manifest.categories.components.map(item => item.name).sort() expect(missingFromManifest(publicComponents, documentedComponents)).toEqual([]) expect(missingFromManifest(documentedComponents, publicComponents)).toEqual([]) }) it('keeps source-owned examples for the core frontend documentation contract', () => { const manifest = extractFrontendManifest() const documented = [ ...manifest.categories.components, ...manifest.categories.composables, ] const byName = new Map(documented.map(item => [item.name, item])) expect(CORE_USAGE_NAMES.filter(name => !byName.get(name)?.examples?.length)).toEqual([]) }) it('keeps core frontend examples syntactically valid', () => { const manifest = extractFrontendManifest() const documented = [ ...manifest.categories.components, ...manifest.categories.composables, ] const byName = new Map(documented.map(item => [item.name, item])) const invalid: string[] = [] for (const name of CORE_USAGE_NAMES) { for (const example of byName.get(name)?.examples ?? []) { if (example.language === 'vue') { try { compile(example.code) } catch { invalid.push(name) } continue } const result = transpileModule(example.code, { compilerOptions: { module: ModuleKind.ESNext, target: ScriptTarget.ESNext, }, reportDiagnostics: true, }) if (result.diagnostics?.some(diagnostic => diagnostic.category === DiagnosticCategory.Error)) { invalid.push(name) } } } expect(invalid).toEqual([]) }) it('documents required props for generated high-level components', () => { const manifest = extractFrontendManifest() const byName = new Map(manifest.categories.components.map(item => [item.name, item])) expect({ GeneratedPluginView: byName.get('GeneratedPluginView')?.props?.map(prop => prop.name), GeneratedResultRenderer: byName.get('GeneratedResultRenderer')?.props?.map(prop => prop.name), }).toEqual({ GeneratedPluginView: ['baseUrl', 'showStandaloneLabel', 'transport'], GeneratedResultRenderer: ['result'], }) }) it('documents the source and MINT 1.1 JobsStatusTray props', () => { const manifest = extractFrontendManifest() const jobsStatusTray = manifest.categories.components.find(item => item.name === 'JobsStatusTray') expect(jobsStatusTray?.props?.map(prop => prop.name).sort()).toEqual([ 'adapter', 'eventStream', 'jobs', 'loadOnMount', 'source', 'teleportTo', 'title', ]) }) it('re-exports every public component from the root SDK entrypoint', () => { const publicComponents = parseComponentExports() const rootComponents = parseRootComponentExports() expect(missingFromManifest(publicComponents, rootComponents)).toEqual([]) }) it('marks every deprecated public component export with its replacement', () => { const manifest = extractFrontendManifest() const expected = parseDeprecatedComponentExports() const documented = Object.fromEntries( manifest.categories.components .filter(item => item.deprecated === true) .map(item => [item.name, item.replacement ?? '']) .sort(([a], [b]) => a.localeCompare(b)), ) expect(documented).toEqual(expected) }) it('keeps retired compatibility APIs out of public SDK entrypoints', () => { const rootIndex = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8') const componentsIndex = readFileSync(join(SDK_ROOT, 'src', 'components', 'index.ts'), 'utf8') const composablesIndex = readFileSync(join(SDK_ROOT, 'src', 'composables', 'index.ts'), 'utf8') const composableExports = composablesIndex.replace(/from\s+['"][^'"]+['"]/g, '') const retiredNames = [ 'ToastNotification', 'SettingsButton', 'GroupingModal', 'FormSection', 'FormFieldRenderer', 'AppPageSelector', 'AppPillNav', 'usePluginApi', 'UsePluginApiOptions', 'usePluginConfig', 'UsePluginConfigReturn', 'ColorSlider', 'DropdownButton', 'InstrumentAlertLog', 'InstrumentStatusCard', 'LcmsSequenceTable', 'useAsync', 'useCommandHistory', 'useOptimisticMutation', 'useResourceCrud', 'useWellPainting', 'useWellPlateAdapter', 'useWellPlateValidation', ] for (const name of retiredNames) { expect(rootIndex).not.toMatch(new RegExp(`\\b${name}\\b`)) expect(componentsIndex).not.toMatch(new RegExp(`\\b${name}\\b`)) expect(composableExports).not.toMatch(new RegExp(`\\b${name}\\b`)) } }) it('keeps the MINT 1.1 composable compatibility exports', () => { const composablesIndex = readFileSync(join(SDK_ROOT, 'src', 'composables', 'index.ts'), 'utf8') const expected = [ 'useJobsStatusTray', 'UseJobsStatusTrayOptions', 'UseJobsStatusTrayReturn', 'JobsStatusTrayAdapter', 'JobStreamMessage', 'usePluginClient', ] expect(expected.filter(name => !new RegExp(`\\b${name}\\b`).test(composablesIndex))).toEqual([]) }) it('documents every public composable/helper export used by docs and doctor', () => { const manifest = extractFrontendManifest() const publicComposables = parseDocumentedComposableExports() const documentedComposables = manifest.categories.composables.map(item => item.name).sort() expect(missingFromManifest(publicComposables, documentedComposables)).toEqual([]) expect(missingFromManifest(documentedComposables, publicComposables)).toEqual([]) }) it('documents generated component binding helpers for control models', () => { const manifest = extractFrontendManifest() const documentedComposables = new Set(manifest.categories.composables.map(item => item.name)) expect(documentedComposables.has('defineControlComponentBindings')).toBe(true) expect(documentedComposables.has('controlValuesToComponentBindings')).toBe(true) expect(documentedComposables.has('controlValuesToComponentBindingsById')).toBe(true) expect(documentedComposables.has('defineWellPlateDoseComponentBindings')).toBe(true) }) it('re-exports every documented composable/helper from the root SDK entrypoint', () => { const publicComposables = parseDocumentedComposableExports() const rootComposables = parseRootComposableExports() expect(missingFromManifest(publicComposables, rootComposables)).toEqual([]) }) it('re-exports the public types barrel from the root SDK entrypoint', () => { const rootIndex = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8') expect(rootIndex).toMatch(/export\s+type\s+\*\s+from\s+['"]\.\/types['"]/) }) it('re-exports the public stores barrel from the root SDK entrypoint', () => { const rootIndex = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8') expect(rootIndex).toMatch(/export\s+\*\s+from\s+['"]\.\/stores['"]/) }) it('re-exports the public templates barrel from the root SDK entrypoint', () => { const rootIndex = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8') expect(rootIndex).toMatch(/export\s+\*\s+from\s+['"]\.\/templates['"]/) }) it('expands root barrel exports into the docs manifest', () => { const manifest = extractFrontendManifest() const rootExports = manifest.exports.map(item => item.name) expect(rootExports).toEqual(expect.arrayContaining([ 'BaseButton', 'DoseDesignWorkspaceView', 'WellPlate', 'useForm', 'useReagentSeries', 'ConversionResult', 'FormSectionSchema', 'createWellPlateScreenCollection', ])) }) it('documents curated biology template packs', () => { const manifest = extractFrontendManifest() expect(manifest.categories.templatePacks.map(item => item.name)).toEqual([ 'cell-culture-screen', 'omics-assay', 'longitudinal-study', 'molecular-assay', ]) }) it('documents every public template subpath export used by doctor', () => { const manifest = extractFrontendManifest() const publicTemplateExports = parseTemplateExports() const documentedTemplateExports = manifest.categories.templateExports.map(item => item.name).sort() expect(missingFromManifest(publicTemplateExports, documentedTemplateExports)).toEqual([]) expect(missingFromManifest(documentedTemplateExports, publicTemplateExports)).toEqual([]) }) it('extracts return members from external type files', () => { const manifest = extractFrontendManifest() const useFormBuilder = manifest.categories.composables.find(item => item.name === 'useFormBuilder') expect(useFormBuilder?.members?.map(member => member.name)).toContain('updateSchema') }) it('extracts inherited return interface members', () => { const manifest = extractFrontendManifest() const workspace = manifest.categories.composables.find(item => item.name === 'useBioTemplateWorkspace') expect(workspace?.members?.map(member => member.name)).toContain('componentUsage') }) it('ignores generic constraints when extracting inherited return members', () => { const root = mkdtempSync(join(tmpdir(), 'mint-docs-')) const composablesDir = join(root, 'composables') mkdirSync(composablesDir) writeFileSync(join(composablesDir, 'index.ts'), "export { useStack } from './useStack'\n") writeFileSync(join(composablesDir, 'useStack.ts'), [ 'export interface StackCommand { description?: string }', 'export interface UseStackReturn {', ' push: (command: TCommand) => void', '}', 'export function useStack(): UseStackReturn { return { push: () => {} } }', ].join('\n')) try { const [stack] = extractComposables({ sdkRoot: root, composablesDir, templatesDir: join(root, 'templates'), templateIndexTs: join(root, 'templates', 'index.ts'), returnTypeFiles: [], }) expect(stack.members.map(member => member.name)).toEqual(['push']) } finally { rmSync(root, { recursive: true, force: true }) } }) it('extracts exact array return member types while resolving inheritance', () => { const manifest = extractFrontendManifest() const bindings = manifest.categories.composables.find(item => item.name === 'controlValuesToComponentBindings') expect(bindings?.members?.find(member => member.name === 'props')?.type).toBe('Record') }) })