File size: 6,056 Bytes
fd2c364 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | #!/usr/bin/env node
/**
* Resolves which mock-LLM E2E test subdirectories to run based on a list
* of changed source files.
*
* Usage:
* node resolve-affected-tests.mjs --files 'src/routes/home.tsx,src/api/foo.ts'
* node resolve-affected-tests.mjs --files-stdin # one file per line on stdin
*
* Output (stdout):
* Space-separated list of test paths relative to the repo root, suitable
* for passing directly to `npx playwright test <paths...>`. Changed
* spec files are resolved to their containing feature directory, so
* test-only PRs that add new specs still execute those new tests.
* Prints nothing if no tests are affected (meaning the full suite should
* be skipped β the caller should have already used the workflow-level
* paths filter).
*
* Exit codes:
* 0 β resolved successfully (may output nothing if no match)
* 1 β error (missing args, bad config, etc.)
*
* Reads the mapping from test-mapping.json (sibling of ./scripts/).
*/
import { readFileSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolve(__dirname, "../test-mapping.json");
// ββ CLI args βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const { values } = parseArgs({
options: {
files: { type: "string", default: "" },
"files-stdin": { type: "boolean", default: false },
},
strict: false,
});
let changedFiles;
if (values["files-stdin"]) {
changedFiles = readFileSync("/dev/stdin", "utf8")
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
} else if (values.files) {
changedFiles = values.files
.split(",")
.map((f) => f.trim())
.filter(Boolean);
} else {
console.error(
"Usage: resolve-affected-tests.mjs --files 'file1,file2' | --files-stdin",
);
process.exit(1);
}
if (changedFiles.length === 0) {
// No files changed β nothing to run.
process.exit(0);
}
// ββ Load config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const config = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
// ββ Glob matching (minimal, no deps) βββββββββββββββββββββββββββββββββββ
/**
* Convert a simple glob pattern to a RegExp.
* Supports `*` (one path segment), `**` (any depth), and `?`.
* Dot-prefixed segments are matched normally.
*/
function globToRegex(pattern) {
let re = "";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "*") {
if (pattern[i + 1] === "*") {
// ** β match any number of path segments
if (pattern[i + 2] === "/") {
re += "(?:.+/)?";
i += 3;
} else {
re += ".*";
i += 2;
}
} else {
// * β match within one segment (no /)
re += "[^/]*";
i += 1;
}
} else if (ch === "?") {
re += "[^/]";
i += 1;
} else if (".+^${}()|[]\\".includes(ch)) {
re += "\\" + ch;
i += 1;
} else {
re += ch;
i += 1;
}
}
return new RegExp("^" + re + "$");
}
// ββ Resolve affected test dirs βββββββββββββββββββββββββββββββββββββββββ
const testDir = config.testDir;
const testDirPrefix = testDir + "/";
const affectedDirs = new Set();
const affectedFiles = new Set();
function includeAlwaysRunDirs() {
for (const dir of config.alwaysRun ?? []) {
affectedDirs.add(dir);
}
}
// Check if any changed file triggers "run all" explicitly
const runAllPatterns = (config.runAllSources ?? []).map(globToRegex);
const shouldRunAll = changedFiles.some((file) =>
runAllPatterns.some((rx) => rx.test(file)),
);
if (shouldRunAll) {
console.log("__ALL__");
process.exit(0);
}
// If a changed file IS a test file, run the feature directory containing it.
// This is what makes test-only PRs that add new specs execute the new tests
// even when no source files changed. Root-level specs are no longer expected,
// but include the exact file path as a defensive fallback.
for (const file of changedFiles) {
if (file.startsWith(testDirPrefix) && file.endsWith(".spec.ts")) {
const relative = file.slice(testDirPrefix.length);
if (relative.includes("/")) {
affectedDirs.add(relative.split("/")[0]);
} else {
affectedFiles.add(file);
}
includeAlwaysRunDirs();
}
}
// Map changed source files to specific test dirs.
// Any source file (src/**) that doesn't match a specific mapping is
// treated as cross-cutting (could affect any test) β run all.
for (const file of changedFiles) {
if (!file.startsWith("src/")) continue;
let matched = false;
for (const mapping of config.mappings) {
const patterns = mapping.sources.map(globToRegex);
if (patterns.some((rx) => rx.test(file))) {
for (const dir of mapping.tests) {
affectedDirs.add(dir);
}
includeAlwaysRunDirs();
matched = true;
}
}
// Unmapped src/ file β could affect any test, run everything.
if (!matched) {
console.log("__ALL__");
process.exit(0);
}
}
if (affectedDirs.size === 0 && affectedFiles.size === 0) {
// No src/ files, no test files, no runAll triggers. Changed files are
// outside the E2E-relevant tree (docs, specs, unit tests). Nothing to run.
process.exit(0);
}
// Convert dir names to full paths relative to repo root. File paths are
// already repo-relative and are only used for defensive root-level spec support.
const testPaths = [
...[...affectedDirs].sort().map((d) => join(testDir, d)),
...[...affectedFiles].sort(),
];
console.log(testPaths.join(" "));
|