diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 5cd7243dd..f5a24f93a 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -1,5 +1,4 @@ #! /usr/bin/env node -import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { axeSetupBinding } from '@code-pushup/axe-plugin'; import { coverageSetupBinding } from '@code-pushup/coverage-plugin'; @@ -8,13 +7,8 @@ import { jsPackagesSetupBinding } from '@code-pushup/js-packages-plugin'; import { jsDocsSetupBinding } from '@code-pushup/jsdocs-plugin'; import { lighthouseSetupBinding } from '@code-pushup/lighthouse-plugin'; import { typescriptSetupBinding } from '@code-pushup/typescript-plugin'; -import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js'; -import { - CI_PROVIDERS, - CONFIG_FILE_FORMATS, - type PluginSetupBinding, - SETUP_MODES, -} from './lib/setup/types.js'; +import { yargsCli } from './lib/setup/cli-args.js'; +import type { PluginSetupBinding } from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; const bindings: PluginSetupBinding[] = [ @@ -27,42 +21,6 @@ const bindings: PluginSetupBinding[] = [ jsDocsSetupBinding, ]; -const argv = await yargs(hideBin(process.argv)) - .option('dry-run', { - type: 'boolean', - default: false, - describe: 'Preview changes without writing files', - }) - .option('yes', { - alias: 'y', - type: 'boolean', - default: false, - describe: 'Skip prompts and use defaults', - }) - .option('config-format', { - type: 'string', - choices: CONFIG_FILE_FORMATS, - describe: 'Config file format (default: auto-detected from project)', - }) - .option('plugins', { - type: 'string', - describe: 'Comma-separated plugin slugs to include (e.g. eslint,coverage)', - coerce: parsePluginSlugs, - }) - .option('mode', { - type: 'string', - choices: SETUP_MODES, - describe: 'Setup mode (default: auto-detected from project)', - }) - .option('ci', { - type: 'string', - choices: CI_PROVIDERS, - describe: 'CI/CD integration (github, gitlab, or none)', - }) - .check(parsed => { - validatePluginSlugs(bindings, parsed.plugins); - return true; - }) - .parse(); +const argv = await yargsCli(bindings).parse(hideBin(process.argv)); await runSetupWizard(bindings, argv); diff --git a/packages/create-cli/src/lib/setup/ci.unit.test.ts b/packages/create-cli/src/lib/setup/ci.unit.test.ts index 8059c3e7f..88b9108fe 100644 --- a/packages/create-cli/src/lib/setup/ci.unit.test.ts +++ b/packages/create-cli/src/lib/setup/ci.unit.test.ts @@ -1,9 +1,9 @@ import { select } from '@inquirer/prompts'; import { vol } from 'memfs'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { createTree } from '@code-pushup/utils'; import { promptCiProvider, resolveCi } from './ci.js'; import type { ConfigContext } from './types.js'; -import { createTree } from './virtual-fs.js'; vi.mock('@inquirer/prompts', () => ({ select: vi.fn(), diff --git a/packages/create-cli/src/lib/setup/cli-args.ts b/packages/create-cli/src/lib/setup/cli-args.ts new file mode 100644 index 000000000..6ad753bec --- /dev/null +++ b/packages/create-cli/src/lib/setup/cli-args.ts @@ -0,0 +1,53 @@ +import yargs, { type Argv } from 'yargs'; +import { parsePluginSlugs, validatePluginSlugs } from './plugins.js'; +import { + CI_PROVIDERS, + CONFIG_FILE_FORMATS, + type PluginSetupBinding, + SETUP_MODES, +} from './types.js'; + +export function yargsCli(bindings: PluginSetupBinding[]): Argv { + return yargs() + .scriptName('create-cli') + .usage('$0 [options]') + .parserConfiguration({ 'dot-notation': false }) + .option('dry-run', { + type: 'boolean', + default: false, + describe: 'Preview changes without writing files', + }) + .option('yes', { + alias: 'y', + type: 'boolean', + default: false, + describe: 'Skip prompts and use defaults', + }) + .option('config-format', { + type: 'string', + choices: CONFIG_FILE_FORMATS, + describe: 'Config file format (default: auto-detected from project)', + }) + .option('plugins', { + type: 'string', + describe: + 'Comma-separated plugin slugs to include (e.g. eslint,coverage)', + coerce: parsePluginSlugs, + }) + .option('mode', { + type: 'string', + choices: SETUP_MODES, + describe: 'Setup mode (default: auto-detected from project)', + }) + .option('ci', { + type: 'string', + choices: CI_PROVIDERS, + describe: 'CI/CD integration (github, gitlab, or none)', + }) + .check(parsed => { + validatePluginSlugs(bindings, parsed.plugins); + return true; + }) + .help() + .version(); +} diff --git a/packages/create-cli/src/lib/setup/cli-args.unit.test.ts b/packages/create-cli/src/lib/setup/cli-args.unit.test.ts new file mode 100644 index 000000000..f50bac990 --- /dev/null +++ b/packages/create-cli/src/lib/setup/cli-args.unit.test.ts @@ -0,0 +1,34 @@ +import { yargsCli } from './cli-args.js'; +import type { PluginSetupBinding } from './types.js'; + +const bareBindings: PluginSetupBinding[] = [ + { + slug: 'eslint', + title: 'ESLint', + packageName: '@code-pushup/eslint-plugin', + generateConfig: () => ({ imports: [], pluginInit: [] }), + }, +]; + +describe('yargsCli', () => { + it('should expose --eslint.patterns as a flat key', async () => { + const argv = await yargsCli(bareBindings).parse([ + '--eslint.patterns', + 'src', + ]); + + expect(argv['eslint.patterns']).toBe('src'); + }); + + it('should expose --no-eslint.categories as a flat false', async () => { + const argv = await yargsCli(bareBindings).parse(['--no-eslint.categories']); + + expect(argv['eslint.categories']).toBeFalse(); + }); + + it('should expose --eslint.categories without a value as a flat true', async () => { + const argv = await yargsCli(bareBindings).parse(['--eslint.categories']); + + expect(argv['eslint.categories']).toBeTrue(); + }); +}); diff --git a/packages/create-cli/src/lib/setup/gitignore.unit.test.ts b/packages/create-cli/src/lib/setup/gitignore.unit.test.ts index 16e544927..0ef80027c 100644 --- a/packages/create-cli/src/lib/setup/gitignore.unit.test.ts +++ b/packages/create-cli/src/lib/setup/gitignore.unit.test.ts @@ -1,8 +1,8 @@ import { vol } from 'memfs'; import { readFile } from 'node:fs/promises'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { createTree } from '@code-pushup/utils'; import { resolveGitignore } from './gitignore.js'; -import { createTree } from './virtual-fs.js'; describe('resolveGitignore', () => { it('should create .gitignore with comment when it does not exist', async () => { diff --git a/packages/create-cli/src/lib/setup/monorepo.unit.test.ts b/packages/create-cli/src/lib/setup/monorepo.unit.test.ts index c56f8e5d2..0b2467ba5 100644 --- a/packages/create-cli/src/lib/setup/monorepo.unit.test.ts +++ b/packages/create-cli/src/lib/setup/monorepo.unit.test.ts @@ -1,10 +1,9 @@ import { select } from '@inquirer/prompts'; import { vol } from 'memfs'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import { logger } from '@code-pushup/utils'; +import { createTree, logger } from '@code-pushup/utils'; import { addCodePushUpCommand, promptSetupMode } from './monorepo.js'; import type { WizardProject } from './types.js'; -import { createTree } from './virtual-fs.js'; vi.mock('@inquirer/prompts', () => ({ select: vi.fn(), diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index 5948d4a15..231cd919c 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -1,16 +1,19 @@ import type { PluginCodegenResult } from '@code-pushup/models'; -import type { MonorepoTool } from '@code-pushup/utils'; +import type { MonorepoTool, Tree } from '@code-pushup/utils'; export type { CategoryCodegenConfig, ImportDeclarationStructure, PluginAnswer, + PluginCodegenInput, PluginCodegenResult, PluginPromptDescriptor, PluginSetupBinding, PluginSetupTree, } from '@code-pushup/models'; +export type { FileChange, FileSystemAdapter, Tree } from '@code-pushup/utils'; + export const CI_PROVIDERS = ['github', 'gitlab', 'none'] as const; export type CiProvider = (typeof CI_PROVIDERS)[number]; @@ -58,31 +61,3 @@ export type WriteContext = { configFilename: string; isEsm: boolean; }; - -/** A single file operation recorded by the virtual tree. */ -export type FileChange = { - path: string; - type: 'CREATE' | 'UPDATE'; - content: string; -}; - -/** Virtual file system that buffers writes in memory until flushed to disk. */ -export type Tree = { - root: string; - exists: (filePath: string) => Promise; - read: (filePath: string) => Promise; - write: (filePath: string, content: string) => Promise; - listChanges: () => FileChange[]; - flush: () => Promise; -}; - -/** Abstraction over `node:fs` used by the virtual tree for disk I/O. */ -export type FileSystemAdapter = { - readFile: (path: string, encoding: 'utf8') => Promise; - writeFile: (path: string, content: string) => Promise; - exists: (path: string) => Promise; - mkdir: ( - path: string, - options: { recursive: true }, - ) => Promise; -}; diff --git a/packages/create-cli/src/lib/setup/virtual-fs.ts b/packages/create-cli/src/lib/setup/virtual-fs.ts deleted file mode 100644 index 90ed531f3..000000000 --- a/packages/create-cli/src/lib/setup/virtual-fs.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileExists } from '@code-pushup/utils'; -import type { FileChange, FileSystemAdapter, Tree } from './types.js'; - -const DEFAULT_FS: FileSystemAdapter = { - readFile, - writeFile, - exists: fileExists, - mkdir, -}; - -export function createTree( - root: string, - fs: FileSystemAdapter = DEFAULT_FS, -): Tree { - const pending = new Map>(); - - const resolve = (filePath: string): string => path.resolve(root, filePath); - - return { - root, - - exists: async (filePath: string): Promise => - pending.has(filePath) || fs.exists(resolve(filePath)), - - read: async (filePath: string): Promise => { - const entry = pending.get(filePath); - if (entry) { - return entry.content; - } - const absolutePath = resolve(filePath); - if (!(await fs.exists(absolutePath))) { - return null; - } - return fs.readFile(absolutePath, 'utf8'); - }, - - write: async (filePath: string, content: string): Promise => { - const entry = pending.get(filePath); - if (entry) { - pending.set(filePath, { ...entry, content }); - } else { - const type = (await fs.exists(resolve(filePath))) ? 'UPDATE' : 'CREATE'; - pending.set(filePath, { content, type }); - } - }, - - listChanges: (): FileChange[] => - [...pending.entries()].map(([filePath, { content, type }]) => ({ - path: filePath, - type, - content, - })), - - async flush(): Promise { - await Promise.all( - [...pending.entries()].map(async ([filePath, { content }]) => { - const absolutePath = resolve(filePath); - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - await fs.writeFile(absolutePath, content); - }), - ); - pending.clear(); - }, - }; -} diff --git a/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts b/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts deleted file mode 100644 index 031fe43f6..000000000 --- a/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { toUnixPath } from '@code-pushup/utils'; -import type { FileSystemAdapter } from './types.js'; -import { createTree } from './virtual-fs.js'; - -function createMockFs( - files: Record = {}, -): FileSystemAdapter & { written: Map; dirs: Set } { - const store = new Map(Object.entries(files)); - const written = new Map(); - const dirs = new Set(); - - return { - written, - dirs, - async readFile(path: string) { - const content = store.get(toUnixPath(path)); - if (content == null) { - throw new Error(`ENOENT: no such file or directory, open '${path}'`); - } - return content; - }, - async writeFile(path: string, content: string) { - store.set(toUnixPath(path), content); - written.set(toUnixPath(path), content); - }, - async exists(path: string) { - return store.has(toUnixPath(path)); - }, - async mkdir(path: string): Promise { - dirs.add(toUnixPath(path)); - }, - }; -} - -describe('createTree', () => { - it('should report the root directory', () => { - expect(createTree('/project').root).toBe('/project'); - }); - - describe('exists', () => { - it('should return false for non-existent files', async () => { - await expect( - createTree('/project', createMockFs()).exists('missing.ts'), - ).resolves.toBeFalse(); - }); - - it('should return true for files on disk', async () => { - await expect( - createTree( - '/project', - createMockFs({ '/project/existing.ts': 'content' }), - ).exists('existing.ts'), - ).resolves.toBeTrue(); - }); - - it('should return true for files written to the tree', async () => { - const tree = createTree('/project', createMockFs()); - await tree.write('new.ts', 'content'); - await expect(tree.exists('new.ts')).resolves.toBeTrue(); - }); - }); - - describe('read', () => { - it('should return null for non-existent files', async () => { - await expect( - createTree('/project', createMockFs()).read('missing.ts'), - ).resolves.toBeNull(); - }); - - it('should read files from disk', async () => { - await expect( - createTree( - '/project', - createMockFs({ '/project/existing.ts': 'disk content' }), - ).read('existing.ts'), - ).resolves.toBe('disk content'); - }); - - it('should return pending content over disk content', async () => { - const tree = createTree( - '/project', - createMockFs({ '/project/file.ts': 'old' }), - ); - await tree.write('file.ts', 'new'); - await expect(tree.read('file.ts')).resolves.toBe('new'); - }); - }); - - describe('write', () => { - it('should mark new files as CREATE', async () => { - const tree = createTree('/project', createMockFs()); - await tree.write('new.ts', 'content'); - - expect(tree.listChanges()).toStrictEqual([ - { path: 'new.ts', type: 'CREATE', content: 'content' }, - ]); - }); - - it('should preserve CREATE type when writing to the same path twice', async () => { - const tree = createTree('/project', createMockFs()); - await tree.write('new.ts', 'first'); - await tree.write('new.ts', 'second'); - - expect(tree.listChanges()).toStrictEqual([ - { path: 'new.ts', type: 'CREATE', content: 'second' }, - ]); - }); - - it('should mark existing files as UPDATE', async () => { - const tree = createTree( - '/project', - createMockFs({ '/project/existing.ts': 'old' }), - ); - await tree.write('existing.ts', 'new'); - - expect(tree.listChanges()).toStrictEqual([ - { path: 'existing.ts', type: 'UPDATE', content: 'new' }, - ]); - }); - }); - - describe('listChanges', () => { - it('should return empty array when no changes are detected', () => { - expect( - createTree('/project', createMockFs()).listChanges(), - ).toStrictEqual([]); - }); - - it('should return all pending changes', async () => { - const tree = createTree( - '/project', - createMockFs({ '/project/existing.ts': 'old' }), - ); - await tree.write('new.ts', 'created'); - await tree.write('existing.ts', 'updated'); - - expect(tree.listChanges()).toHaveLength(2); - expect(tree.listChanges()).toContainEqual({ - path: 'new.ts', - type: 'CREATE', - content: 'created', - }); - expect(tree.listChanges()).toContainEqual({ - path: 'existing.ts', - type: 'UPDATE', - content: 'updated', - }); - }); - }); - - describe('flush', () => { - it('should write all pending files to the fs', async () => { - const fs = createMockFs(); - const tree = createTree('/project', fs); - await tree.write('src/config.ts', 'export default {};'); - - await tree.flush(); - - expect(fs.written.get('/project/src/config.ts')).toBe( - 'export default {};', - ); - }); - - it('should create parent directories', async () => { - const fs = createMockFs(); - const tree = createTree('/project', fs); - await tree.write('src/deep/config.ts', 'content'); - - await tree.flush(); - - expect(fs.dirs).toContain('/project/src/deep'); - }); - - it('should clear pending changes after flush', async () => { - const tree = createTree('/project', createMockFs()); - await tree.write('file.ts', 'content'); - - await tree.flush(); - - expect(tree.listChanges()).toStrictEqual([]); - }); - - it('should not write anything when no changes are pending', async () => { - const fs = createMockFs(); - - await createTree('/project', fs).flush(); - - expect(fs.written.size).toBe(0); - }); - }); -}); diff --git a/packages/create-cli/src/lib/setup/wizard.int.test.ts b/packages/create-cli/src/lib/setup/wizard.int.test.ts index 7e544c3db..e4b17f9e8 100644 --- a/packages/create-cli/src/lib/setup/wizard.int.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.int.test.ts @@ -28,7 +28,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [ default: 'alpha.config.js', }, ], - generateConfig(answers) { + generateConfig({ answers }) { const configPath = answers['alpha.path'] ?? 'alpha.config.js'; return { imports: [ diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index 4fcc62eaa..813418cce 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { type MonorepoTool, asyncSequential, + createTree, formatAsciiTable, getGitRoot, logger, @@ -35,7 +36,6 @@ import type { Tree, WriteContext, } from './types.js'; -import { createTree } from './virtual-fs.js'; /** * Runs the interactive setup wizard that generates a Code PushUp config file. @@ -93,7 +93,12 @@ async function resolveBinding( tree: Pick, ): Promise { if (!binding.prompts) { - return binding.generateConfig({}, tree); + return binding.generateConfig({ + tree, + targetDir, + cliArgs, + answers: {}, + }); } logger.newline(); logger.info(ansis.bold(binding.title)); @@ -102,7 +107,7 @@ async function resolveBinding( descriptors.length > 0 ? await promptPluginOptions(descriptors, cliArgs) : {}; - return binding.generateConfig(answers, tree); + return binding.generateConfig({ tree, targetDir, cliArgs, answers }); } async function writeStandaloneConfig( diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 680db2a37..23ecaaedc 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -116,6 +116,7 @@ export type { CategoryCodegenConfig, ImportDeclarationStructure, PluginAnswer, + PluginCodegenInput, PluginCodegenResult, PluginDeclarationStructure, PluginPromptDescriptor, diff --git a/packages/models/src/lib/plugin-setup.ts b/packages/models/src/lib/plugin-setup.ts index f10bbd94c..c19396f55 100644 --- a/packages/models/src/lib/plugin-setup.ts +++ b/packages/models/src/lib/plugin-setup.ts @@ -72,6 +72,13 @@ export type PluginSetupTree = { write: (path: string, content: string) => Promise; }; +export type PluginCodegenInput = { + answers: Record; + tree: PluginSetupTree; + targetDir: string; + cliArgs: Record; +}; + /** * Defines how a plugin integrates with the setup wizard. * @@ -88,7 +95,6 @@ export type PluginSetupBinding = { prompts?: (targetDir: string) => Promise; isRecommended?: (targetDir: string) => Promise; generateConfig: ( - answers: Record, - tree: PluginSetupTree, + input: PluginCodegenInput, ) => PluginCodegenResult | Promise; }; diff --git a/packages/plugin-axe/src/lib/binding.ts b/packages/plugin-axe/src/lib/binding.ts index d4c32f547..72d2ea346 100644 --- a/packages/plugin-axe/src/lib/binding.ts +++ b/packages/plugin-axe/src/lib/binding.ts @@ -3,7 +3,6 @@ import type { CategoryCodegenConfig, PluginAnswer, PluginSetupBinding, - PluginSetupTree, } from '@code-pushup/models'; import { answerBoolean, @@ -84,10 +83,7 @@ export const axeSetupBinding = { default: true, }, ], - generateConfig: async ( - answers: Record, - tree: PluginSetupTree, - ) => { + generateConfig: async ({ answers, tree }) => { const options = parseAnswers(answers); if (options.setupScript) { await tree.write(SETUP_SCRIPT_PATH, SETUP_SCRIPT_CONTENT); diff --git a/packages/plugin-axe/src/lib/binding.unit.test.ts b/packages/plugin-axe/src/lib/binding.unit.test.ts index fb2af7ce2..1c5a1bc0e 100644 --- a/packages/plugin-axe/src/lib/binding.unit.test.ts +++ b/packages/plugin-axe/src/lib/binding.unit.test.ts @@ -1,5 +1,8 @@ import type { PluginAnswer } from '@code-pushup/models'; -import { createMockTree } from '@code-pushup/test-utils'; +import { + createMockCodegenInput, + createMockTree, +} from '@code-pushup/test-utils'; import { axeSetupBinding as binding } from './binding.js'; const defaultAnswers: Record = { @@ -32,8 +35,7 @@ describe('axeSetupBinding', () => { describe('generateConfig with categories selected', () => { it('should declare plugin as a variable for use in category refs', async () => { const { pluginDeclaration } = await binding.generateConfig( - defaultAnswers, - createMockTree(), + createMockCodegenInput(defaultAnswers, createMockTree()), ); expect(pluginDeclaration).toStrictEqual({ identifier: 'axe', @@ -43,8 +45,7 @@ describe('axeSetupBinding', () => { it('should import axeGroupRefs helper', async () => { const { imports } = await binding.generateConfig( - defaultAnswers, - createMockTree(), + createMockCodegenInput(defaultAnswers, createMockTree()), ); expect(imports).toStrictEqual([ expect.objectContaining({ namedImports: ['axeGroupRefs'] }), @@ -53,8 +54,7 @@ describe('axeSetupBinding', () => { it('should produce accessibility category with refs expression', async () => { const { categories } = await binding.generateConfig( - defaultAnswers, - createMockTree(), + createMockCodegenInput(defaultAnswers, createMockTree()), ); expect(categories).toStrictEqual([ expect.objectContaining({ @@ -68,24 +68,21 @@ describe('axeSetupBinding', () => { describe('generateConfig without categories selected', () => { it('should not declare plugin as a variable', async () => { const { pluginDeclaration } = await binding.generateConfig( - noCategoryAnswers, - createMockTree(), + createMockCodegenInput(noCategoryAnswers, createMockTree()), ); expect(pluginDeclaration).toBeUndefined(); }); it('should not import axeGroupRefs helper', async () => { const { imports } = await binding.generateConfig( - noCategoryAnswers, - createMockTree(), + createMockCodegenInput(noCategoryAnswers, createMockTree()), ); expect(imports[0]).not.toHaveProperty('namedImports'); }); it('should not produce categories', async () => { const { categories } = await binding.generateConfig( - noCategoryAnswers, - createMockTree(), + createMockCodegenInput(noCategoryAnswers, createMockTree()), ); expect(categories).toBeUndefined(); }); @@ -95,8 +92,10 @@ describe('axeSetupBinding', () => { it('should write setup script file when confirmed', async () => { const tree = createMockTree(); await binding.generateConfig( - { ...defaultAnswers, 'axe.setupScript': true }, - tree, + createMockCodegenInput( + { ...defaultAnswers, 'axe.setupScript': true }, + tree, + ), ); expect(tree.written.get('./axe-setup.ts')).toContain( "import type { Page } from 'playwright-core'", @@ -105,8 +104,10 @@ describe('axeSetupBinding', () => { it('should include setupScript in plugin call when confirmed', async () => { const { pluginDeclaration } = await binding.generateConfig( - { ...defaultAnswers, 'axe.setupScript': true }, - createMockTree(), + createMockCodegenInput( + { ...defaultAnswers, 'axe.setupScript': true }, + createMockTree(), + ), ); expect(pluginDeclaration!.expression).toContain( "setupScript: './axe-setup.ts'", @@ -115,26 +116,32 @@ describe('axeSetupBinding', () => { it('should not write setup script file when declined', async () => { const tree = createMockTree(); - await binding.generateConfig(defaultAnswers, tree); + await binding.generateConfig( + createMockCodegenInput(defaultAnswers, tree), + ); expect(tree.written.size).toBe(0); }); }); it('should include non-default preset in plugin call', async () => { const { pluginDeclaration } = await binding.generateConfig( - { ...defaultAnswers, 'axe.preset': 'wcag22aa' }, - createMockTree(), + createMockCodegenInput( + { ...defaultAnswers, 'axe.preset': 'wcag22aa' }, + createMockTree(), + ), ); expect(pluginDeclaration!.expression).toContain("preset: 'wcag22aa'"); }); it('should format multiple URLs as array', async () => { const { pluginDeclaration } = await binding.generateConfig( - { - ...defaultAnswers, - 'axe.urls': 'http://localhost:4200/login, http://localhost:4200/home', - }, - createMockTree(), + createMockCodegenInput( + { + ...defaultAnswers, + 'axe.urls': 'http://localhost:4200/login, http://localhost:4200/home', + }, + createMockTree(), + ), ); expect(pluginDeclaration!.expression).toContain( "axePlugin(['http://localhost:4200/login', 'http://localhost:4200/home']", diff --git a/packages/plugin-coverage/src/lib/binding.ts b/packages/plugin-coverage/src/lib/binding.ts index 1876a1d13..b1b46cfe5 100644 --- a/packages/plugin-coverage/src/lib/binding.ts +++ b/packages/plugin-coverage/src/lib/binding.ts @@ -128,10 +128,7 @@ export const coverageSetupBinding = { }, ]; }, - generateConfig: async ( - answers: Record, - tree?: PluginSetupTree, - ) => { + generateConfig: async ({ answers, tree }) => { const options = parseAnswers(answers); const lcovConfigured = await configureLcovReporter(options, tree); return { diff --git a/packages/plugin-coverage/src/lib/binding.unit.test.ts b/packages/plugin-coverage/src/lib/binding.unit.test.ts index eee6b445a..e2ec2795f 100644 --- a/packages/plugin-coverage/src/lib/binding.unit.test.ts +++ b/packages/plugin-coverage/src/lib/binding.unit.test.ts @@ -1,6 +1,10 @@ import { vol } from 'memfs'; import type { PluginAnswer } from '@code-pushup/models'; -import { MEMFS_VOLUME, createMockTree } from '@code-pushup/test-utils'; +import { + MEMFS_VOLUME, + createMockCodegenInput, + createMockTree, +} from '@code-pushup/test-utils'; import { readJsonFile } from '@code-pushup/utils'; import { coverageSetupBinding as binding } from './binding.js'; @@ -107,7 +111,9 @@ describe('coverageSetupBinding', () => { describe('generateConfig', () => { it('should generate vitest config', async () => { - const { pluginInit } = await binding.generateConfig(defaultAnswers); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput(defaultAnswers), + ); expect(pluginInit).toEqual([ '// NOTE: Ensure your test config includes "lcov" in coverage reporters.', 'await coveragePlugin({', @@ -118,11 +124,13 @@ describe('coverageSetupBinding', () => { }); it('should generate jest config', async () => { - const { pluginInit } = await binding.generateConfig({ - ...defaultAnswers, - 'coverage.framework': 'jest', - 'coverage.testCommand': 'npx jest --coverage', - }); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'coverage.framework': 'jest', + 'coverage.testCommand': 'npx jest --coverage', + }), + ); expect(pluginInit).toEqual([ '// NOTE: Ensure your test config includes "lcov" in coverage reporters.', 'await coveragePlugin({', @@ -133,10 +141,12 @@ describe('coverageSetupBinding', () => { }); it('should omit coverageToolCommand when test command is empty', async () => { - const { pluginInit } = await binding.generateConfig({ - ...defaultAnswers, - 'coverage.testCommand': '', - }); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'coverage.testCommand': '', + }), + ); expect(pluginInit).not.toEqual( expect.arrayContaining([ expect.stringContaining('coverageToolCommand'), @@ -145,10 +155,12 @@ describe('coverageSetupBinding', () => { }); it('should use default report path when empty', async () => { - const { pluginInit } = await binding.generateConfig({ - ...defaultAnswers, - 'coverage.reportPath': '', - }); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'coverage.reportPath': '', + }), + ); expect(pluginInit).toEqual( expect.arrayContaining([ expect.stringContaining("'coverage/lcov.info'"), @@ -157,10 +169,12 @@ describe('coverageSetupBinding', () => { }); it('should use custom report path when provided', async () => { - const { pluginInit } = await binding.generateConfig({ - ...defaultAnswers, - 'coverage.reportPath': 'dist/coverage/lcov.info', - }); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'coverage.reportPath': 'dist/coverage/lcov.info', + }), + ); expect(pluginInit).toEqual( expect.arrayContaining([ expect.stringContaining("'dist/coverage/lcov.info'"), @@ -169,17 +183,21 @@ describe('coverageSetupBinding', () => { }); it('should omit coverageTypes when all selected', async () => { - const { pluginInit } = await binding.generateConfig(defaultAnswers); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput(defaultAnswers), + ); expect(pluginInit).not.toEqual( expect.arrayContaining([expect.stringContaining('coverageTypes')]), ); }); it('should include coverageTypes when subset selected', async () => { - const { pluginInit } = await binding.generateConfig({ - ...defaultAnswers, - 'coverage.types': ['branch', 'line'], - }); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'coverage.types': ['branch', 'line'], + }), + ); expect(pluginInit).toEqual( expect.arrayContaining([ expect.stringContaining("coverageTypes: ['branch', 'line']"), @@ -188,10 +206,12 @@ describe('coverageSetupBinding', () => { }); it('should disable continueOnCommandFail when declined', async () => { - const { pluginInit } = await binding.generateConfig({ - ...defaultAnswers, - 'coverage.continueOnFail': false, - }); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'coverage.continueOnFail': false, + }), + ); expect(pluginInit).toEqual( expect.arrayContaining([ expect.stringContaining('continueOnCommandFail: false'), @@ -200,7 +220,9 @@ describe('coverageSetupBinding', () => { }); it('should omit continueOnCommandFail when default', async () => { - const { pluginInit } = await binding.generateConfig(defaultAnswers); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput(defaultAnswers), + ); expect(pluginInit).not.toEqual( expect.arrayContaining([ expect.stringContaining('continueOnCommandFail'), @@ -209,15 +231,19 @@ describe('coverageSetupBinding', () => { }); it('should omit categories when declined', async () => { - const { categories } = await binding.generateConfig({ - ...defaultAnswers, - 'coverage.categories': false, - }); + const { categories } = await binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'coverage.categories': false, + }), + ); expect(categories).toBeUndefined(); }); it('should import from @code-pushup/coverage-plugin', async () => { - const { imports } = await binding.generateConfig(defaultAnswers); + const { imports } = await binding.generateConfig( + createMockCodegenInput(defaultAnswers), + ); expect(imports).toEqual([ { moduleSpecifier: '@code-pushup/coverage-plugin', @@ -239,7 +265,9 @@ describe('coverageSetupBinding', () => { 'vitest.config.ts': "export default { test: { coverage: { reporter: ['lcov'] } } };", }); - const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput(vitestAnswers, tree), + ); expect(pluginInit).not.toEqual( expect.arrayContaining([expect.stringContaining('NOTE')]), ); @@ -250,7 +278,9 @@ describe('coverageSetupBinding', () => { 'vitest.config.ts': "import { defineConfig } from 'vitest/config';\nexport default defineConfig({ test: { coverage: { reporter: ['text'] } } });", }); - const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput(vitestAnswers, tree), + ); expect(pluginInit).not.toEqual( expect.arrayContaining([expect.stringContaining('NOTE')]), ); @@ -258,10 +288,12 @@ describe('coverageSetupBinding', () => { }); it('should include comment when framework is other', async () => { - const { pluginInit } = await binding.generateConfig({ - ...defaultAnswers, - 'coverage.framework': 'other', - }); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'coverage.framework': 'other', + }), + ); expect(pluginInit).toEqual( expect.arrayContaining([expect.stringContaining('NOTE')]), ); @@ -269,7 +301,9 @@ describe('coverageSetupBinding', () => { it('should include comment when config file cannot be read', async () => { const tree = createMockTree({}); - const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); + const { pluginInit } = await binding.generateConfig( + createMockCodegenInput(vitestAnswers, tree), + ); expect(pluginInit).toEqual( expect.arrayContaining([expect.stringContaining('NOTE')]), ); @@ -280,12 +314,14 @@ describe('coverageSetupBinding', () => { 'jest.config.js': "module.exports = { coverageReporters: ['text'] };", }); const { pluginInit } = await binding.generateConfig( - { - ...defaultAnswers, - 'coverage.framework': 'jest', - 'coverage.configFile': 'jest.config.js', - }, - tree, + createMockCodegenInput( + { + ...defaultAnswers, + 'coverage.framework': 'jest', + 'coverage.configFile': 'jest.config.js', + }, + tree, + ), ); expect(pluginInit).toEqual( expect.arrayContaining([expect.stringContaining('NOTE')]), diff --git a/packages/plugin-eslint/src/lib/binding.ts b/packages/plugin-eslint/src/lib/binding.ts index a9df94bb1..cce6cb1a6 100644 --- a/packages/plugin-eslint/src/lib/binding.ts +++ b/packages/plugin-eslint/src/lib/binding.ts @@ -90,7 +90,7 @@ export const eslintSetupBinding = { default: true, }, ], - generateConfig: (answers: Record) => { + generateConfig: ({ answers }) => { const options = parseAnswers(answers); return { imports: [ diff --git a/packages/plugin-eslint/src/lib/binding.unit.test.ts b/packages/plugin-eslint/src/lib/binding.unit.test.ts index 94c476da5..49d8f93d5 100644 --- a/packages/plugin-eslint/src/lib/binding.unit.test.ts +++ b/packages/plugin-eslint/src/lib/binding.unit.test.ts @@ -1,5 +1,5 @@ import { vol } from 'memfs'; -import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { MEMFS_VOLUME, createMockCodegenInput } from '@code-pushup/test-utils'; import { directoryExists, readJsonFile } from '@code-pushup/utils'; import { eslintSetupBinding } from './binding.js'; @@ -111,21 +111,25 @@ describe('eslintSetupBinding', () => { describe('generateConfig', () => { it('should omit eslintrc for standard config filenames', () => { expect( - eslintSetupBinding.generateConfig({ - 'eslint.eslintrc': 'eslint.config.ts', - 'eslint.patterns': 'src', - 'eslint.categories': true, - }).pluginInit, + eslintSetupBinding.generateConfig( + createMockCodegenInput({ + 'eslint.eslintrc': 'eslint.config.ts', + 'eslint.patterns': 'src', + 'eslint.categories': true, + }), + ).pluginInit, ).toEqual(["await eslintPlugin({ patterns: 'src' }),"]); }); it('should include eslintrc for non-standard config paths', () => { expect( - eslintSetupBinding.generateConfig({ - 'eslint.eslintrc': 'configs/eslint.config.js', - 'eslint.patterns': 'src', - 'eslint.categories': false, - }).pluginInit, + eslintSetupBinding.generateConfig( + createMockCodegenInput({ + 'eslint.eslintrc': 'configs/eslint.config.js', + 'eslint.patterns': 'src', + 'eslint.categories': false, + }), + ).pluginInit, ).toEqual([ "await eslintPlugin({ eslintrc: 'configs/eslint.config.js', patterns: 'src' }),", ]); @@ -133,51 +137,61 @@ describe('eslintSetupBinding', () => { it('should format comma-separated patterns as array', () => { expect( - eslintSetupBinding.generateConfig({ - 'eslint.eslintrc': '', - 'eslint.patterns': 'src, lib', - 'eslint.categories': false, - }).pluginInit, + eslintSetupBinding.generateConfig( + createMockCodegenInput({ + 'eslint.eslintrc': '', + 'eslint.patterns': 'src, lib', + 'eslint.categories': false, + }), + ).pluginInit, ).toEqual(["await eslintPlugin({ patterns: ['src', 'lib'] }),"]); }); it('should produce no-arg call when no options provided', () => { expect( - eslintSetupBinding.generateConfig({ - 'eslint.eslintrc': '', - 'eslint.patterns': '', - 'eslint.categories': false, - }).pluginInit, + eslintSetupBinding.generateConfig( + createMockCodegenInput({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': false, + }), + ).pluginInit, ).toEqual(['await eslintPlugin(),']); }); it('should include categories when user confirms', () => { expect( - eslintSetupBinding.generateConfig({ - 'eslint.eslintrc': '', - 'eslint.patterns': '', - 'eslint.categories': true, - }).categories, + eslintSetupBinding.generateConfig( + createMockCodegenInput({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': true, + }), + ).categories, ).toHaveLength(2); }); it('should omit categories when user declines', () => { expect( - eslintSetupBinding.generateConfig({ - 'eslint.eslintrc': '', - 'eslint.patterns': '', - 'eslint.categories': false, - }).categories, + eslintSetupBinding.generateConfig( + createMockCodegenInput({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': false, + }), + ).categories, ).toBeUndefined(); }); it('should import from @code-pushup/eslint-plugin', () => { expect( - eslintSetupBinding.generateConfig({ - 'eslint.eslintrc': '', - 'eslint.patterns': '', - 'eslint.categories': false, - }).imports, + eslintSetupBinding.generateConfig( + createMockCodegenInput({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': false, + }), + ).imports, ).toEqual([ { moduleSpecifier: '@code-pushup/eslint-plugin', diff --git a/packages/plugin-js-packages/src/lib/binding.ts b/packages/plugin-js-packages/src/lib/binding.ts index 488461105..3caa14081 100644 --- a/packages/plugin-js-packages/src/lib/binding.ts +++ b/packages/plugin-js-packages/src/lib/binding.ts @@ -105,7 +105,7 @@ export const jsPackagesSetupBinding = { }, ]; }, - generateConfig: (answers: Record) => { + generateConfig: ({ answers }) => { const options = parseAnswers(answers); return { imports: [ diff --git a/packages/plugin-js-packages/src/lib/binding.unit.test.ts b/packages/plugin-js-packages/src/lib/binding.unit.test.ts index 71210090a..663a35408 100644 --- a/packages/plugin-js-packages/src/lib/binding.unit.test.ts +++ b/packages/plugin-js-packages/src/lib/binding.unit.test.ts @@ -1,6 +1,6 @@ import { vol } from 'memfs'; import type { PluginAnswer } from '@code-pushup/models'; -import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { MEMFS_VOLUME, createMockCodegenInput } from '@code-pushup/test-utils'; import { jsPackagesSetupBinding as binding } from './binding.js'; const defaultAnswers: Record = { @@ -85,7 +85,10 @@ describe('jsPackagesSetupBinding', () => { describe('generateConfig', () => { it('should always include packageManager in plugin init', () => { - expect(binding.generateConfig(defaultAnswers).pluginInit).toEqual( + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)) + .pluginInit, + ).toEqual( expect.arrayContaining([ expect.stringContaining("packageManager: 'npm'"), ]), @@ -93,34 +96,44 @@ describe('jsPackagesSetupBinding', () => { }); it('should omit checks when all defaults (audit and outdated) are selected', () => { - expect(binding.generateConfig(defaultAnswers).pluginInit).not.toEqual( + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)) + .pluginInit, + ).not.toEqual( expect.arrayContaining([expect.stringContaining('checks')]), ); }); it('should include checks when only audit is selected', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'js-packages.checks': ['audit'], - }).pluginInit, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'js-packages.checks': ['audit'], + }), + ).pluginInit, ).toEqual( expect.arrayContaining([expect.stringContaining("checks: ['audit']")]), ); }); it('should omit dependencyGroups when default prod and dev are selected', () => { - expect(binding.generateConfig(defaultAnswers).pluginInit).not.toEqual( + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)) + .pluginInit, + ).not.toEqual( expect.arrayContaining([expect.stringContaining('dependencyGroups')]), ); }); it('should include dependencyGroups when optionalDependencies are added', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'js-packages.dependencyGroups': ['prod', 'dev', 'optional'], - }).pluginInit, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'js-packages.dependencyGroups': ['prod', 'dev', 'optional'], + }), + ).pluginInit, ).toEqual( expect.arrayContaining([ expect.stringContaining( @@ -132,24 +145,31 @@ describe('jsPackagesSetupBinding', () => { it('should generate security category for audit check', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'js-packages.checks': ['audit'], - }).categories, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'js-packages.checks': ['audit'], + }), + ).categories, ).toEqual([expect.objectContaining({ slug: 'security' })]); }); it('should generate updates category for outdated check', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'js-packages.checks': ['outdated'], - }).categories, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'js-packages.checks': ['outdated'], + }), + ).categories, ).toEqual([expect.objectContaining({ slug: 'updates' })]); }); it('should generate both categories when audit and outdated checks are selected', () => { - expect(binding.generateConfig(defaultAnswers).categories).toEqual([ + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)) + .categories, + ).toEqual([ expect.objectContaining({ slug: 'security' }), expect.objectContaining({ slug: 'updates' }), ]); @@ -157,10 +177,12 @@ describe('jsPackagesSetupBinding', () => { it('should use package manager as prefix in category group refs', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'js-packages.packageManager': 'pnpm', - }).categories, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'js-packages.packageManager': 'pnpm', + }), + ).categories, ).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -175,15 +197,19 @@ describe('jsPackagesSetupBinding', () => { it('should omit categories when declined', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'js-packages.categories': false, - }).categories, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'js-packages.categories': false, + }), + ).categories, ).toBeUndefined(); }); it('should import from @code-pushup/js-packages-plugin', () => { - expect(binding.generateConfig(defaultAnswers).imports).toEqual([ + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)).imports, + ).toEqual([ { moduleSpecifier: '@code-pushup/js-packages-plugin', defaultImport: 'jsPackagesPlugin', diff --git a/packages/plugin-jsdocs/src/lib/binding.ts b/packages/plugin-jsdocs/src/lib/binding.ts index 872f295e8..216157e54 100644 --- a/packages/plugin-jsdocs/src/lib/binding.ts +++ b/packages/plugin-jsdocs/src/lib/binding.ts @@ -60,7 +60,7 @@ export const jsDocsSetupBinding = { default: true, }, ], - generateConfig: (answers: Record) => { + generateConfig: ({ answers }) => { const options = parseAnswers(answers); return { imports: [ diff --git a/packages/plugin-jsdocs/src/lib/binding.unit.test.ts b/packages/plugin-jsdocs/src/lib/binding.unit.test.ts index 30ea7c745..8219d510a 100644 --- a/packages/plugin-jsdocs/src/lib/binding.unit.test.ts +++ b/packages/plugin-jsdocs/src/lib/binding.unit.test.ts @@ -1,4 +1,5 @@ import type { PluginAnswer } from '@code-pushup/models'; +import { createMockCodegenInput } from '@code-pushup/test-utils'; import { jsDocsSetupBinding as binding } from './binding.js'; const defaultAnswers: Record = { @@ -32,7 +33,9 @@ describe('jsDocsSetupBinding', () => { describe('generateConfig', () => { it('should import from @code-pushup/jsdocs-plugin', () => { - const { imports } = binding.generateConfig(defaultAnswers); + const { imports } = binding.generateConfig( + createMockCodegenInput(defaultAnswers), + ); expect(imports).toStrictEqual([ expect.objectContaining({ defaultImport: 'jsDocsPlugin', @@ -41,7 +44,9 @@ describe('jsDocsSetupBinding', () => { }); it('should pass multiple patterns as array to plugin call', () => { - const { pluginInit } = binding.generateConfig(defaultAnswers); + const { pluginInit } = binding.generateConfig( + createMockCodegenInput(defaultAnswers), + ); expect(pluginInit).toStrictEqual([ 'jsDocsPlugin([', " 'src/**/*.ts',", @@ -52,15 +57,19 @@ describe('jsDocsSetupBinding', () => { }); it('should pass single pattern as string to plugin call', () => { - const { pluginInit } = binding.generateConfig({ - ...defaultAnswers, - 'jsdocs.patterns': 'src/**/*.ts', - }); + const { pluginInit } = binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'jsdocs.patterns': 'src/**/*.ts', + }), + ); expect(pluginInit).toStrictEqual(["jsDocsPlugin('src/**/*.ts'),"]); }); it('should generate Documentation category from documentation-coverage group', () => { - const { categories } = binding.generateConfig(defaultAnswers); + const { categories } = binding.generateConfig( + createMockCodegenInput(defaultAnswers), + ); expect(categories).toStrictEqual([ expect.objectContaining({ slug: 'docs', @@ -76,7 +85,9 @@ describe('jsDocsSetupBinding', () => { }); it('should omit categories when declined', () => { - const { categories } = binding.generateConfig(noCategoryAnswers); + const { categories } = binding.generateConfig( + createMockCodegenInput(noCategoryAnswers), + ); expect(categories).toBeUndefined(); }); }); diff --git a/packages/plugin-lighthouse/src/lib/binding.ts b/packages/plugin-lighthouse/src/lib/binding.ts index a7db333f6..57b992bfc 100644 --- a/packages/plugin-lighthouse/src/lib/binding.ts +++ b/packages/plugin-lighthouse/src/lib/binding.ts @@ -80,7 +80,7 @@ export const lighthouseSetupBinding = { default: CATEGORIES.map(({ slug }) => slug), }, ], - generateConfig: (answers: Record) => { + generateConfig: ({ answers }) => { const options = parseAnswers(answers); const hasCategories = options.categories.length > 0; const imports = [ diff --git a/packages/plugin-lighthouse/src/lib/binding.unit.test.ts b/packages/plugin-lighthouse/src/lib/binding.unit.test.ts index 86216aea7..cda806742 100644 --- a/packages/plugin-lighthouse/src/lib/binding.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/binding.unit.test.ts @@ -1,4 +1,5 @@ import type { PluginAnswer } from '@code-pushup/models'; +import { createMockCodegenInput } from '@code-pushup/test-utils'; import { lighthouseSetupBinding as binding } from './binding.js'; const defaultAnswers: Record = { @@ -26,20 +27,27 @@ describe('lighthouseSetupBinding', () => { describe('generateConfig with categories selected', () => { it('should declare plugin as a variable for use in category refs', () => { - expect(binding.generateConfig(defaultAnswers).pluginDeclaration).toEqual({ + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)) + .pluginDeclaration, + ).toEqual({ identifier: 'lhPlugin', expression: "lighthousePlugin('http://localhost:4200')", }); }); it('should import lighthouseGroupRefs helper', () => { - expect(binding.generateConfig(defaultAnswers).imports).toEqual([ + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)).imports, + ).toEqual([ expect.objectContaining({ namedImports: ['lighthouseGroupRefs'] }), ]); }); it('should produce categories with refs expressions for each selected group', () => { - const { categories } = binding.generateConfig(defaultAnswers); + const { categories } = binding.generateConfig( + createMockCodegenInput(defaultAnswers), + ); expect(categories).toHaveLength(4); expect(categories).toEqual([ expect.objectContaining({ @@ -62,10 +70,12 @@ describe('lighthouseSetupBinding', () => { }); it('should only include selected categories', () => { - const { categories } = binding.generateConfig({ - ...defaultAnswers, - 'lighthouse.categories': ['performance', 'seo'], - }); + const { categories } = binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'lighthouse.categories': ['performance', 'seo'], + }), + ); expect(categories).toHaveLength(2); expect(categories).toEqual([ expect.objectContaining({ slug: 'performance' }), @@ -74,26 +84,32 @@ describe('lighthouseSetupBinding', () => { }); it('should pass onlyGroups when not all categories are selected', () => { - const { pluginDeclaration } = binding.generateConfig({ - ...defaultAnswers, - 'lighthouse.categories': ['performance', 'seo'], - }); + const { pluginDeclaration } = binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'lighthouse.categories': ['performance', 'seo'], + }), + ); expect(pluginDeclaration!.expression).toContain( "onlyGroups: ['performance', 'seo']", ); }); it('should omit onlyGroups when all categories are selected', () => { - const { pluginDeclaration } = binding.generateConfig(defaultAnswers); + const { pluginDeclaration } = binding.generateConfig( + createMockCodegenInput(defaultAnswers), + ); expect(pluginDeclaration!.expression).not.toContain('onlyGroups'); }); it('should use custom URL in plugin declaration', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'lighthouse.urls': 'https://example.com', - }).pluginDeclaration, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'lighthouse.urls': 'https://example.com', + }), + ).pluginDeclaration, ).toEqual( expect.objectContaining({ expression: "lighthousePlugin('https://example.com')", @@ -103,10 +119,12 @@ describe('lighthouseSetupBinding', () => { it('should format multiple URLs as an array', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'lighthouse.urls': 'http://localhost:4200, http://localhost:4201', - }).pluginDeclaration, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'lighthouse.urls': 'http://localhost:4200, http://localhost:4201', + }), + ).pluginDeclaration, ).toEqual( expect.objectContaining({ expression: @@ -119,18 +137,22 @@ describe('lighthouseSetupBinding', () => { describe('generateConfig without categories selected', () => { it('should not declare plugin as a variable', () => { expect( - binding.generateConfig(noCategoryAnswers).pluginDeclaration, + binding.generateConfig(createMockCodegenInput(noCategoryAnswers)) + .pluginDeclaration, ).toBeUndefined(); }); it('should not import lighthouseGroupRefs helper', () => { - const { imports } = binding.generateConfig(noCategoryAnswers); + const { imports } = binding.generateConfig( + createMockCodegenInput(noCategoryAnswers), + ); expect(imports[0]).not.toHaveProperty('namedImports'); }); it('should not produce categories', () => { expect( - binding.generateConfig(noCategoryAnswers).categories, + binding.generateConfig(createMockCodegenInput(noCategoryAnswers)) + .categories, ).toBeUndefined(); }); }); diff --git a/packages/plugin-typescript/src/lib/binding.ts b/packages/plugin-typescript/src/lib/binding.ts index c90f40f2e..525adbc0f 100644 --- a/packages/plugin-typescript/src/lib/binding.ts +++ b/packages/plugin-typescript/src/lib/binding.ts @@ -66,7 +66,7 @@ export const typescriptSetupBinding = { }, ]; }, - generateConfig: (answers: Record) => { + generateConfig: ({ answers }) => { const options = parseAnswers(answers); return { imports: [ diff --git a/packages/plugin-typescript/src/lib/binding.unit.test.ts b/packages/plugin-typescript/src/lib/binding.unit.test.ts index 89962adf9..590c081c5 100644 --- a/packages/plugin-typescript/src/lib/binding.unit.test.ts +++ b/packages/plugin-typescript/src/lib/binding.unit.test.ts @@ -1,6 +1,6 @@ import { vol } from 'memfs'; import type { PluginAnswer } from '@code-pushup/models'; -import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { MEMFS_VOLUME, createMockCodegenInput } from '@code-pushup/test-utils'; import { typescriptSetupBinding as binding } from './binding.js'; const defaultAnswers: Record = { @@ -81,17 +81,20 @@ describe('typescriptSetupBinding', () => { describe('generateConfig', () => { it('should omit tsconfig option when using default tsconfig.json', () => { - expect(binding.generateConfig(defaultAnswers).pluginInit).toEqual([ - 'typescriptPlugin(),', - ]); + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)) + .pluginInit, + ).toEqual(['typescriptPlugin(),']); }); it('should include tsconfig when non-default path provided', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'typescript.tsconfig': 'tsconfig.base.json', - }).pluginInit, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'typescript.tsconfig': 'tsconfig.base.json', + }), + ).pluginInit, ).toEqual([ 'typescriptPlugin({', " tsconfig: 'tsconfig.base.json',", @@ -100,7 +103,10 @@ describe('typescriptSetupBinding', () => { }); it('should generate bug-prevention category from problems group when confirmed', () => { - expect(binding.generateConfig(defaultAnswers).categories).toEqual([ + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)) + .categories, + ).toEqual([ expect.objectContaining({ slug: 'bug-prevention', refs: [ @@ -116,15 +122,19 @@ describe('typescriptSetupBinding', () => { it('should omit categories when declined', () => { expect( - binding.generateConfig({ - ...defaultAnswers, - 'typescript.categories': false, - }).categories, + binding.generateConfig( + createMockCodegenInput({ + ...defaultAnswers, + 'typescript.categories': false, + }), + ).categories, ).toBeUndefined(); }); it('should import from @code-pushup/typescript-plugin', () => { - expect(binding.generateConfig(defaultAnswers).imports).toEqual([ + expect( + binding.generateConfig(createMockCodegenInput(defaultAnswers)).imports, + ).toEqual([ { moduleSpecifier: '@code-pushup/typescript-plugin', defaultImport: 'typescriptPlugin', diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5cacf5243..6f0522e66 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -204,6 +204,12 @@ export { answerNonEmptyArray, answerString, } from './lib/plugin-answers.js'; +export { + createTree, + type FileChange, + type FileSystemAdapter, + type Tree, +} from './lib/wizard/index.js'; export { hasCodePushUpDependency, hasDependency, diff --git a/packages/utils/src/lib/wizard/index.ts b/packages/utils/src/lib/wizard/index.ts new file mode 100644 index 000000000..d19d6eb23 --- /dev/null +++ b/packages/utils/src/lib/wizard/index.ts @@ -0,0 +1,2 @@ +export type { FileChange, FileSystemAdapter, Tree } from './types.js'; +export { createTree } from './virtual-fs.js'; diff --git a/packages/utils/src/lib/wizard/types.ts b/packages/utils/src/lib/wizard/types.ts new file mode 100644 index 000000000..001c06e1b --- /dev/null +++ b/packages/utils/src/lib/wizard/types.ts @@ -0,0 +1,35 @@ +/** A single file operation recorded by the virtual tree. */ +export type FileChange = { + path: string; + type: 'CREATE' | 'UPDATE'; + content: string; +}; + +/** Abstraction over `node:fs` used by the virtual tree for disk I/O. */ +export type FileSystemAdapter = { + readFile: (path: string, encoding: 'utf8') => Promise; + writeFile: (path: string, content: string) => Promise; + exists: (path: string) => Promise; + mkdir: ( + path: string, + options: { recursive: true }, + ) => Promise; + unlink: (path: string) => Promise; +}; + +/** Virtual file system that buffers writes in memory until flushed to disk. */ +export type Tree = { + root: string; + exists: (filePath: string) => Promise; + read: (filePath: string) => Promise; + write: (filePath: string, content: string) => Promise; + listChanges: () => FileChange[]; + flush: () => Promise; +}; + +/** Internal pending-entry shape held by the virtual tree. */ +export type PendingEntry = { + content: string; + type: 'CREATE' | 'UPDATE'; + original: string | null; +}; diff --git a/packages/utils/src/lib/wizard/virtual-fs.ts b/packages/utils/src/lib/wizard/virtual-fs.ts new file mode 100644 index 000000000..725dfcb76 --- /dev/null +++ b/packages/utils/src/lib/wizard/virtual-fs.ts @@ -0,0 +1,115 @@ +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileExists } from '../file-system.js'; +import type { + FileChange, + FileSystemAdapter, + PendingEntry, + Tree, +} from './types.js'; + +const DEFAULT_FS: FileSystemAdapter = { + readFile, + writeFile, + exists: fileExists, + mkdir, + unlink, +}; + +// eslint-disable-next-line max-lines-per-function +export function createTree( + root: string, + fs: FileSystemAdapter = DEFAULT_FS, +): Tree { + const pending = new Map(); + const resolve = (filePath: string): string => path.resolve(root, filePath); + + return { + root, + + exists: async (filePath: string): Promise => + pending.has(filePath) || fs.exists(resolve(filePath)), + + read: async (filePath: string): Promise => { + const entry = pending.get(filePath); + if (entry) { + return entry.content; + } + const absolute = resolve(filePath); + if (!(await fs.exists(absolute))) { + return null; + } + return fs.readFile(absolute, 'utf8'); + }, + + write: async (filePath: string, content: string): Promise => { + const entry = pending.get(filePath); + if (entry) { + if (entry.content !== content) { + pending.set(filePath, { ...entry, content }); + } + return; + } + const absolute = resolve(filePath); + const existing = await fs.readFile(absolute, 'utf8').catch(() => null); + if (existing === content) { + return; + } + pending.set(filePath, { + content, + type: existing == null ? 'CREATE' : 'UPDATE', + original: existing, + }); + }, + + listChanges: (): FileChange[] => + [...pending.entries()].map(([filePath, { content, type }]) => ({ + path: filePath, + type, + content, + })), + + async flush(): Promise { + const written = new Set(); + try { + await [...pending.entries()].reduce>( + async (acc, [filePath, { content }]) => { + await acc; + const absolutePath = resolve(filePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content); + written.add(filePath); + return null; + }, + Promise.resolve(null), + ); + pending.clear(); + } catch (error) { + await rollback([...written], pending, fs, resolve); + throw error; + } + }, + }; +} + +async function rollback( + written: string[], + pending: Map, + fs: FileSystemAdapter, + resolve: (filePath: string) => string, +): Promise { + await Promise.allSettled( + written.map(async filePath => { + const entry = pending.get(filePath); + if (!entry) { + return; + } + const absolutePath = resolve(filePath); + if (entry.original == null) { + await fs.unlink(absolutePath); + return; + } + await fs.writeFile(absolutePath, entry.original); + }), + ); +} diff --git a/packages/utils/src/lib/wizard/virtual-fs.unit.test.ts b/packages/utils/src/lib/wizard/virtual-fs.unit.test.ts new file mode 100644 index 000000000..b6c18bb40 --- /dev/null +++ b/packages/utils/src/lib/wizard/virtual-fs.unit.test.ts @@ -0,0 +1,261 @@ +import { toUnixPath } from '../transform.js'; +import type { FileSystemAdapter } from './types.js'; +import { createTree } from './virtual-fs.js'; + +type MockFs = FileSystemAdapter & { + written: Map; + unlinked: Set; + dirs: Set; +}; + +function createMockFs( + files: Record = {}, + options: { failOnWrite?: string } = {}, +): MockFs { + const store = new Map(Object.entries(files)); + const written = new Map(); + const unlinked = new Set(); + const dirs = new Set(); + return { + written, + unlinked, + dirs, + async readFile(path: string) { + const content = store.get(toUnixPath(path)); + if (content == null) { + throw new Error(`ENOENT: no such file or directory, open '${path}'`); + } + return content; + }, + async writeFile(path: string, content: string) { + if (options.failOnWrite === toUnixPath(path)) { + throw new Error(`EACCES: permission denied, open '${path}'`); + } + store.set(toUnixPath(path), content); + written.set(toUnixPath(path), content); + }, + async exists(path: string) { + return store.has(toUnixPath(path)); + }, + async mkdir(path: string): Promise { + dirs.add(toUnixPath(path)); + }, + async unlink(path: string) { + store.delete(toUnixPath(path)); + unlinked.add(toUnixPath(path)); + }, + }; +} + +describe('createTree', () => { + it('should report the root directory', () => { + expect(createTree('/project').root).toBe('/project'); + }); + + it('should report exists() as false for non-existent files', async () => { + await expect( + createTree('/project', createMockFs()).exists('missing.ts'), + ).resolves.toBeFalse(); + }); + + it('should report exists() as true for files on disk', async () => { + await expect( + createTree( + '/project', + createMockFs({ '/project/existing.ts': 'content' }), + ).exists('existing.ts'), + ).resolves.toBeTrue(); + }); + + it('should report exists() as true for files written to the tree', async () => { + const tree = createTree('/project', createMockFs()); + await tree.write('new.ts', 'content'); + + await expect(tree.exists('new.ts')).resolves.toBeTrue(); + }); + + it('should return null from read() for non-existent files', async () => { + await expect( + createTree('/project', createMockFs()).read('missing.ts'), + ).resolves.toBeNull(); + }); + + it('should read files from disk', async () => { + await expect( + createTree( + '/project', + createMockFs({ '/project/existing.ts': 'disk content' }), + ).read('existing.ts'), + ).resolves.toBe('disk content'); + }); + + it('should return pending content over disk content from read()', async () => { + const tree = createTree( + '/project', + createMockFs({ '/project/file.ts': 'old' }), + ); + await tree.write('file.ts', 'new'); + + await expect(tree.read('file.ts')).resolves.toBe('new'); + }); + + it('should mark new files as CREATE on write()', async () => { + const tree = createTree('/project', createMockFs()); + await tree.write('new.ts', 'content'); + + expect(tree.listChanges()).toStrictEqual([ + { path: 'new.ts', type: 'CREATE', content: 'content' }, + ]); + }); + + it('should preserve CREATE type when writing to the same path twice', async () => { + const tree = createTree('/project', createMockFs()); + await tree.write('new.ts', 'first'); + await tree.write('new.ts', 'second'); + + expect(tree.listChanges()).toStrictEqual([ + { path: 'new.ts', type: 'CREATE', content: 'second' }, + ]); + }); + + it('should mark existing files as UPDATE on write()', async () => { + const tree = createTree( + '/project', + createMockFs({ '/project/existing.ts': 'old' }), + ); + await tree.write('existing.ts', 'new'); + + expect(tree.listChanges()).toStrictEqual([ + { path: 'existing.ts', type: 'UPDATE', content: 'new' }, + ]); + }); + + it('should skip recording when written content matches disk content', async () => { + const tree = createTree( + '/project', + createMockFs({ '/project/existing.ts': 'same' }), + ); + await tree.write('existing.ts', 'same'); + + expect(tree.listChanges()).toStrictEqual([]); + }); + + it('should skip re-recording when pending content is overwritten with the same value', async () => { + const tree = createTree('/project', createMockFs()); + await tree.write('new.ts', 'content'); + const before = tree.listChanges(); + await tree.write('new.ts', 'content'); + + expect(tree.listChanges()).toStrictEqual(before); + }); + + it('should return empty array from listChanges() when no changes are detected', () => { + expect(createTree('/project', createMockFs()).listChanges()).toStrictEqual( + [], + ); + }); + + it('should return all pending changes from listChanges()', async () => { + const tree = createTree( + '/project', + createMockFs({ '/project/existing.ts': 'old' }), + ); + await tree.write('new.ts', 'created'); + await tree.write('existing.ts', 'updated'); + + expect(tree.listChanges()).toHaveLength(2); + expect(tree.listChanges()).toContainEqual({ + path: 'new.ts', + type: 'CREATE', + content: 'created', + }); + expect(tree.listChanges()).toContainEqual({ + path: 'existing.ts', + type: 'UPDATE', + content: 'updated', + }); + }); + + it('should buffer writes without touching the fs until flush()', async () => { + const fs = createMockFs(); + const tree = createTree('/project', fs); + await tree.write('first.ts', 'one'); + await tree.write('second.ts', 'two'); + + expect(fs.written.size).toBe(0); + }); + + it('should write all pending files to the fs on flush()', async () => { + const fs = createMockFs(); + const tree = createTree('/project', fs); + await tree.write('src/config.ts', 'export default {};'); + + await tree.flush(); + + expect(fs.written.get('/project/src/config.ts')).toBe('export default {};'); + }); + + it('should create parent directories on flush()', async () => { + const fs = createMockFs(); + const tree = createTree('/project', fs); + await tree.write('src/deep/config.ts', 'content'); + + await tree.flush(); + + expect(fs.dirs).toContain('/project/src/deep'); + }); + + it('should clear pending changes after flush()', async () => { + const tree = createTree('/project', createMockFs()); + await tree.write('file.ts', 'content'); + + await tree.flush(); + + expect(tree.listChanges()).toStrictEqual([]); + }); + + it('should not write anything on flush() when no changes are pending', async () => { + const fs = createMockFs(); + + await createTree('/project', fs).flush(); + + expect(fs.written.size).toBe(0); + }); + + it('should rollback created files by unlinking them when a later write fails', async () => { + const fs = createMockFs({}, { failOnWrite: '/project/second.ts' }); + const tree = createTree('/project', fs); + await tree.write('first.ts', 'one'); + await tree.write('second.ts', 'two'); + + await expect(tree.flush()).rejects.toThrow(/EACCES/); + + expect(fs.unlinked).toContain('/project/first.ts'); + }); + + it('should rollback updated files by restoring original content when a later write fails', async () => { + const fs = createMockFs( + { '/project/existing.ts': 'original' }, + { failOnWrite: '/project/second.ts' }, + ); + const tree = createTree('/project', fs); + await tree.write('existing.ts', 'modified'); + await tree.write('second.ts', 'new'); + + await expect(tree.flush()).rejects.toThrow(/EACCES/); + + expect(fs.written.get('/project/existing.ts')).toBe('original'); + expect(fs.unlinked).not.toContain('/project/existing.ts'); + }); + + it('should keep pending changes after a failed flush() so it can be retried', async () => { + const fs = createMockFs({}, { failOnWrite: '/project/second.ts' }); + const tree = createTree('/project', fs); + await tree.write('first.ts', 'one'); + await tree.write('second.ts', 'two'); + + await expect(tree.flush()).rejects.toThrow(/EACCES/); + + expect(tree.listChanges()).toHaveLength(2); + }); +}); diff --git a/testing/test-utils/src/index.ts b/testing/test-utils/src/index.ts index 29e1d6a63..5ae4b52c6 100644 --- a/testing/test-utils/src/index.ts +++ b/testing/test-utils/src/index.ts @@ -12,4 +12,5 @@ export * from './lib/utils/project-graph.js'; export * from './lib/utils/test-folder-setup.js'; export * from './lib/utils/profiler.mock.js'; export * from './lib/utils/omit-trace-json.js'; +export * from './lib/utils/plugin-codegen-input.mock.js'; export * from './lib/utils/plugin-setup-tree.mock.js'; diff --git a/testing/test-utils/src/lib/utils/plugin-codegen-input.mock.ts b/testing/test-utils/src/lib/utils/plugin-codegen-input.mock.ts new file mode 100644 index 000000000..145ccb31c --- /dev/null +++ b/testing/test-utils/src/lib/utils/plugin-codegen-input.mock.ts @@ -0,0 +1,18 @@ +import type { + PluginAnswer, + PluginCodegenInput, + PluginSetupTree, +} from '@code-pushup/models'; +import { createMockTree } from './plugin-setup-tree.mock.js'; + +export function createMockCodegenInput( + answers: Record = {}, + tree: PluginSetupTree = createMockTree(), +): PluginCodegenInput { + return { + answers, + tree, + targetDir: '', + cliArgs: {}, + }; +}