dynamo/.github/scripts/test-filters.js

295 lines
8.4 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Test script for .github/filters.yaml pattern matching.
* Reads patterns directly from filters.yaml and validates behavior.
*
* Usage:
* cd .github/scripts
* npm install
* npm test # Run pattern tests only
* npm run coverage # Check full repo coverage
* npm test -- --coverage # Run both
*
* This validates that tj-actions/changed-files will correctly:
* - Match backend-specific files to their respective filters (vllm, sglang, trtllm)
* - Exclude doc files (*.md, *.rst, *.txt) from core via negation patterns
* - Match CI/infrastructure changes to core
* - (with --coverage) Ensure all files in repo are covered by at least one filter
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const micromatch = require('micromatch');
const YAML = require('yaml');
const runCoverage = process.argv.includes('--coverage');
// Find filters.yaml relative to this script
const scriptDir = path.dirname(__filename);
const filtersPath = path.resolve(scriptDir, '../filters.yaml');
console.log(`Reading filters from: ${filtersPath}\n`);
// Parse YAML (handles anchors/aliases automatically)
const filtersYaml = fs.readFileSync(filtersPath, 'utf8');
const filters = YAML.parse(filtersYaml);
// Flatten nested arrays (YAML anchors create nested arrays)
function flattenPatterns(patterns) {
if (!patterns || !Array.isArray(patterns)) return [];
return patterns.flat(Infinity).filter(p => typeof p === 'string');
}
// Simulate tj-actions/changed-files behavior with negation
function checkFilter(file, patterns) {
const flat = flattenPatterns(patterns);
if (flat.length === 0) return false;
const positive = flat.filter(p => !p.startsWith('!'));
const negative = flat.filter(p => p.startsWith('!')).map(p => p.slice(1));
const matchesPositive = micromatch.isMatch(file, positive);
const matchesNegative = negative.length > 0 && micromatch.isMatch(file, negative);
return matchesPositive && !matchesNegative;
}
// Test cases: [file, expectations, description]
// expectations: { filterName: expectedValue, ... }
const testCases = [
// Backend-specific files should only trigger their backend
{
file: 'examples/backends/vllm/launch/dsr1_dep.sh',
expect: { core: false, vllm: true, sglang: false, trtllm: false },
desc: 'vllm script triggers only vllm'
},
{
file: 'examples/backends/sglang/example.py',
expect: { core: false, vllm: false, sglang: true, trtllm: false },
desc: 'sglang script triggers only sglang'
},
{
file: 'examples/backends/trtllm/example.py',
expect: { core: false, vllm: false, sglang: false, trtllm: true },
desc: 'trtllm script triggers only trtllm'
},
{
file: 'components/src/dynamo/vllm/worker.py',
expect: { core: false, vllm: true },
desc: 'vllm component triggers only vllm'
},
// Doc files should be excluded from core (negation patterns)
{
file: 'lib/README.md',
expect: { core: false, vllm: false, docs: true },
desc: 'lib README excluded from core, matches docs'
},
{
file: 'tests/README.md',
expect: { core: false, docs: true },
desc: 'tests README excluded from core'
},
{
file: 'lib/docs/guide.txt',
expect: { core: false, docs: true },
desc: 'txt file excluded from core'
},
{
file: 'docs/guide.md',
expect: { core: false, docs: true },
desc: 'docs folder matches docs filter'
},
// Code files should trigger core
{
file: 'lib/runtime/src/main.rs',
expect: { core: true, vllm: false },
desc: 'rust file triggers core'
},
{
file: 'lib/runtime/Cargo.toml',
expect: { core: true },
desc: 'Cargo.toml triggers core'
},
{
file: 'tests/test_something.py',
expect: { core: true },
desc: 'python test triggers core'
},
{
file: 'components/src/dynamo/router/router.py',
expect: { core: true },
desc: 'router triggers core'
},
{
file: 'components/src/dynamo/frontend/server.py',
expect: { core: true },
desc: 'frontend triggers core'
},
// CI files should trigger core
{
file: '.github/workflows/ci.yml',
expect: { core: true },
desc: 'workflow triggers core'
},
{
file: '.github/filters.yaml',
expect: { core: true },
desc: 'filters.yaml triggers core'
},
{
file: '.github/actions/docker-build/action.yml',
expect: { core: true },
desc: 'action triggers core'
},
// Root level files
{
file: 'pyproject.toml',
expect: { core: true },
desc: 'root toml triggers core'
},
{
file: 'setup.py',
expect: { core: true },
desc: 'root py triggers core'
},
// Operator and deploy
{
file: 'deploy/operator/cmd/main.go',
expect: { core: false, operator: true },
desc: 'operator file triggers operator'
},
{
file: 'deploy/helm/charts/platform/values.yaml',
expect: { core: false, deploy: true },
desc: 'helm file triggers deploy'
},
];
// Print available filters
console.log('Loaded filters:', Object.keys(filters).join(', '));
console.log('');
console.log('Testing filter patterns\n');
console.log('File | Result');
console.log('-----------------------------------------------|--------');
let passed = 0;
let failed = 0;
testCases.forEach(({ file, expect, desc }) => {
const results = {};
let allMatch = true;
// Check each expected filter
for (const [filterName, expectedValue] of Object.entries(expect)) {
const actual = checkFilter(file, filters[filterName]);
results[filterName] = actual;
if (actual !== expectedValue) {
allMatch = false;
}
}
if (allMatch) {
passed++;
const matchedFilters = Object.entries(results)
.filter(([_, v]) => v)
.map(([k, _]) => k)
.join(', ') || 'none';
console.log(`${file.padEnd(45)} | ${matchedFilters}`);
} else {
failed++;
console.log(`${file.padEnd(45)} | FAIL`);
console.log(` ${desc}`);
for (const [filterName, expectedValue] of Object.entries(expect)) {
const actual = results[filterName];
if (actual !== expectedValue) {
console.log(` ${filterName}: expected=${expectedValue}, got=${actual}`);
}
}
}
});
console.log(`\n${passed}/${testCases.length} tests passed`);
if (failed > 0) {
console.error(`\n${failed} test(s) failed!`);
process.exit(1);
}
console.log('\nAll filter tests passed! ✓');
// --- Coverage Check ---
// Validates that all files in the repo are covered by at least one specific filter
if (runCoverage) {
console.log('\n' + '='.repeat(60));
console.log('Running full repository coverage check...\n');
// Get repo root (two levels up from .github/scripts)
const repoRoot = path.resolve(scriptDir, '../..');
// Get all tracked files using git
let allFiles;
try {
const output = execSync('git ls-files', { cwd: repoRoot, encoding: 'utf8' });
allFiles = output.trim().split('\n').filter(f => f.length > 0);
} catch (err) {
console.error('Failed to run git ls-files. Are you in a git repository?');
process.exit(1);
}
console.log(`Found ${allFiles.length} tracked files in repository\n`);
// Specific filters to check (exclude 'all' since it matches everything)
const specificFilters = Object.keys(filters).filter(f => f !== 'all');
// Check each file
const uncoveredFiles = [];
for (const file of allFiles) {
let covered = false;
for (const filterName of specificFilters) {
if (checkFilter(file, filters[filterName])) {
covered = true;
break;
}
}
if (!covered) {
uncoveredFiles.push(file);
}
}
if (uncoveredFiles.length > 0) {
console.error(`ERROR: ${uncoveredFiles.length} file(s) not covered by any CI filter:\n`);
// Group by directory for readability
const byDir = {};
for (const file of uncoveredFiles) {
const dir = path.dirname(file) || '.';
if (!byDir[dir]) byDir[dir] = [];
byDir[dir].push(path.basename(file));
}
for (const [dir, files] of Object.entries(byDir).sort()) {
console.log(` ${dir}/`);
for (const file of files.slice(0, 10)) {
console.log(` - ${file}`);
}
if (files.length > 10) {
console.log(` ... and ${files.length - 10} more`);
}
}
console.log('\nPlease add patterns for these files to .github/filters.yaml');
process.exit(1);
}
console.log(`All ${allFiles.length} files are covered by CI filters! ✓`);
}