diff --git a/package-lock.json b/package-lock.json index dc51c2298d22..26467c21b53e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "next-mdx-remote": "^4.4.1", "node-fetch": "^3.3.2", "node-fetch-2": "npm:node-fetch@^2.7.0", + "p-limit": "^3.1.0", "postcss": "^8.4.35", "prettier": "^3.3.3", "react": "^18", @@ -9040,18 +9041,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/babel-loader/node_modules/yocto-queue": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.0.tgz", - "integrity": "sha512-cMojmlnwkAgIXqga+2sXshlgrrcI0QEPJ5n58pEvtuFo4PaekfomlCudArDD7hj8Hkswjl0/x4eu4q+Xa0WFgQ==", - "dev": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -23012,6 +23001,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -23022,6 +23012,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-limit/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-locate": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", @@ -30515,11 +30517,13 @@ } }, "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index f9cc8e225374..d2d6bc47eda5 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "next-mdx-remote": "^4.4.1", "node-fetch": "^3.3.2", "node-fetch-2": "npm:node-fetch@^2.7.0", + "p-limit": "^3.1.0", "postcss": "^8.4.35", "prettier": "^3.3.3", "react": "^18", diff --git a/scripts/markdown/check-markdown.js b/scripts/markdown/check-markdown.js index cd3bd7ddd1c5..2052f9f7080e 100644 --- a/scripts/markdown/check-markdown.js +++ b/scripts/markdown/check-markdown.js @@ -1,6 +1,41 @@ const fs = require('fs').promises; const matter = require('gray-matter'); const path = require('path'); +const pLimit = require('p-limit'); + +const DEFAULT_CONCURRENCY_LIMIT = 10; + +/** + * Validates and retrieves the concurrency limit from environment variables. + * @returns {number} The validated concurrency limit. + */ +function getConcurrencyLimit() { + const envLimit = process.env.MARKDOWN_CONCURRENCY_LIMIT; + + // If no env var is set, return default + if (envLimit === undefined) { + return DEFAULT_CONCURRENCY_LIMIT; + } + + // Attempt to parse the environment variable + const parsedLimit = parseInt(envLimit, 10); + + // Validate the parsed limit + if (Number.isNaN(parsedLimit)) { + console.warn(`Invalid MARKDOWN_CONCURRENCY_LIMIT: '${envLimit}'. Value must be a number. Falling back to default of 10.`); + return DEFAULT_CONCURRENCY_LIMIT; + } + + // Check for non-positive integers + if (parsedLimit <= 0) { + console.warn( + `MARKDOWN_CONCURRENCY_LIMIT must be a positive integer greater than 0. Received: ${parsedLimit}. Falling back to default of 10.` + ); + return DEFAULT_CONCURRENCY_LIMIT; + } + + return parsedLimit; +} /** * Checks if a given string is a valid URL. @@ -8,12 +43,12 @@ const path = require('path'); * @returns {boolean} True if the string is a valid URL, false otherwise. */ function isValidURL(str) { - try { - new URL(str); - return true; - } catch (err) { - return false; - } + try { + new URL(str); + return true; + } catch (err) { + return false; + } } /** @@ -23,51 +58,51 @@ function isValidURL(str) { * @returns {string[]|null} An array of validation error messages, or null if no errors. */ function validateBlogs(frontmatter) { - const requiredAttributes = ['title', 'date', 'type', 'tags', 'cover', 'authors']; - const errors = []; + const requiredAttributes = ['title', 'date', 'type', 'tags', 'cover', 'authors']; + const errors = []; - // Check for required attributes - requiredAttributes.forEach(attr => { - if (!frontmatter.hasOwnProperty(attr)) { - errors.push(`${attr} is missing`); - } - }); - - // Validate date format - if (frontmatter.date && Number.isNaN(Date.parse(frontmatter.date))) { - errors.push(`Invalid date format: ${frontmatter.date}`); - } - - // Validate tags format (must be an array) - if (frontmatter.tags && !Array.isArray(frontmatter.tags)) { - errors.push(`Tags should be an array`); + // Check for required attributes + requiredAttributes.forEach((attr) => { + if (!Object.hasOwn(frontmatter, attr)) { + errors.push(`${attr} is missing`); } - - // Validate cover is a string - if (frontmatter.cover && typeof frontmatter.cover !== 'string') { - errors.push(`Cover must be a string`); - } - - // Validate authors (must be an array with valid attributes) - if (frontmatter.authors) { - if (!Array.isArray(frontmatter.authors)) { - errors.push('Authors should be an array'); - } else { - frontmatter.authors.forEach((author, index) => { - if (!author.name) { - errors.push(`Author at index ${index} is missing a name`); - } - if (author.link && !isValidURL(author.link)) { - errors.push(`Invalid URL for author at index ${index}: ${author.link}`); - } - if (!author.photo) { - errors.push(`Author at index ${index} is missing a photo`); - } - }); + }); + + // Validate date format + if (frontmatter.date && Number.isNaN(Date.parse(frontmatter.date))) { + errors.push(`Invalid date format: ${frontmatter.date}`); + } + + // Validate tags format (must be an array) + if (frontmatter.tags && !Array.isArray(frontmatter.tags)) { + errors.push(`Tags should be an array`); + } + + // Validate cover is a string + if (frontmatter.cover && typeof frontmatter.cover !== 'string') { + errors.push(`Cover must be a string`); + } + + // Validate authors (must be an array with valid attributes) + if (frontmatter.authors) { + if (!Array.isArray(frontmatter.authors)) { + errors.push('Authors should be an array'); + } else { + frontmatter.authors.forEach((author, index) => { + if (!author.name) { + errors.push(`Author at index ${index} is missing a name`); } + if (author.link && !isValidURL(author.link)) { + errors.push(`Invalid URL for author at index ${index}: ${author.link}`); + } + if (!author.photo) { + errors.push(`Author at index ${index} is missing a photo`); + } + }); } + } - return errors.length ? errors : null; + return errors.length ? errors : null; } /** @@ -77,19 +112,22 @@ function validateBlogs(frontmatter) { * @returns {string[]|null} An array of validation error messages, or null if no errors. */ function validateDocs(frontmatter) { - const errors = []; - - // Check if title exists and is a string - if (!frontmatter.title || typeof frontmatter.title !== 'string') { - errors.push('Title is missing or not a string'); - } - - // Check if weight exists and is a number - if (frontmatter.weight === undefined || typeof frontmatter.weight !== 'number') { - errors.push('Weight is missing or not a number'); - } - - return errors.length ? errors : null; + const errors = []; + + // Check if title exists and is a string + if (!frontmatter.title || typeof frontmatter.title !== 'string') { + errors.push('Title is missing or not a string'); + } + + // Check if weight exists and is a number + if ( + frontmatter.weight === undefined || + typeof frontmatter.weight !== 'number' + ) { + errors.push('Weight is missing or not a number'); + } + + return errors.length ? errors : null; } /** @@ -97,62 +135,100 @@ function validateDocs(frontmatter) { * @param {string} folderPath - The path to the folder to check. * @param {Function} validateFunction - The function used to validate the frontmatter. * @param {string} [relativePath=''] - The relative path of the folder for logging purposes. + * @param {import('p-limit').default} limit - Concurrency limiter. + * @throws {Error} When the concurrency limiter fails or when file operations fail + * @example + * // Process files with a concurrency limit of 5 + * const limit = pLimit(5); + * await checkMarkdownFiles(folderPath, validateFn, limit); */ -async function checkMarkdownFiles(folderPath, validateFunction, relativePath = '') { - try { - const files = await fs.readdir(folderPath); - const filePromises = files.map(async (file) => { - const filePath = path.join(folderPath, file); - const relativeFilePath = path.join(relativePath, file); - - // Skip the folder 'docs/reference/specification' - if (relativeFilePath.includes('reference/specification')) { - return; +async function checkMarkdownFiles(folderPath, validateFunction, limit, relativePath = '') { + try { + const files = await fs.readdir(folderPath); + const filePromises = files.map(async (file) => { + const filePath = path.join(folderPath, file); + const relativeFilePath = path.join(relativePath, file); + + const stats = await fs.stat(filePath); + + // Skip the folder 'docs/reference/specification' + if (relativeFilePath.includes('reference/specification')) { + return; + } + + if (stats.isDirectory()) { + await checkMarkdownFiles(filePath, validateFunction, limit, relativeFilePath); + } else if (path.extname(file) === '.md') { + return limit(async () => { + try { + const fileContent = await fs.readFile(filePath, 'utf-8'); + const { data: frontmatter } = matter(fileContent); + + const errors = validateFunction(frontmatter); + if (errors) { + console.log(`Errors in file ${relativeFilePath}:`); + errors.forEach((error) => console.log(` - ${error}`)); + process.exitCode = 1; } - - const stats = await fs.stat(filePath); - - // Recurse if directory, otherwise validate markdown file - if (stats.isDirectory()) { - await checkMarkdownFiles(filePath, validateFunction, relativeFilePath); - } else if (path.extname(file) === '.md') { - const fileContent = await fs.readFile(filePath, 'utf-8'); - const { data: frontmatter } = matter(fileContent); - - const errors = validateFunction(frontmatter); - if (errors) { - console.log(`Errors in file ${relativeFilePath}:`); - errors.forEach(error => console.log(` - ${error}`)); - process.exitCode = 1; - } - } + } catch (error) { + console.error(`Error processing markdown file ${relativeFilePath} (validation failed):`, { + error: error.message, + code: error.code, + stack: error.stack + }); + throw error; + } }); + } + }); - await Promise.all(filePromises); - } catch (err) { - console.error(`Error in directory ${folderPath}:`, err); - throw err; - } + await Promise.all(filePromises.filter(Boolean)); + } catch (err) { + console.error(`Failed to process markdown files in directory ${folderPath}:`, { + error: err.message, + code: err.code, + stack: err.stack + }); + throw err; + } } const docsFolderPath = path.resolve(__dirname, '../../markdown/docs'); const blogsFolderPath = path.resolve(__dirname, '../../markdown/blog'); async function main() { - try { - await Promise.all([ - checkMarkdownFiles(docsFolderPath, validateDocs), - checkMarkdownFiles(blogsFolderPath, validateBlogs) - ]); - } catch (error) { - console.error('Failed to validate markdown files:', error); - process.exit(1); - } + try { + // Get concurrency limit from environment or use default + const concurrencyLimit = getConcurrencyLimit(); + console.log(`Configured markdown processing with concurrency limit: ${concurrencyLimit}`); + + // Create a concurrency limiter + const limit = pLimit(concurrencyLimit); + + await Promise.all([ + checkMarkdownFiles(docsFolderPath, validateDocs, limit, ''), + checkMarkdownFiles(blogsFolderPath, validateBlogs, limit, '') + ]); + } catch (error) { + console.error('Markdown validation process failed:', { + error: error.message, + code: error.code, + stack: error.stack + }); + process.exit(1); + } } /* istanbul ignore next */ if (require.main === module) { - main(); + main(); } -module.exports = { validateBlogs, validateDocs, checkMarkdownFiles, main, isValidURL }; +module.exports = { + validateBlogs, + validateDocs, + checkMarkdownFiles, + main, + isValidURL, + getConcurrencyLimit +}; diff --git a/tests/markdown/check-markdown.test.js b/tests/markdown/check-markdown.test.js index 85e06b70383f..02e8a8165838 100644 --- a/tests/markdown/check-markdown.test.js +++ b/tests/markdown/check-markdown.test.js @@ -1,150 +1,240 @@ const fs = require('fs').promises; const path = require('path'); const os = require('os'); +const pLimit = require('p-limit'); const { - isValidURL, - main, - validateBlogs, - validateDocs, - checkMarkdownFiles + isValidURL, + main, + validateBlogs, + validateDocs, + checkMarkdownFiles, + getConcurrencyLimit } = require('../../scripts/markdown/check-markdown'); describe('Frontmatter Validator', () => { - let tempDir; - let mockConsoleError; - let mockProcessExit; - - beforeEach(async () => { - mockConsoleError = jest.spyOn(console, 'error').mockImplementation(); - mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(); - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-config')); + let tempDir; + let mockConsoleError; + let mockProcessExit; + let originalEnv; + + beforeEach(async () => { + // Store original environment variables + originalEnv = { ...process.env }; + + mockConsoleError = jest.spyOn(console, 'error').mockImplementation(); + mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-config')); + }); + + afterEach(async () => { + // Restore original environment variables + process.env = originalEnv; + + // Verify environment restoration + expect(process.env).toEqual(originalEnv); + + mockConsoleError.mockRestore(); + mockProcessExit.mockRestore(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + +/** +* Test suite for concurrency limit validation. +* Verifies the behavior of concurrency limits in markdown processing. +*/ + describe('Concurrency Limit Validation', () => { + it('returns default concurrency limit when no env var is set', () => { + process.env.MARKDOWN_CONCURRENCY_LIMIT = undefined; + const limit = getConcurrencyLimit(); + expect(limit).toBe(10); }); - afterEach(async () => { - mockConsoleError.mockRestore(); - mockProcessExit.mockRestore(); - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it('validates authors array and returns specific errors', async () => { - const frontmatter = { - title: 'Test Blog', - date: '2024-01-01', - type: 'blog', - tags: ['test'], - cover: 'cover.jpg', - authors: [{ name: 'John' }, { photo: 'jane.jpg' }, { name: 'Bob', photo: 'bob.jpg', link: 'not-a-url' }] - }; - - const errors = validateBlogs(frontmatter); - expect(errors).toEqual(expect.arrayContaining([ - 'Author at index 0 is missing a photo', - 'Author at index 1 is missing a name', - 'Invalid URL for author at index 2: not-a-url' - ])); - }); - - it('validates docs frontmatter for required fields', async () => { - const frontmatter = { title: 123, weight: 'not-a-number' }; - const errors = validateDocs(frontmatter); - expect(errors).toEqual(expect.arrayContaining([ - 'Title is missing or not a string', - 'Weight is missing or not a number' - ])); - }); - - it('checks for errors in markdown files in a directory', async () => { - await fs.writeFile(path.join(tempDir, 'invalid.md'), `---\ntitle: Invalid Blog\n---`); - const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); - - await checkMarkdownFiles(tempDir, validateBlogs); - - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Errors in file invalid.md:')); - mockConsoleLog.mockRestore(); - }); - - it('returns multiple validation errors for invalid blog frontmatter', async () => { - const frontmatter = { - title: 123, - date: 'invalid-date', - type: 'blog', - tags: 'not-an-array', - cover: ['not-a-string'], - authors: { name: 'John Doe' } - }; - const errors = validateBlogs(frontmatter); - - expect(errors).toEqual([ - 'Invalid date format: invalid-date', - 'Tags should be an array', - 'Cover must be a string', - 'Authors should be an array']); - }); - - it('logs error to console when an error occurs in checkMarkdownFiles', async () => { - const invalidFolderPath = path.join(tempDir, 'non-existent-folder'); - - await expect(checkMarkdownFiles(invalidFolderPath, validateBlogs)) - .rejects.toThrow('ENOENT'); - - expect(mockConsoleError.mock.calls[0][0]).toContain('Error in directory'); - }); + it('returns default concurrency limit when env var is invalid', () => { + const mockWarn = jest.spyOn(console, 'warn').mockImplementation(); - it('skips the "reference/specification" folder during validation', async () => { - const referenceSpecDir = path.join(tempDir, 'reference', 'specification'); - await fs.mkdir(referenceSpecDir, { recursive: true }); - await fs.writeFile(path.join(referenceSpecDir, 'skipped.md'), `---\ntitle: Skipped File\n---`); + // Test various invalid inputs + const invalidInputs = ['abc', '-1', '0', ' ']; + invalidInputs.forEach((input) => { + process.env.MARKDOWN_CONCURRENCY_LIMIT = input; + const limit = getConcurrencyLimit(); + expect(limit).toBe(10); + }); - const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); - - await checkMarkdownFiles(tempDir, validateDocs); - - expect(mockConsoleLog).not.toHaveBeenCalledWith(expect.stringContaining('Errors in file reference/specification/skipped.md')); - mockConsoleLog.mockRestore(); + mockWarn.mockRestore(); }); - it('logs and rejects when an exception occurs while processing a file', async () => { - const filePath = path.join(tempDir, 'invalid.md'); - await fs.writeFile(filePath, `---\ntitle: Valid Title\n---`); - - const mockReadFile = jest.spyOn(fs, 'readFile').mockRejectedValue(new Error('Test readFile error')); - - await expect(checkMarkdownFiles(tempDir, validateBlogs)).rejects.toThrow('Test readFile error'); - expect(mockConsoleError).toHaveBeenCalledWith( - expect.stringContaining(`Error in directory`), - expect.any(Error) - ); - - mockReadFile.mockRestore(); + it('returns custom concurrency limit when env var is a valid positive integer', () => { + process.env.MARKDOWN_CONCURRENCY_LIMIT = '20'; + const limit = getConcurrencyLimit(); + expect(limit).toBe(20); }); - it('should handle main function errors and exit with status 1', async () => { - jest.spyOn(fs, 'readdir').mockRejectedValue(new Error('Test error')); - - await main(); - - expect(mockProcessExit).toHaveBeenCalledWith(1); - - expect(mockConsoleError).toHaveBeenCalledWith( - 'Failed to validate markdown files:', - expect.any(Error) - ); + it('should process files concurrently while respecting the configured limit', async () => { + const CONCURRENCY_LIMIT = 5; + const TOTAL_FILES = 20; + const PROCESSING_TIME_MS = 50; + let activeCount = 0; + let maxActiveCount = 0; + + const mockValidateFunction = jest.fn().mockImplementation(async (frontmatter) => { + activeCount++; + maxActiveCount = Math.max(maxActiveCount, activeCount); + await new Promise(resolve => setTimeout(resolve, PROCESSING_TIME_MS)); + activeCount--; + return null; // No validation errors + }); + + // Create test files + const writePromises = Array.from({ length: TOTAL_FILES }).map((_, i) => + fs.writeFile( + path.join(tempDir, `test${i}.md`), + '---\ntitle: Test\n---' + ) + ); + await Promise.all(writePromises); + + const limit = pLimit(CONCURRENCY_LIMIT); + await checkMarkdownFiles(tempDir, mockValidateFunction, limit, ''); + + expect(maxActiveCount).toBeLessThanOrEqual(CONCURRENCY_LIMIT); + expect(mockValidateFunction).toHaveBeenCalledTimes(TOTAL_FILES); }); - - it('should handle successful main function execution', async () => { - - await main(); - - expect(mockConsoleError).not.toHaveBeenCalledWith(); - }); - - it('should return true or false for URLs', () => { - expect(isValidURL('http://example.com')).toBe(true); - expect(isValidURL('https://www.example.com')).toBe(true); - expect(isValidURL('ftp://ftp.example.com')).toBe(true); - expect(isValidURL('invalid-url')).toBe(false); - expect(isValidURL('/path/to/file')).toBe(false); - expect(isValidURL('www.example.com')).toBe(false); - }); - -}); + }); + + it('validates authors array and returns specific errors', async () => { + const frontmatter = { + title: 'Test Blog', + date: '2024-01-01', + type: 'blog', + tags: ['test'], + cover: 'cover.jpg', + authors: [{ name: 'John' }, { photo: 'jane.jpg' }, { name: 'Bob', photo: 'bob.jpg', link: 'not-a-url' }] + }; + + const errors = validateBlogs(frontmatter); + expect(errors).toEqual( + expect.arrayContaining([ + 'Author at index 0 is missing a photo', + 'Author at index 1 is missing a name', + 'Invalid URL for author at index 2: not-a-url', + ]) + ); + }); + + it('validates docs frontmatter for required fields', async () => { + const frontmatter = { title: 123, weight: 'not-a-number' }; + const errors = validateDocs(frontmatter); + expect(errors).toEqual( + expect.arrayContaining(['Title is missing or not a string', 'Weight is missing or not a number']) + ); + }); + + it('checks for errors in markdown files in a directory', async () => { + await fs.writeFile( + path.join(tempDir, 'invalid.md'), + `---\ntitle: Invalid Blog\n---`, + ); + const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); + + await checkMarkdownFiles(tempDir, validateBlogs, '', pLimit(10)); + + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Errors in file invalid.md:')); + mockConsoleLog.mockRestore(); + }); + + describe('Blog Frontmatter Validation', () => { + it('should return all validation errors when multiple fields are invalid', async () => { + const frontmatter = { + title: 123, + date: 'invalid-date', + type: 'blog', + tags: 'not-an-array', + cover: ['not-a-string'], + authors: { name: 'John Doe' }, + }; + const errors = validateBlogs(frontmatter); + + expect(errors).toEqual([ + 'Invalid date format: invalid-date', + 'Tags should be an array', + 'Cover must be a string', + 'Authors should be an array', + ]); + }) + }); + + it('logs error to console when an error occurs in checkMarkdownFiles', async () => { + const invalidFolderPath = path.join(tempDir, 'non-existent-folder'); + + await expect( + checkMarkdownFiles(invalidFolderPath, validateBlogs, pLimit(10), '') + ).rejects.toThrow('ENOENT'); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Failed to process markdown files in directory'), + expect.any(Object) + ); + }); + + it('skips the "reference/specification" folder during validation', async () => { + const referenceSpecDir = path.join(tempDir, 'reference', 'specification'); + await fs.mkdir(referenceSpecDir, { recursive: true }); + await fs.writeFile( + path.join(referenceSpecDir, 'skipped.md'), + `---\ntitle: Skipped File\n---`, + ); + + const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); + + await checkMarkdownFiles(tempDir, validateDocs, '', pLimit(10)); + + expect(mockConsoleLog).not.toHaveBeenCalledWith(expect.stringContaining('Errors in file reference/specification/skipped.md')); + mockConsoleLog.mockRestore(); + }); + + it('logs and rejects when an exception occurs while processing a file', async () => { + const filePath = path.join(tempDir, 'invalid.md'); + await fs.writeFile(filePath, `---\ntitle: Valid Title\n---`); + + const mockReadFile = jest.spyOn(fs, 'readFile').mockRejectedValue(new Error('Test readFile error')); + + await expect( + checkMarkdownFiles(tempDir, validateBlogs, '', pLimit(10)), + ).rejects.toThrow('Test readFile error'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining(`Error in directory`), + expect.any(Error), + ); + + mockReadFile.mockRestore(); + }); + + it('should handle main function errors and exit with status 1', async () => { + jest.spyOn(fs, 'readdir').mockRejectedValue(new Error('Test error')); + + await main(); + + expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith( + 'Markdown validation process failed:', + expect.objectContaining({ + error: 'Test error' + }) + ); + }); + + it('should handle successful main function execution', async () => { + await main(); + + expect(mockConsoleError).not.toHaveBeenCalledWith(); + }); + + it('should return true or false for URLs', () => { + expect(isValidURL('http://example.com')).toBe(true); + expect(isValidURL('https://www.example.com')).toBe(true); + expect(isValidURL('ftp://ftp.example.com')).toBe(true); + expect(isValidURL('invalid-url')).toBe(false); + expect(isValidURL('/path/to/file')).toBe(false); + expect(isValidURL('www.example.com')).toBe(false); + }); +}); \ No newline at end of file