Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"predev": "npm run build:ts && node scripts/clean-dev-assets.js",
"dev": "node scripts/index.js dev",
"clear-assets": "rimraf src/renderer/bundle.js src/renderer/bundle.js.map src/renderer/bundle.js.LICENSE.txt src/renderer/output.css",
"lint": "npm run format:check && cross-env ESLINT_USE_FLAT_CONFIG=false eslint src tests --ext .js,.jsx,.ts,.tsx --cache && npm run lint:md && npm run changelog:validate",
"lint": "npm run format:check && cross-env ESLINT_USE_FLAT_CONFIG=false eslint src tests --ext .js,.jsx,.ts,.tsx --cache && npm run lint:md && npm run test:catalog && npm run changelog:validate",
"lint:md": "npm run lint:md:links && npm run lint:md:style",
"lint:md:links": "node scripts/lint-markdown-links.js",
"lint:md:style": "markdownlint \"**/*.{md,mdx}\" --config .markdownlint.json --ignore node_modules --ignore dist",
Expand All @@ -28,6 +28,7 @@
"test": "jest --config jest.config.js --passWithNoTests",
"test:watch": "jest --watch --config jest.config.js --passWithNoTests",
"test:stress": "jest --config jest.config.js --runInBand --testMatch=\"**/tests/stress/**/*.{js,jsx,ts,tsx}\" --verbose",
"test:catalog": "node scripts/validate-test-catalog.js",
"test:gitignore": "jest --config jest.config.js --testMatch=\"**/tests/unit/gitignore-parser.test.{js,ts}\" --verbose",
"test:binary": "jest --config jest.config.js --testMatch=\"**/tests/unit/binary-detection.test.{js,ts}\" --verbose",
"test:patterns": "jest --config jest.config.js --testMatch=\"**/tests/**/*pattern*.test.{js,ts}\" --verbose",
Expand Down
264 changes: 264 additions & 0 deletions scripts/validate-test-catalog.js
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();
}
Comment on lines +90 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Validator mis-models jest discovery 🐞 Bug ✓ Correctness

The 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
### Issue description
The catalog validator only considers files matching `\.(test|spec)\.(js|jsx|ts|tsx)$` as “Jest discovered”, but the repo’s `jest.config.js` discovers **all** `tests/**/*.{js,jsx,ts,tsx}`. This mismatch means `npm run test:catalog` can pass even while Jest executes non-`.test`/`.spec` files (e.g. `tests/setup.ts`) that are not enforced in `tests/catalog.md`.

### Issue Context
- Current Jest config `testMatch` is broader than the validator’s `EXECUTABLE_TEST_FILE_PATTERN`.
- `tests/setup.ts` contains a real Jest suite and is also included in `setupFilesAfterEnv`, so it will be executed under the current `testMatch`.

### Fix Focus Areas
Choose one approach and make the validator + Jest consistent.

**Approach A (recommended): narrow Jest discovery to match validator intent**
- Update Jest `testMatch` to only include actual test files (e.g. default Jest pattern within `tests/`).
- Remove the “dummy test” from `tests/setup.ts` (it will still run as a setup file via `setupFilesAfterEnv`, but should not itself be a test suite).

**Approach B: broaden validator discovery to match Jest config**
- Treat any file under `tests/` that matches Jest `testMatch` (and isn’t ignored) as discovered; then decide whether helper files should be required in the catalog.

Add/adjust unit tests to cover whichever approach you choose.

Fix locations:
- scripts/validate-test-catalog.js[10-12]
- scripts/validate-test-catalog.js[86-138]
- jest.config.js[8-18]
- tests/setup.ts[1-10]
- tests/unit/scripts/validate-test-catalog.test.js[35-139]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current logic for identifying listedButNotDiscoveredTestFiles explicitly ignores files that are listed in the catalog but are ignored by Jest's testPathIgnorePatterns. This seems to contradict the goal of ensuring the catalog is consistent with Jest's discovery, as ignored files are not discovered or run by Jest. If a test is listed in the catalog, it should probably be discovered by Jest. If it's intentionally ignored, it might be better to remove it from the catalog to avoid confusion. Consider changing this logic to flag cataloged tests that are ignored by Jest.

    .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,
};
47 changes: 27 additions & 20 deletions tests/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Purpose: quick map of what is covered, why it exists, and which command to run.
## Core Commands

