diff --git a/.github/workflows/scripts/mailchimp/package-lock.json b/.github/workflows/scripts/mailchimp/package-lock.json index 7ee7d84f86..cc41a360da 100644 --- a/.github/workflows/scripts/mailchimp/package-lock.json +++ b/.github/workflows/scripts/mailchimp/package-lock.json @@ -594,4 +594,4 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" } } -} \ No newline at end of file +} diff --git a/apps/generator/cli.js b/apps/generator/cli.js index ffae4b0f8e..741023a6a2 100755 --- a/apps/generator/cli.js +++ b/apps/generator/cli.js @@ -19,6 +19,7 @@ let asyncapiDocPath; let template; const params = {}; const noOverwriteGlobs = []; +const generateOnly = []; const disabledHooks = {}; const mapBaseUrlToFolder = {}; @@ -33,6 +34,8 @@ const paramParser = v => { const noOverwriteParser = v => noOverwriteGlobs.push(v); +const generateOnlyParser = v => generateOnly.push(v); + const disableHooksParser = v => { const [hookType, hookNames] = v.split(/=/); if (!hookType) throw new Error('Invalid --disable-hook flag. It must be in the format of: --disable-hook or --disable-hook =,,...'); @@ -86,6 +89,7 @@ program .option('--debug', 'enable more specific errors in the console') .option('-i, --install', 'installs the template and its dependencies (defaults to false)') .option('-n, --no-overwrite ', 'glob or path of the file(s) to skip when regenerating', noOverwriteParser) + .option('-g, --generate-only ', 'glob or path of the file(s) to generate. Only files matching patterns will be generated', generateOnlyParser) .option('-o, --output ', 'directory where to put the generated files (defaults to current directory)', parseOutput, process.cwd()) .option('-p, --param ', 'additional param to pass to templates', paramParser) .option('--force-write', 'force writing of the generated files to given directory even if it is a git repo with unstaged files or not empty dir (defaults to false)') @@ -153,6 +157,7 @@ function generate(targetDir) { const generator = new Generator(template, targetDir || path.resolve(os.tmpdir(), 'asyncapi-generator'), { templateParams: params, noOverwriteGlobs, + generateOnly, disabledHooks, forceWrite: program.forceWrite, install: program.install, diff --git a/apps/generator/lib/generator.js b/apps/generator/lib/generator.js index 93f6f72cc9..f2eb29f957 100644 --- a/apps/generator/lib/generator.js +++ b/apps/generator/lib/generator.js @@ -23,7 +23,9 @@ const { readFile, readDir, writeFile, + writeFileWithFiltering, copyFile, + copyFileWithFiltering, exists, fetchSpec, isReactTemplate, @@ -46,7 +48,7 @@ const DEFAULT_TEMPLATES_DIR = path.resolve(ROOT_DIR, 'node_modules'); const TRANSPILED_TEMPLATE_LOCATION = '__transpiled'; const TEMPLATE_CONTENT_DIRNAME = 'template'; -const GENERATOR_OPTIONS = ['debug', 'disabledHooks', 'entrypoint', 'forceWrite', 'install', 'noOverwriteGlobs', 'output', 'templateParams', 'mapBaseUrlToFolder', 'url', 'auth', 'token', 'registry', 'compile']; +const GENERATOR_OPTIONS = new Set(['debug', 'disabledHooks', 'entrypoint', 'forceWrite', 'install', 'noOverwriteGlobs', 'generateOnly', 'output', 'templateParams', 'mapBaseUrlToFolder', 'url', 'auth', 'token', 'registry', 'compile']); const logMessage = require('./logMessages'); const shouldIgnoreFile = filePath => @@ -78,6 +80,7 @@ class Generator { * @param {Object} [options.templateParams] Optional parameters to pass to the template. Each template define their own params. * @param {String} [options.entrypoint] Name of the file to use as the entry point for the rendering process. Use in case you want to use only a specific template file. Note: this potentially avoids rendering every file in the template. * @param {String[]} [options.noOverwriteGlobs] List of globs to skip when regenerating the template. + * @param {String[]} [options.generateOnly] List of globs to specify which files should be generated. Only files matching these patterns will be generated. * @param {Object} [options.disabledHooks] Object with hooks to disable. The key is a hook type. If key has "true" value, then the generator skips all hooks from the given type. If the value associated with a key is a string with the name of a single hook, then the generator skips only this single hook name. If the value associated with a key is an array of strings, then the generator skips only hooks from the array. * @param {String} [options.output='fs'] Type of output. Can be either 'fs' (default) or 'string'. Only available when entrypoint is set. * @param {Boolean} [options.forceWrite=false] Force writing of the generated files to given directory even if it is a git repo with unstaged files or not empty dir. Default is set to false. @@ -91,7 +94,7 @@ class Generator { * @param {String} [options.registry.token] Optional parameter to pass npm registry auth token that you can grab from .npmrc file */ - constructor(templateName, targetDir, { templateParams = {}, entrypoint, noOverwriteGlobs, disabledHooks, output = 'fs', forceWrite = false, install = false, debug = false, mapBaseUrlToFolder = {}, registry = {}, compile = true } = {}) { + constructor(templateName, targetDir, { templateParams = {}, entrypoint, noOverwriteGlobs, generateOnly, disabledHooks, output = 'fs', forceWrite = false, install = false, debug = false, mapBaseUrlToFolder = {}, registry = {}, compile = true } = {}) { const options = arguments[arguments.length - 1]; this.verifyoptions(options); if (!templateName) throw new Error('No template name has been specified.'); @@ -109,6 +112,8 @@ class Generator { this.entrypoint = entrypoint; /** @type {String[]} List of globs to skip when regenerating the template. */ this.noOverwriteGlobs = noOverwriteGlobs || []; + /** @type {String[]} List of globs to specify which files should be generated. */ + this.generateOnly = generateOnly || []; /** @type {Object} Object with hooks to disable. The key is a hook type. If key has "true" value, then the generator skips all hooks from the given type. If the value associated with a key is a string with the name of a single hook, then the generator skips only this single hook name. If the value associated with a key is an array of strings, then the generator skips only hooks from the array. */ this.disabledHooks = disabledHooks || {}; /** @type {String} Type of output. Can be either 'fs' (default) or 'string'. Only available when entrypoint is set. */ @@ -125,6 +130,8 @@ class Generator { this.hooks = {}; /** @type {Object} Maps schema URL to folder. */ this.mapBaseUrlToFolder = mapBaseUrlToFolder; + /** @type {number} Counter for successfully generated files. */ + this.generatedFilesCount = 0; // Load template configuration /** @type {Object} The template parameters. The structure for this object is based on each individual template. */ @@ -153,7 +160,7 @@ class Generator { verifyoptions(Options) { if (typeof Options !== 'object') return []; - const invalidOptions = Object.keys(Options).filter(param => !GENERATOR_OPTIONS.includes(param)); + const invalidOptions = Object.keys(Options).filter(param => !GENERATOR_OPTIONS.has(param)); if (invalidOptions.length > 0) { throw new Error(`These options are not supported by the generator: ${invalidOptions.join(', ')}`); @@ -192,6 +199,7 @@ class Generator { */ async generate(asyncapiDocument, parseOptions = {}) { this.validateAsyncAPIDocument(asyncapiDocument); + this.generatedFilesCount = 0; await this.setupOutput(); this.setLogLevel(); @@ -199,6 +207,7 @@ class Generator { await this.configureTemplateWorkflow(parseOptions); await this.handleEntrypoint(); await this.executeAfterHook(); + this.warnIfNoGenerateOnlyMatches(); } /** @@ -877,9 +886,13 @@ class Generator { if (renderContent === undefined) { return; } else if (isReactTemplate(this.templateConfig)) { - await saveRenderedReactContent(renderContent, outputpath, this.noOverwriteGlobs); + const writtenCount = await saveRenderedReactContent(renderContent, outputpath, this.noOverwriteGlobs, this.generateOnly, this.targetDir); + this.generatedFilesCount += writtenCount; } else { - await writeFile(outputpath, renderContent); + const written = await writeFileWithFiltering(outputpath, renderContent, {}, this.targetDir, this.noOverwriteGlobs, this.generateOnly); + if (written) { + this.generatedFilesCount += 1; + } } } @@ -946,8 +959,14 @@ class Generator { return log.debug(logMessage.conditionalGenerationMatched(conditionalPath)); } - - if (this.isNonRenderableFile(relativeSourceFile)) return await copyFile(sourceFile, targetFile); + + if (this.isNonRenderableFile(relativeSourceFile)) { + const copied = await copyFileWithFiltering(sourceFile, targetFile, this.targetDir, this.noOverwriteGlobs, this.generateOnly); + if (copied) { + this.generatedFilesCount += 1; + } + return; + } await this.renderAndWriteToFile(asyncapiDocument, sourceFile, targetFile); log.debug(`Successfully rendered template and wrote file ${relativeSourceFile} to location: ${targetFile}`); } @@ -1017,6 +1036,18 @@ class Generator { return !this.noOverwriteGlobs.some(globExp => minimatch(filePath, globExp)); } + /** + * warn when generateOnly is set but no files were generated. + * + * @private + */ + warnIfNoGenerateOnlyMatches() { + if (this.output !== 'fs') return; + if (Array.isArray(this.generateOnly) && this.generateOnly.length > 0 && this.generatedFilesCount === 0) { + log.warn(logMessage.generateOnlyNoMatches(this.generateOnly)); + } + } + /** * Launches all the hooks registered at a given hook point/name. * diff --git a/apps/generator/lib/logMessages.js b/apps/generator/lib/logMessages.js index 614eaae1b1..349a4f2819 100644 --- a/apps/generator/lib/logMessages.js +++ b/apps/generator/lib/logMessages.js @@ -41,6 +41,14 @@ function skipOverwrite(testFilePath) { return `Skipping overwrite for: ${testFilePath}`; } +function skipGenerateOnly(filePath) { + return `Skipping file "${filePath}" because it does not match any generateOnly patterns.`; +} + +function generateOnlyNoMatches(patterns) { + return `No files matched the generateOnly patterns: ${patterns.join(', ')}`; +} + function conditionalGenerationMatched(conditionalPath) { return `${conditionalPath} was not generated because condition specified for this location in template configuration in conditionalGeneration matched.`; } @@ -69,5 +77,7 @@ module.exports = { conditionalGenerationMatched, conditionalFilesMatched, compileEnabled, - skipOverwrite + skipOverwrite, + skipGenerateOnly, + generateOnlyNoMatches }; diff --git a/apps/generator/lib/renderer/react.js b/apps/generator/lib/renderer/react.js index 5a8171b8b9..7b4959b30a 100644 --- a/apps/generator/lib/renderer/react.js +++ b/apps/generator/lib/renderer/react.js @@ -1,10 +1,9 @@ const path = require('path'); const AsyncReactSDK = require('@asyncapi/generator-react-sdk'); -const minimatch = require('minimatch'); const logMessage = require('../logMessages.js'); const log = require('loglevel'); const { - writeFile + writeFileWithFiltering } = require('../utils'); const reactExport = module.exports; @@ -62,8 +61,9 @@ reactExport.renderReact = async (asyncapiDocument, filePath, extraTemplateData, * @private * @param {TemplateRenderResult} renderedContent the react content rendered * @param {String} outputPath Path to the file being rendered. + * @param {String} targetDir Target directory for relative path calculations. */ -const saveContentToFile = async (renderedContent, outputPath, noOverwriteGlobs = []) => { +const saveContentToFile = async (renderedContent, outputPath, noOverwriteGlobs = [], generateOnly = [], targetDir = process.cwd()) => { let filePath = outputPath; // Might be the same as in the `fs` package, but is an active choice for our default file permission for any rendered files. let permissions = 0o666; @@ -83,19 +83,16 @@ const saveContentToFile = async (renderedContent, outputPath, noOverwriteGlobs = } } - // get the final file name of the file - const finalFileName = path.basename(filePath); - // check whether the filename should be ignored based on user's inputs - const shouldOverwrite = !noOverwriteGlobs.some(globExp => minimatch(finalFileName, globExp)); + const written = await writeFileWithFiltering( + filePath, + content, + { mode: permissions }, + targetDir, + noOverwriteGlobs, + generateOnly + ); - // Write the file only if it should not be skipped - if (shouldOverwrite) { - await writeFile(filePath, content, { - mode: permissions - }); - } else { - await log.debug(logMessage.skipOverwrite(filePath)); - } + return written ? 1 : 0; }; /** @@ -105,10 +102,13 @@ const saveContentToFile = async (renderedContent, outputPath, noOverwriteGlobs = * @param {TemplateRenderResult[] | TemplateRenderResult} renderedContent the react content rendered * @param {String} outputPath Path to the file being rendered. * @param noOverwriteGlobs Array of globs to skip overwriting files. + * @param generateOnly array of globs to specify which files should be generated. + * @param targetDir target directory for relative path calculations. */ -reactExport.saveRenderedReactContent = async (renderedContent, outputPath, noOverwriteGlobs = []) => { +reactExport.saveRenderedReactContent = async (renderedContent, outputPath, noOverwriteGlobs = [], generateOnly = [], targetDir = process.cwd()) => { if (Array.isArray(renderedContent)) { - return Promise.all(renderedContent.map(content => saveContentToFile(content, outputPath, noOverwriteGlobs))); + const results = await Promise.all(renderedContent.map(content => saveContentToFile(content, outputPath, noOverwriteGlobs, generateOnly, targetDir))); + return results.reduce((acc, val) => acc + (val || 0), 0); } - return await saveContentToFile(renderedContent, outputPath, noOverwriteGlobs); + return await saveContentToFile(renderedContent, outputPath, noOverwriteGlobs, generateOnly, targetDir); }; diff --git a/apps/generator/lib/utils.js b/apps/generator/lib/utils.js index e43d093639..3b1b5166d5 100644 --- a/apps/generator/lib/utils.js +++ b/apps/generator/lib/utils.js @@ -7,6 +7,7 @@ const resolvePkg = require('resolve-pkg'); const resolveFrom = require('resolve-from'); const globalDirs = require('global-dirs'); const log = require('loglevel'); +const minimatch = require('minimatch'); const packageJson = require('../package.json'); @@ -32,6 +33,102 @@ utils.exists = async (path) => { } }; +/** + * determine if a file should be generated based on generateOnly globs. + * @param {String} relativeFilePath Target-relative file path. + * @param {String[]} generateOnly Globs whitelist. + * @returns {Boolean} True if generation is allowed. + */ +function isAllowedByGenerateOnly(relativeFilePath, generateOnly = []) { + if (!Array.isArray(generateOnly) || generateOnly.length === 0) return true; + + let allowed = false; + let excluded = false; + + for (const globExp of generateOnly) { + if (typeof globExp !== 'string') continue; + const isNegation = globExp.startsWith('!'); + const pattern = isNegation ? globExp.slice(1) : globExp; + + if (minimatch(relativeFilePath, pattern)) { + if (isNegation) excluded = true; + else allowed = true; + } + } + + return allowed && !excluded; +} + +/** + * determine if an existing file should be skipped based on noOverwriteGlobs. + * @param {String} relativeFilePath Target-relative file path. + * @param {Boolean} fileExists Whether the target file already exists. + * @param {String[]} noOverwriteGlobs Globs blacklist for overwriting. + * @returns {Boolean} True if overwrite should be skipped. + */ +function shouldSkipOverwrite(relativeFilePath, fileExists, noOverwriteGlobs = []) { + if (!fileExists) return false; + if (!Array.isArray(noOverwriteGlobs) || noOverwriteGlobs.length === 0) return false; + return noOverwriteGlobs.some(globExp => minimatch(relativeFilePath, globExp)); +} + +/** + * writes a file with generateOnly and noOverwriteGlobs filtering + * + * @param {String} filePath Absolute path to the file to write. + * @param {String|Buffer} content Content to write. + * @param {Object} options Options to pass to fs.writeFile (e.g., { mode: 0o666 }). + * @param {String} targetDir Target directory for calculating relative paths. + * @param {String[]} noOverwriteGlobs Array of glob patterns for files to skip overwriting. + * @param {String[]} generateOnly Array of glob patterns for files to generate (whitelist). + * @returns {Promise} True if file was written, false if skipped. + */ +utils.writeFileWithFiltering = async (filePath, content, options, targetDir, noOverwriteGlobs = [], generateOnly = []) => { + const relativeFilePath = path.relative(targetDir, filePath); + + if (!isAllowedByGenerateOnly(relativeFilePath, generateOnly)) { + log.debug(logMessage.skipGenerateOnly(filePath)); + return false; + } + + const fileExists = await utils.exists(filePath); + if (shouldSkipOverwrite(relativeFilePath, fileExists, noOverwriteGlobs)) { + log.debug(logMessage.skipOverwrite(filePath)); + return false; + } + + await utils.writeFile(filePath, content, options); + return true; +}; + +/** + * copies a file with generateOnly and noOverwriteGlobs filtering. + * + * @param {String} sourcePath Absolute path to the source file. + * @param {String} targetPath Absolute path to the destination file. + * @param {String} targetDir Target directory for calculating relative paths. + * @param {String[]} noOverwriteGlobs Array of glob patterns for files to skip overwriting. + * @param {String[]} generateOnly Array of glob patterns for files to generate (whitelist). + * @returns {Promise} True if file was copied, false if skipped. + */ +utils.copyFileWithFiltering = async (sourcePath, targetPath, targetDir, noOverwriteGlobs = [], generateOnly = []) => { + const relativeFilePath = path.relative(targetDir, targetPath); + + if (!isAllowedByGenerateOnly(relativeFilePath, generateOnly)) { + log.debug(logMessage.skipGenerateOnly(targetPath)); + return false; + } + + const fileExists = await utils.exists(targetPath); + if (shouldSkipOverwrite(relativeFilePath, fileExists, noOverwriteGlobs)) { + log.debug(logMessage.skipOverwrite(targetPath)); + return false; + } + + await utils.copyFile(sourcePath, targetPath); + return true; +}; + /** * Checks if a string is a filesystem path. * @private diff --git a/apps/generator/test/generator.test.js b/apps/generator/test/generator.test.js index ae0a7660f3..23c2324bc5 100644 --- a/apps/generator/test/generator.test.js +++ b/apps/generator/test/generator.test.js @@ -22,6 +22,7 @@ describe('Generator', () => { expect(gen.targetDir).toStrictEqual(__dirname); expect(gen.entrypoint).toStrictEqual(undefined); expect(gen.noOverwriteGlobs).toStrictEqual([]); + expect(gen.generateOnly).toStrictEqual([]); expect(gen.disabledHooks).toStrictEqual({}); expect(gen.output).toStrictEqual('fs'); expect(gen.forceWrite).toStrictEqual(false); @@ -34,6 +35,7 @@ describe('Generator', () => { const gen = new Generator('testTemplate', __dirname, { entrypoint: 'test-entrypoint', noOverwriteGlobs: ['test-globs'], + generateOnly: ['test-generate-globs'], disabledHooks: { 'test-hooks': true, 'generate:after': ['foo', 'bar'], foo: 'bar' }, output: 'string', forceWrite: true, @@ -47,6 +49,7 @@ describe('Generator', () => { expect(gen.targetDir).toStrictEqual(__dirname); expect(gen.entrypoint).toStrictEqual('test-entrypoint'); expect(gen.noOverwriteGlobs).toStrictEqual(['test-globs']); + expect(gen.generateOnly).toStrictEqual(['test-generate-globs']); expect(gen.disabledHooks).toStrictEqual({ 'test-hooks': true, 'generate:after': ['foo', 'bar'], foo: 'bar' }); expect(gen.output).toStrictEqual('string'); expect(gen.forceWrite).toStrictEqual(true); @@ -106,6 +109,30 @@ describe('Generator', () => { }); }); + describe('#warnIfNoGenerateOnlyMatches', () => { + it('warns when generateOnly is set and no files were generated', () => { + const gen = new Generator('testTemplate', __dirname, { generateOnly: ['*.json'] }); + const warnSpy = jest.spyOn(require('loglevel'), 'warn').mockImplementation(() => {}); + + gen.generatedFilesCount = 0; + gen.warnIfNoGenerateOnlyMatches(); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('does not warn when files were generated', () => { + const gen = new Generator('testTemplate', __dirname, { generateOnly: ['*.json'] }); + const warnSpy = jest.spyOn(require('loglevel'), 'warn').mockImplementation(() => {}); + + gen.generatedFilesCount = 2; + gen.warnIfNoGenerateOnlyMatches(); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + describe('#generate', () => { let asyncApiDocumentMock; let xfsMock; diff --git a/apps/generator/test/integration.test.js b/apps/generator/test/integration.test.js index 0c95805fc8..cee5c29594 100644 --- a/apps/generator/test/integration.test.js +++ b/apps/generator/test/integration.test.js @@ -154,6 +154,74 @@ describe('Integration testing generateFromFile() to make sure the result of the */ }); + it('should generate only specified files with generateOnly', async () => { + const outputDir = generateFolderName(); + const cleanReactTemplate = await getCleanReactTemplate(); + + const generator = new Generator(cleanReactTemplate, outputDir, { + forceWrite: true, + generateOnly: ['package.json'], + debug: true, + }); + + await generator.generateFromFile(dummySpecPath); + + const packageJsonPath = path.join(outputDir, 'package.json'); + const packageJsonExists = await access(packageJsonPath).then(() => true).catch(() => false); + expect(packageJsonExists).toBe(true); + + const testFilePath = path.normalize(path.resolve(outputDir, testOutputFile)); + const testFileExists = await access(testFilePath).then(() => true).catch(() => false); + expect(testFileExists).toBe(false); + }); + + it('should generate only files matching glob patterns with generateOnly', async () => { + const outputDir = generateFolderName(); + const cleanReactTemplate = await getCleanReactTemplate(); + + const generator = new Generator(cleanReactTemplate, outputDir, { + forceWrite: true, + generateOnly: ['*.json'], + debug: true, + }); + + await generator.generateFromFile(dummySpecPath); + + const packageJsonPath = path.join(outputDir, 'package.json'); + const packageJsonExists = await access(packageJsonPath).then(() => true).catch(() => false); + expect(packageJsonExists).toBe(true); + + const testFilePath = path.normalize(path.resolve(outputDir, testOutputFile)); + const testFileExists = await access(testFilePath).then(() => true).catch(() => false); + expect(testFileExists).toBe(false); + }); + + it('should work with both generateOnly and noOverwriteGlobs', async () => { + const outputDir = generateFolderName(); + const cleanReactTemplate = await getCleanReactTemplate(); + + await mkdir(outputDir, { recursive: true }); + const customPackageContent = '{"custom": "content"}'; + const packageJsonPath = path.join(outputDir, 'package.json'); + await writeFile(packageJsonPath, customPackageContent); + + const generator = new Generator(cleanReactTemplate, outputDir, { + forceWrite: true, + generateOnly: ['*.json'], + noOverwriteGlobs: ['package.json'], + debug: true, + }); + + await generator.generateFromFile(dummySpecPath); + + const packageContent = await readFile(packageJsonPath, 'utf8'); + expect(packageContent).toBe(customPackageContent); + + const testFilePath = path.normalize(path.resolve(outputDir, testOutputFile)); + const testFileExists = await access(testFilePath).then(() => true).catch(() => false); + expect(testFileExists).toBe(false); + }); + it('should not generate the conditionalFolder if the singleFolder parameter is set true', async () => { const outputDir = generateFolderName(); const generator = new Generator(reactTemplate, outputDir, { diff --git a/apps/generator/test/renderer.test.js b/apps/generator/test/renderer.test.js index bd1a6489ea..905daa50df 100644 --- a/apps/generator/test/renderer.test.js +++ b/apps/generator/test/renderer.test.js @@ -1,13 +1,16 @@ /* eslint-disable sonarjs/no-duplicate-string */ -const { + +jest.mock('../lib/utils', () => ({ + writeFileWithFiltering: jest.fn().mockResolvedValue(true), +})); +jest.mock('@asyncapi/generator-react-sdk'); + +const { configureReact, renderReact, saveRenderedReactContent, } = require('../lib/renderer/react'); -jest.mock('../lib/utils'); -jest.mock('@asyncapi/generator-react-sdk'); - describe('React renderer', () => { describe('saveRenderedReactContent', () => { let util; @@ -35,8 +38,9 @@ describe('React renderer', () => { } }; - await saveRenderedReactContent(content, '../some/path'); - expect(util.writeFile).toHaveBeenCalledTimes(1); + const written = await saveRenderedReactContent(content, '../some/path'); + expect(util.writeFileWithFiltering).toHaveBeenCalledTimes(1); + expect(written).toBe(1); }); it('works saving multiple rendered contents', async () => { @@ -55,8 +59,9 @@ describe('React renderer', () => { }, ]; - await saveRenderedReactContent(content, '../some/path'); - expect(util.writeFile).toHaveBeenCalledTimes(2); + const written = await saveRenderedReactContent(content, '../some/path'); + expect(util.writeFileWithFiltering).toHaveBeenCalledTimes(2); + expect(written).toBe(2); }); }); }); diff --git a/apps/generator/test/test-templates/react-template/package-lock.json b/apps/generator/test/test-templates/react-template/package-lock.json index 58e378f92c..ba42f44a83 100644 --- a/apps/generator/test/test-templates/react-template/package-lock.json +++ b/apps/generator/test/test-templates/react-template/package-lock.json @@ -8,7 +8,7 @@ "name": "react-template", "version": "0.0.1", "dependencies": { - "@asyncapi/generator-components": "0.1.0", + "@asyncapi/generator-components": "*", "@asyncapi/generator-react-sdk": "*" } }, diff --git a/apps/generator/test/utils.test.js b/apps/generator/test/utils.test.js index fa2e72beaa..22f2eb8723 100644 --- a/apps/generator/test/utils.test.js +++ b/apps/generator/test/utils.test.js @@ -126,4 +126,167 @@ describe('Utils', () => { expect(isReactTemplate).toBeFalsy(); }); }); + + describe('#writeFileWithFiltering', () => { + const targetDir = '/tmp/test-target'; + const testFilePath = path.join(targetDir, 'test.txt'); + const testContent = 'test content'; + + beforeEach(() => { + utils.writeFile = jest.fn().mockResolvedValue(undefined); + utils.exists = jest.fn().mockResolvedValue(false); + log.debug = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('generateOnly filtering (whitelist)', () => { + it('writes file when generateOnly is not set', async () => { + const result = await utils.writeFileWithFiltering(testFilePath, testContent, {}, targetDir, [], []); + expect(result).toBe(true); + expect(utils.writeFile).toHaveBeenCalledWith(testFilePath, testContent, {}); + }); + + it('writes file when generateOnly is empty array', async () => { + const result = await utils.writeFileWithFiltering(testFilePath, testContent, {}, targetDir, [], []); + expect(result).toBe(true); + expect(utils.writeFile).toHaveBeenCalledWith(testFilePath, testContent, {}); + }); + + it('writes file when it matches single pattern', async () => { + const jsonFilePath = path.join(targetDir, 'package.json'); + const result = await utils.writeFileWithFiltering(jsonFilePath, testContent, {}, targetDir, [], ['*.json']); + expect(result).toBe(true); + expect(utils.writeFile).toHaveBeenCalledWith(jsonFilePath, testContent, {}); + }); + + it('skips file when it does not match pattern', async () => { + const jsFilePath = path.join(targetDir, 'index.js'); + const result = await utils.writeFileWithFiltering(jsFilePath, testContent, {}, targetDir, [], ['*.json']); + expect(result).toBe(false); + expect(utils.writeFile).not.toHaveBeenCalled(); + expect(log.debug).toHaveBeenCalledWith(logMessage.skipGenerateOnly(jsFilePath)); + }); + + it('writes file when it matches any pattern from multiple patterns', async () => { + const jsonFilePath = path.join(targetDir, 'package.json'); + const jsFilePath = path.join(targetDir, 'src/models/User.js'); + + const result1 = await utils.writeFileWithFiltering(jsonFilePath, testContent, {}, targetDir, [], ['*.json', 'src/**/*.js']); + const result2 = await utils.writeFileWithFiltering(jsFilePath, testContent, {}, targetDir, [], ['*.json', 'src/**/*.js']); + + expect(result1).toBe(true); + expect(result2).toBe(true); + expect(utils.writeFile).toHaveBeenCalledTimes(2); + }); + + it('skips file when it does not match any pattern from multiple patterns', async () => { + const mdFilePath = path.join(targetDir, 'README.md'); + const result = await utils.writeFileWithFiltering(mdFilePath, testContent, {}, targetDir, [], ['*.json', 'src/**/*.js']); + expect(result).toBe(false); + expect(utils.writeFile).not.toHaveBeenCalled(); + }); + + it('works with complex glob patterns', async () => { + const userFilePath = path.join(targetDir, 'src/models/User.java'); + const daoFilePath = path.join(targetDir, 'src/models/dao/UserDao.java'); + + const result1 = await utils.writeFileWithFiltering(userFilePath, testContent, {}, targetDir, [], ['src/models/**/*.java', '!src/models/**/Test*.java']); + const result2 = await utils.writeFileWithFiltering(daoFilePath, testContent, {}, targetDir, [], ['src/models/**/*.java', '!src/models/**/Test*.java']); + + expect(result1).toBe(true); + expect(result2).toBe(true); + }); + + it('excludes files when negated patterns match', async () => { + const chatFilePath = path.join(targetDir, 'src/api/routes/chat.js'); + const testRouteFilePath = path.join(targetDir, 'src/api/routes/testRoute.js'); + + const result1 = await utils.writeFileWithFiltering(chatFilePath, testContent, {}, targetDir, [], ['src/**/*.js', '!src/**/test*.js']); + const result2 = await utils.writeFileWithFiltering(testRouteFilePath, testContent, {}, targetDir, [], ['src/**/*.js', '!src/**/test*.js']); + + expect(result1).toBe(true); + expect(result2).toBe(false); + expect(log.debug).toHaveBeenCalledWith(logMessage.skipGenerateOnly(testRouteFilePath)); + }); + }); + + describe('noOverwriteGlobs filtering (blacklist)', () => { + it('skips overwriting existing file that matches noOverwriteGlobs', async () => { + const existingFilePath = path.join(targetDir, 'package.json'); + utils.exists = jest.fn().mockResolvedValue(true); + + const result = await utils.writeFileWithFiltering(existingFilePath, testContent, {}, targetDir, ['*.json'], []); + + expect(result).toBe(false); + expect(utils.writeFile).not.toHaveBeenCalled(); + expect(log.debug).toHaveBeenCalledWith(logMessage.skipOverwrite(existingFilePath)); + }); + + it('writes new file even if it matches noOverwriteGlobs', async () => { + const newFilePath = path.join(targetDir, 'package.json'); + utils.exists = jest.fn().mockResolvedValue(false); + + const result = await utils.writeFileWithFiltering(newFilePath, testContent, {}, targetDir, ['*.json'], []); + + expect(result).toBe(true); + expect(utils.writeFile).toHaveBeenCalledWith(newFilePath, testContent, {}); + }); + + it('overwrites existing file that does not match noOverwriteGlobs', async () => { + const existingFilePath = path.join(targetDir, 'index.js'); + utils.exists = jest.fn().mockResolvedValue(true); + + const result = await utils.writeFileWithFiltering(existingFilePath, testContent, {}, targetDir, ['*.json'], []); + + expect(result).toBe(true); + expect(utils.writeFile).toHaveBeenCalledWith(existingFilePath, testContent, {}); + }); + }); + + describe('combined filtering (generateOnly + noOverwriteGlobs)', () => { + it('applies generateOnly filter first, then noOverwriteGlobs', async () => { + const jsonFilePath = path.join(targetDir, 'package.json'); + utils.exists = jest.fn().mockResolvedValue(true); + + const result = await utils.writeFileWithFiltering(jsonFilePath, testContent, {}, targetDir, ['*.json'], ['*.json']); + + expect(result).toBe(false); + expect(utils.writeFile).not.toHaveBeenCalled(); + expect(log.debug).toHaveBeenCalledWith(logMessage.skipOverwrite(jsonFilePath)); + }); + + it('skips file that does not match generateOnly, ignoring noOverwriteGlobs', async () => { + const jsFilePath = path.join(targetDir, 'index.js'); + + const result = await utils.writeFileWithFiltering(jsFilePath, testContent, {}, targetDir, ['package.json'], ['*.json']); + + expect(result).toBe(false); + expect(utils.writeFile).not.toHaveBeenCalled(); + expect(log.debug).toHaveBeenCalledWith(logMessage.skipGenerateOnly(jsFilePath)); + }); + + it('writes file that matches generateOnly and does not match noOverwriteGlobs', async () => { + const jsonFilePath = path.join(targetDir, 'tsconfig.json'); + utils.exists = jest.fn().mockResolvedValue(true); + + const result = await utils.writeFileWithFiltering(jsonFilePath, testContent, {}, targetDir, ['package.json'], ['*.json']); + + expect(result).toBe(true); + expect(utils.writeFile).toHaveBeenCalledWith(jsonFilePath, testContent, {}); + }); + }); + + describe('file options', () => { + it('passes file options to writeFile', async () => { + const options = { mode: 0o755 }; + const result = await utils.writeFileWithFiltering(testFilePath, testContent, options, targetDir, [], []); + + expect(result).toBe(true); + expect(utils.writeFile).toHaveBeenCalledWith(testFilePath, testContent, options); + }); + }); + }); });