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(" "));