- Full tests: `npm test -- --runInBand`
- Test catalog consistency (path + discovery checks): `npm run test:catalog`
- Stress metrics summary (+ optional Pushgateway publish): `npm run stress:metrics`
- Stress publish verification in Prometheus: `npm run prometheus:verify`
- End-to-end perf metrics job (`TOOLS_DOMAIN` aware): `npm run perf:test` or `make perf-test`
Expand All @@ -19,26 +20,32 @@ Purpose: quick map of what is covered, why it exists, and which command to run.

## Unit Tests

| File | Primary Target | Key Use Cases |
| ----------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- |
| `tests/unit/components/app.test.tsx` | `src/renderer/components/App.tsx` | Tab switching, config load, directory selection, processing flow, error handling |
| `tests/unit/components/config-tab.test.tsx` | `src/renderer/components/ConfigTab.tsx` | Config toggles/inputs, callback wiring, directory picker trigger |
| `tests/unit/components/file-tree.test.tsx` | `src/renderer/components/FileTree.tsx` | Tree render, folder expand/collapse, select all, empty-state behavior |
| `tests/unit/file-analyzer.test.ts` | `src/utils/file-analyzer.ts` | Include/exclude rules, gitignore behavior, binary handling, error cases |
| `tests/unit/gitignore-parser.test.ts` | `src/utils/gitignore-parser.ts` | Pattern parsing, negation behavior, caching, nested path handling |
| `tests/unit/binary-detection.test.ts` | `src/utils/file-analyzer.ts` | Binary signature detection, control-char thresholds, fallback-on-error behavior |
| `tests/unit/utils/filter-utils.test.ts` | `src/utils/filter-utils.ts` | Path normalization, extension filtering, custom excludes, gitignore precedence |
| `tests/unit/utils/secret-scanner.test.ts` | `src/utils/secret-scanner.ts` | Sensitive path detection, secret-pattern scanning, default-on safety toggles |
| `tests/unit/utils/fnmatch.test.ts` | `src/utils/fnmatch.ts` | Glob semantics: wildcards, classes, double-star, braces, path anchors |
| `tests/unit/utils/export-format.test.ts` | `src/utils/export-format.ts` | Export format normalization, XML attribute escaping, CDATA-safe sanitization |
| `tests/unit/utils/content-processor.test.ts` | `src/utils/content-processor.ts` | Content assembly, binary skip logic, malformed input handling |
| `tests/unit/utils/config-manager.test.ts` | `src/utils/config-manager.ts` | Default config load, parse failures, graceful fallback behavior |
| `tests/unit/utils/token-counter.test.ts` | `src/utils/token-counter.ts` | Token counting basics, empty/null input handling |
| `tests/unit/scripts/security.test.js` | `scripts/lib/security.js` | Command safety validation, Windows path acceptance for approved executables |
| `tests/unit/scripts/actions-freshness.test.js` | `scripts/lib/actions-freshness.js` | Workflow `uses:` reference parsing, pinning classification, freshness markdown report output |
| `tests/unit/scripts/validate-changelog.test.js` | `scripts/validate-changelog.js` | Release heading/date format checks, allowed section headings, latest release section coverage |
| `tests/unit/main/updater.test.ts` | `src/main/updater.ts` | Alpha/stable channel selection, platform gating, update-check result handling |
| `tests/unit/main/feature-flags.test.ts` | `src/main/feature-flags.ts` | OpenFeature normalization, env/remote merge rules, secure remote fetch behavior |
| File | Primary Target | Key Use Cases |
| ------------------------------------------------------ | --------------------------------------- | --------------------------------------------------------------------------------------------- |
| `tests/unit/components/app.test.tsx` | `src/renderer/components/App.tsx` | Tab switching, config load, directory selection, processing flow, error handling |
| `tests/unit/components/config-tab.test.tsx` | `src/renderer/components/ConfigTab.tsx` | Config toggles/inputs, callback wiring, directory picker trigger |
| `tests/unit/components/file-tree.test.tsx` | `src/renderer/components/FileTree.tsx` | Tree render, folder expand/collapse, select all, empty-state behavior |
| `tests/unit/components/source-tab.test.tsx` | `src/renderer/components/SourceTab.tsx` | Source tab input state, filter toggles, and event forwarding behavior |
| `tests/unit/file-analyzer.test.ts` | `src/utils/file-analyzer.ts` | Include/exclude rules, gitignore behavior, binary handling, error cases |
| `tests/unit/gitignore-parser.test.ts` | `src/utils/gitignore-parser.ts` | Pattern parsing, negation behavior, caching, nested path handling |
| `tests/unit/binary-detection.test.ts` | `src/utils/file-analyzer.ts` | Binary signature detection, control-char thresholds, fallback-on-error behavior |
| `tests/unit/utils/filter-utils.test.ts` | `src/utils/filter-utils.ts` | Path normalization, extension filtering, custom excludes, gitignore precedence |
| `tests/unit/utils/secret-scanner.test.ts` | `src/utils/secret-scanner.ts` | Sensitive path detection, secret-pattern scanning, default-on safety toggles |
| `tests/unit/utils/fnmatch.test.ts` | `src/utils/fnmatch.ts` | Glob semantics: wildcards, classes, double-star, braces, path anchors |
| `tests/unit/utils/export-format.test.ts` | `src/utils/export-format.ts` | Export format normalization, XML attribute escaping, CDATA-safe sanitization |
| `tests/unit/utils/content-processor.test.ts` | `src/utils/content-processor.ts` | Content assembly, binary skip logic, malformed input handling |
| `tests/unit/utils/config-manager.test.ts` | `src/utils/config-manager.ts` | Default config load, parse failures, graceful fallback behavior |
| `tests/unit/utils/token-counter.test.ts` | `src/utils/token-counter.ts` | Token counting basics, empty/null input handling |
| `tests/unit/scripts/security.test.js` | `scripts/lib/security.js` | Command safety validation, Windows path acceptance for approved executables |
| `tests/unit/scripts/actions-freshness.test.js` | `scripts/lib/actions-freshness.js` | Workflow `uses:` reference parsing, pinning classification, freshness markdown report output |
| `tests/unit/scripts/sonar-options.test.js` | `scripts/lib/sonar-options.js` | Sonar scanner option merge behavior and CPD exclusion defaults |
| `tests/unit/scripts/publish-stress-metrics.test.js` | `scripts/publish-stress-metrics.js` | Prometheus payload generation and Pushgateway publication safeguards |
| `tests/unit/scripts/verify-prometheus-metrics.test.js` | `scripts/verify-prometheus-metrics.js` | Prometheus scrape verification retries, timeouts, and parsing |
| `tests/unit/scripts/perf-metrics-job.test.js` | `scripts/run-perf-metrics-job.js` | End-to-end performance job orchestration (stress, publish, verify) |
| `tests/unit/scripts/validate-test-catalog.test.js` | `scripts/validate-test-catalog.js` | Catalog path validity and Jest discovery coverage checks |
| `tests/unit/scripts/validate-changelog.test.js` | `scripts/validate-changelog.js` | Release heading/date format checks, allowed section headings, latest release section coverage |
| `tests/unit/main/updater.test.ts` | `src/main/updater.ts` | Alpha/stable channel selection, platform gating, update-check result handling |
| `tests/unit/main/feature-flags.test.ts` | `src/main/feature-flags.ts` | OpenFeature normalization, env/remote merge rules, secure remote fetch behavior |

## Integration Tests

Expand Down
Loading
Loading