-
Notifications
You must be signed in to change notification settings - Fork 1
test: enforce test catalog consistency and Jest discovery #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7c07e1a
b980ec9
5680c8c
57437c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,264 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { minimatch } = require('minimatch'); | ||
|
|
||
| const ROOT_DIR = path.join(__dirname, '..'); | ||
| const DEFAULT_CATALOG_PATH = path.join(ROOT_DIR, 'tests', 'catalog.md'); | ||
| const DEFAULT_JEST_CONFIG_PATH = path.join(ROOT_DIR, 'jest.config.js'); | ||
| const CATALOG_PATH_REFERENCE_PATTERN = /`(tests\/[^`\s]+)`/g; | ||
| const EXECUTABLE_TEST_FILE_PATTERN = /\.(test|spec)\.(js|jsx|ts|tsx)$/; | ||
| const JEST_DEFAULT_TEST_MATCH_PATTERNS = [ | ||
| '**/__tests__/**/*.[jt]s?(x)', | ||
| '**/?(*.)+(spec|test).[jt]s?(x)', | ||
| ]; | ||
|
|
||
| function toPosixPath(value) { | ||
| return value.replace(/\\/g, '/'); | ||
| } | ||
|
|
||
| function extractCatalogPathReferences(content) { | ||
| const references = new Set(); | ||
|
|
||
| for (const match of content.matchAll(CATALOG_PATH_REFERENCE_PATTERN)) { | ||
| references.add(toPosixPath(match[1])); | ||
| } | ||
|
|
||
| return Array.from(references).sort(); | ||
| } | ||
|
|
||
| function collectFilesRecursively(directoryPath) { | ||
| if (!fs.existsSync(directoryPath)) { | ||
| return []; | ||
| } | ||
|
|
||
| const files = []; | ||
| const entries = fs.readdirSync(directoryPath, { withFileTypes: true }); | ||
|
|
||
| for (const entry of entries) { | ||
| const absolutePath = path.join(directoryPath, entry.name); | ||
| if (entry.isDirectory()) { | ||
| files.push(...collectFilesRecursively(absolutePath)); | ||
| continue; | ||
| } | ||
|
|
||
| files.push(absolutePath); | ||
| } | ||
|
|
||
| return files; | ||
| } | ||
|
|
||
| function loadJestConfig(jestConfigPath = DEFAULT_JEST_CONFIG_PATH) { | ||
| const resolvedPath = path.resolve(jestConfigPath); | ||
| delete require.cache[resolvedPath]; | ||
| const loaded = require(resolvedPath); | ||
| return loaded && typeof loaded === 'object' ? loaded : {}; | ||
| } | ||
|
|
||
| function normalizeTestMatchPatterns(testMatchPatterns = []) { | ||
| return testMatchPatterns.map((pattern) => | ||
| toPosixPath(pattern).replace('<rootDir>/', '').replace('<rootDir>', '') | ||
| ); | ||
| } | ||
|
|
||
| function compileIgnorePatterns(patterns = [], onInvalidPattern = () => {}) { | ||
| return patterns | ||
| .map((pattern) => { | ||
| try { | ||
| return new RegExp(pattern); | ||
| } catch (error) { | ||
| onInvalidPattern(pattern, error); | ||
| return null; | ||
| } | ||
| }) | ||
| .filter(Boolean); | ||
| } | ||
|
|
||
| function isIgnoredByJest(absoluteFilePath, ignorePatterns) { | ||
| const normalizedAbsolutePath = toPosixPath(absoluteFilePath); | ||
| return ignorePatterns.some((pattern) => pattern.test(normalizedAbsolutePath)); | ||
| } | ||
|
|
||
| function isMatchedByJest(relativeFilePath, testMatchPatterns) { | ||
| const effectivePatterns = | ||
| testMatchPatterns.length > 0 ? testMatchPatterns : JEST_DEFAULT_TEST_MATCH_PATTERNS; | ||
|
|
||
| return effectivePatterns.some((pattern) => minimatch(relativeFilePath, pattern, { dot: true })); | ||
| } | ||
|
|
||
| function listExecutableTestFiles(rootDir = ROOT_DIR) { | ||
| const testsRoot = path.join(rootDir, 'tests'); | ||
| const files = collectFilesRecursively(testsRoot); | ||
|
|
||
| return files | ||
| .map((absolutePath) => toPosixPath(path.relative(rootDir, absolutePath))) | ||
| .filter((relativePath) => EXECUTABLE_TEST_FILE_PATTERN.test(relativePath)) | ||
| .sort(); | ||
| } | ||
|
|
||
| function validateTestCatalog({ | ||
| rootDir = ROOT_DIR, | ||
| catalogPath = DEFAULT_CATALOG_PATH, | ||
| jestConfigPath = DEFAULT_JEST_CONFIG_PATH, | ||
| jestConfig, | ||
| } = {}) { | ||
| let catalogContent = ''; | ||
| const errors = []; | ||
| const warnings = []; | ||
|
|
||
| try { | ||
| catalogContent = fs.readFileSync(catalogPath, 'utf8'); | ||
| } catch (error) { | ||
| return { | ||
| isValid: false, | ||
| errors: [`Unable to read test catalog at ${catalogPath}: ${error.message}`], | ||
| warnings, | ||
| catalogPathReferences: [], | ||
| missingCatalogPaths: [], | ||
| discoveredTestFiles: [], | ||
| unlistedDiscoveredTestFiles: [], | ||
| listedButNotDiscoveredTestFiles: [], | ||
| }; | ||
| } | ||
|
|
||
| const effectiveJestConfig = jestConfig || loadJestConfig(jestConfigPath); | ||
| const testMatchPatterns = normalizeTestMatchPatterns(effectiveJestConfig.testMatch); | ||
| const ignorePatterns = compileIgnorePatterns( | ||
| effectiveJestConfig.testPathIgnorePatterns, | ||
| (pattern, error) => { | ||
| warnings.push(`Invalid Jest ignore pattern \`${pattern}\`: ${error.message}`); | ||
| } | ||
| ); | ||
| const catalogPathReferences = extractCatalogPathReferences(catalogContent); | ||
|
|
||
| const missingCatalogPaths = catalogPathReferences | ||
| .filter((relativePath) => !relativePath.includes('*')) | ||
| .filter((relativePath) => !fs.existsSync(path.join(rootDir, relativePath))) | ||
| .sort(); | ||
|
|
||
| const executableTestFiles = listExecutableTestFiles(rootDir); | ||
| const discoveredTestFiles = executableTestFiles | ||
| .filter((relativePath) => { | ||
| const absolutePath = path.join(rootDir, relativePath); | ||
| return ( | ||
| !isIgnoredByJest(absolutePath, ignorePatterns) && | ||
| isMatchedByJest(relativePath, testMatchPatterns) | ||
| ); | ||
| }) | ||
| .sort(); | ||
|
|
||
| const discoveredTestFileSet = new Set(discoveredTestFiles); | ||
| const catalogReferenceSet = new Set(catalogPathReferences); | ||
|
|
||
| const unlistedDiscoveredTestFiles = discoveredTestFiles | ||
| .filter((relativePath) => !catalogReferenceSet.has(relativePath)) | ||
| .sort(); | ||
|
|
||
| const listedButNotDiscoveredTestFiles = catalogPathReferences | ||
| .filter((relativePath) => EXECUTABLE_TEST_FILE_PATTERN.test(relativePath)) | ||
| .filter((relativePath) => fs.existsSync(path.join(rootDir, relativePath))) | ||
| .filter((relativePath) => { | ||
| const absolutePath = path.join(rootDir, relativePath); | ||
| if (isIgnoredByJest(absolutePath, ignorePatterns)) { | ||
| return false; | ||
| } | ||
|
|
||
| return !discoveredTestFileSet.has(relativePath); | ||
| }) | ||
|
Comment on lines
+161
to
+168
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current logic for identifying .filter((relativePath) => !discoveredTestFileSet.has(relativePath)) |
||
| .sort(); | ||
|
|
||
| if (missingCatalogPaths.length > 0) { | ||
| errors.push( | ||
| `Catalog references missing paths: ${missingCatalogPaths.map((item) => `\`${item}\``).join(', ')}` | ||
| ); | ||
| } | ||
|
|
||
| if (unlistedDiscoveredTestFiles.length > 0) { | ||
| errors.push( | ||
| `Discovered tests missing from catalog: ${unlistedDiscoveredTestFiles.map((item) => `\`${item}\``).join(', ')}` | ||
| ); | ||
| } | ||
|
|
||
| if (listedButNotDiscoveredTestFiles.length > 0) { | ||
| errors.push( | ||
| `Catalog lists tests not discovered by Jest: ${listedButNotDiscoveredTestFiles.map((item) => `\`${item}\``).join(', ')}` | ||
| ); | ||
| } | ||
|
|
||
| return { | ||
| isValid: errors.length === 0, | ||
| errors, | ||
| warnings, | ||
| catalogPathReferences, | ||
| missingCatalogPaths, | ||
| discoveredTestFiles, | ||
| unlistedDiscoveredTestFiles, | ||
| listedButNotDiscoveredTestFiles, | ||
| }; | ||
| } | ||
|
|
||
| function resolvePathWithinRoot(inputPath, defaultPath, label) { | ||
| const resolvedPath = inputPath ? path.resolve(process.cwd(), inputPath) : defaultPath; | ||
| const relativeToRoot = path.relative(ROOT_DIR, resolvedPath); | ||
| const isOutsideRoot = relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot); | ||
|
|
||
| if (isOutsideRoot) { | ||
| throw new Error(`${label} must resolve inside the repository: ${resolvedPath}`); | ||
| } | ||
|
|
||
| return resolvedPath; | ||
| } | ||
|
|
||
| function run() { | ||
| const [catalogArg, jestConfigArg] = process.argv.slice(2); | ||
|
|
||
| let catalogPath = DEFAULT_CATALOG_PATH; | ||
| let jestConfigPath = DEFAULT_JEST_CONFIG_PATH; | ||
|
|
||
| try { | ||
| catalogPath = resolvePathWithinRoot(catalogArg, DEFAULT_CATALOG_PATH, 'Catalog path'); | ||
| jestConfigPath = resolvePathWithinRoot(jestConfigArg, DEFAULT_JEST_CONFIG_PATH, 'Jest config path'); | ||
| } catch (error) { | ||
| console.error('Test catalog validation failed:'); | ||
| console.error(`- ${error.message}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const result = validateTestCatalog({ | ||
| catalogPath, | ||
| jestConfigPath, | ||
| }); | ||
|
|
||
| for (const warning of result.warnings) { | ||
| console.warn(`Warning: ${warning}`); | ||
| } | ||
|
|
||
| if (!result.isValid) { | ||
| console.error('Test catalog validation failed:'); | ||
| for (const error of result.errors) { | ||
| console.error(`- ${error}`); | ||
| } | ||
| process.exit(1); | ||
| } | ||
|
|
||
| console.log( | ||
| `Test catalog validation passed (${result.catalogPathReferences.length} references, ${result.discoveredTestFiles.length} discovered tests).` | ||
| ); | ||
| } | ||
|
|
||
| if (require.main === module) { | ||
| run(); | ||
| } | ||
|
|
||
| module.exports = { | ||
| CATALOG_PATH_REFERENCE_PATTERN, | ||
| EXECUTABLE_TEST_FILE_PATTERN, | ||
| extractCatalogPathReferences, | ||
| isMatchedByJest, | ||
| listExecutableTestFiles, | ||
| normalizeTestMatchPatterns, | ||
| resolvePathWithinRoot, | ||
| compileIgnorePatterns, | ||
| validateTestCatalog, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2. Validator mis-models jest discovery
🐞 Bug✓ CorrectnessThe validator only treats *.test.*/*.spec.* files as “discovered”, but this repo’s Jest testMatch includes all tests/**/*.{js,jsx,ts,tsx}; Jest will execute files like tests/setup.ts that the validator ignores. This can allow real Jest-executed suites/files to be missing from tests/catalog.md without npm run lint failing, contradicting the intended enforcement.Agent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools