1// @effect-diagnostics nodeBuiltinImport:off globalConsole:off
2import * as NodeCrypto from "node:crypto";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeFS from "node:fs";
5import * as NodePath from "node:path";
6import * as NodeUtil from "node:util";
7
8import { matchesTestPath } from "./lib/cached-tests.ts";
9import { shardedTestFiles } from "./lib/sharded-tests.ts";
10
11const root = NodePath.resolve(import.meta.dirname, "..");
12const { values, positionals } = NodeUtil.parseArgs({
13 args: process.argv[2] === "--" ? process.argv.slice(3) : process.argv.slice(2),
14 allowPositionals: true,
15 options: {
16 "group-size": { type: "string", default: "1" },
17 concurrency: { type: "string", default: "1" },
18 shard: { type: "string" },
19 "verify-hits": { type: "string", default: "0" },
20 uncached: { type: "string", multiple: true, default: [] },
21 help: { type: "boolean", short: "h" },
22 },
23});
24if (values.help) {
25 console.log(
26 "vp run test:cached [--group-size 1] [--concurrency 1] [--shard index/count] [--uncached file] [test files or directories]",
27 );
28 process.exit(0);
29}
30const groupSize = Number(values["group-size"]);
31const concurrency = Number(values.concurrency);
32const verifyHits = Number(values["verify-hits"]);
33if (!Number.isSafeInteger(verifyHits) || verifyHits < 0)
34 throw new Error("Invalid verification count");
35if (![groupSize, concurrency].every((value) => Number.isSafeInteger(value) && value > 0)) {
36 throw new Error("Group size and concurrency must be positive integers");
37}
38if (values.shard && positionals.length !== 1) {
39 throw new Error("Sharding requires exactly one test project directory");
40}
41const candidates = values.shard
42 ? (await shardedTestFiles(NodePath.resolve(positionals[0]!), values.shard)).map((file) =>
43 NodePath.relative(root, file),
44 )
45 : NodeChildProcess.execFileSync(
46 "git",
47 ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
48 { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
49 ).split("\0");
50const filters = positionals.map((path) => NodePath.relative(root, NodePath.resolve(path)));
51const isSelectable = (file: string) =>
52 /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file) &&
53 !file.startsWith(".repos/") &&
54 NodeFS.existsSync(NodePath.join(root, file)) &&
55 matchesTestPath(file, filters);
56if (values.shard) {
57 const rejected = candidates.filter((file) => !isSelectable(file));
58 if (rejected.length)
59 throw new Error(`Discovered tests cannot be selected: ${rejected.join(", ")}`);
60}
61const files = [...new Set(candidates)].filter(isSelectable);
62if (files.length === 0) {
63 console.error("No test files matched");
64 process.exit(values.shard ? 0 : 1);
65}
66console.log(
67 `Cached tests: ${files.length} files, ${groupSize} per task, concurrency ${concurrency}`,
68);
69const env = {
70 ...process.env,
71 T3_CACHED_TESTS: JSON.stringify({
72 files,
73 groupSize,
74 uncached: values.uncached.map((file) => NodePath.relative(root, NodePath.resolve(file))),
75 }),
76};
77const started = performance.now();
78const result = NodeChildProcess.spawnSync(
79 NodePath.join(root, "node_modules/.bin/vp"),
80 ["run", "--concurrency-limit", String(concurrency), "test-cache"],
81 {
82 cwd: root,
83 env,
84 stdio: "inherit",
85 },
86);
87if (result.error) throw result.error;
88const cacheRunMs = performance.now() - started;
89process.exitCode = result.status ?? 1;
90if (verifyHits > 0) {
91 const cacheRoot =
92 process.env.VITE_CACHE_PATH || NodePath.join(root, "node_modules/.vite/task-cache");
93 const summaryFiles = NodeFS.readdirSync(cacheRoot, { withFileTypes: true })
94 .filter((entry) => entry.isDirectory())
95 .map((entry) => NodePath.join(cacheRoot, entry.name, "last-summary.json"))
96 .filter((file) => NodeFS.existsSync(file))
97 .sort((a, b) => NodeFS.statSync(b).mtimeMs - NodeFS.statSync(a).mtimeMs);
98 const summary: {
99 tasks: Array<{
100 task_name: string;
101 result: { CacheHit?: { source: string; saved_duration_ms: number } };
102 }>;
103 } = JSON.parse(NodeFS.readFileSync(summaryFiles[0]!, "utf8"));
104 const rank = (task: string) =>
105 NodeCrypto.createHash("sha256")
106 .update(
107 `${process.env.GITHUB_RUN_ID || "local"}:${process.env.GITHUB_RUN_ATTEMPT || "1"}:${task}`,
108 )
109 .digest("hex");
110 const hits = summary.tasks.filter((task) => task.result.CacheHit);
111 const verify = hits
112 .toSorted((a, b) => rank(a.task_name).localeCompare(rank(b.task_name)))
113 .slice(0, verifyHits);
114 const verification: Array<{ task: string; exitCode: number; durationMs: number }> = [];
115 if (result.status === 0) {
116 for (const task of verify) {
117 console.log(`Verifying cached result without cache: ${task.task_name}`);
118 const start = performance.now();
119 const audit = NodeChildProcess.spawnSync(
120 NodePath.join(root, "node_modules/.bin/vp"),
121 ["run", "--no-cache", "--remote-cache", "off", task.task_name],
122 { cwd: root, env, stdio: "inherit" },
123 );
124 if (audit.error) throw audit.error;
125 verification.push({
126 task: task.task_name,
127 exitCode: audit.status ?? 1,
128 durationMs: performance.now() - start,
129 });
130 if (audit.status !== 0) process.exitCode = audit.status ?? 1;
131 }
132 }
133 if (process.env.GITHUB_STEP_SUMMARY) {
134 const remote = hits.filter((task) => task.result.CacheHit?.source === "Remote").length;
135 const savedMs = hits.reduce(
136 (total, task) => total + (task.result.CacheHit?.saved_duration_ms ?? 0),
137 0,
138 );
139 NodeFS.appendFileSync(
140 process.env.GITHUB_STEP_SUMMARY,
141 `### Server test cache (${values.shard || "unsharded"})\n\n` +
142 `${files.length} files selected; ${hits.length} hits (${remote} remote); ${(savedMs / 1000).toFixed(1)}s task runtime avoided; ${(cacheRunMs / 1000).toFixed(1)}s elapsed.\n\n` +
143 `${verification.length} cached results re-executed without cache; ${verification.filter((audit) => audit.exitCode !== 0).length} verification failures.\n`,
144 );
145 }
146}