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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 59 additions & 16 deletions scripts/lib/cached-tests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";

import { cachedTestTasks, matchesTestPath } from "./cached-tests.ts";
import { cachedTestTasks, matchesTestPath, readRunSummary } from "./cached-tests.ts";

describe("cachedTestTasks", () => {
it("selects every test for a checkout-root path", () => {
Expand All @@ -22,34 +22,40 @@ describe("cachedTestTasks", () => {
expect(matchesTestPath("apps/server-other/a.test.ts", ["apps/server"])).toBe(false);
});
it("keeps a file's cache task stable when unrelated tests are added", () => {
const first = cachedTestTasks(JSON.stringify({ files: ["src/b.test.ts"], groupSize: 1 }));
const first = cachedTestTasks(
JSON.stringify({ run: "r", files: ["src/b.test.ts"], groupSize: 1 }),
);
const second = cachedTestTasks(
JSON.stringify({ files: ["src/a.test.ts", "src/b.test.ts"], groupSize: 1 }),
JSON.stringify({ run: "r", files: ["src/a.test.ts", "src/b.test.ts"], groupSize: 1 }),
);
const key = Object.keys(first).find((key) => key !== "test-cache")!;
expect(second[key]).toEqual(first[key]);
});

it("keeps shell metacharacters in filenames out of the command", () => {
const tasks = cachedTestTasks(
JSON.stringify({ files: ["src/a'$(touch injected).test.ts"], groupSize: 1 }),
JSON.stringify({ run: "r", files: ["src/a'$(touch injected).test.ts"], groupSize: 1 }),
);
const task = Object.entries(tasks).find(([key]) => key !== "test-cache")![1];
expect(task.command).toBe("vp test run 'src/a'\"'\"'$(touch injected).test.ts'");
});

it("rejects paths outside the checkout and empty groups", () => {
it("rejects paths outside the checkout, empty groups, and unsafe run ids", () => {
for (const request of [
{ files: ["../elsewhere.test.ts"], groupSize: 1 },
{ files: ["/absolute.test.ts"], groupSize: 1 },
{ files: ["src/a.test.ts"], groupSize: 0 },
{ run: "r", files: ["../elsewhere.test.ts"], groupSize: 1 },
{ run: "r", files: ["/absolute.test.ts"], groupSize: 1 },
{ run: "r", files: ["src/a.test.ts"], groupSize: 0 },
{ files: ["src/a.test.ts"], groupSize: 1 },
{ run: "r; touch injected", files: ["src/a.test.ts"], groupSize: 1 },
]) {
expect(() => cachedTestTasks(JSON.stringify(request))).toThrow();
}
});

it("accepts spec files discovered by Vitest", () => {
const tasks = cachedTestTasks(JSON.stringify({ files: ["src/example.spec.ts"], groupSize: 1 }));
const tasks = cachedTestTasks(
JSON.stringify({ run: "r", files: ["src/example.spec.ts"], groupSize: 1 }),
);
expect(
Object.values(tasks).some((task) => task.command === "vp test run 'src/example.spec.ts'"),
).toBe(true);
Expand All @@ -59,11 +65,45 @@ describe("cachedTestTasks", () => {
expect(cachedTestTasks(undefined)).toEqual({});
});

it("reads only the task summary of the given run", async () => {
const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cached-tests-"));
const write = async (version: string, run: string) => {
const file = NodePath.join(directory, version, "last-summary.json");
await NodeFSP.mkdir(NodePath.dirname(file));
const tasks = cachedTestTasks(JSON.stringify({ run, files: ["a.test.ts"], groupSize: 1 }));
await NodeFSP.writeFile(
file,
JSON.stringify({
tasks: Object.entries(tasks).map(([task_name, task]) => ({
task_name,
command: task.command,
result: {},
})),
}),
);
};
try {
expect(readRunSummary(NodePath.join(directory, "missing"), "own")).toBeUndefined();
await NodeFSP.writeFile(NodePath.join(directory, "file"), "");
expect(readRunSummary(NodePath.join(directory, "file"), "own")).toBeUndefined();
await write("v18", "other");
expect(readRunSummary(directory, "own")).toBeUndefined();
await write("v19", "own");
await NodeFSP.mkdir(NodePath.join(directory, "corrupt"));
await NodeFSP.writeFile(NodePath.join(directory, "corrupt", "last-summary.json"), "{");
expect(readRunSummary(directory, "own")?.tasks).toHaveLength(3);
} finally {
await NodeFSP.rm(directory, { recursive: true, force: true });
}
});

it("reuses individual results, invalidates filesystem inputs, and never caches failures", async () => {
const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cached-tests-"));
const root = NodePath.resolve(import.meta.dirname, "../..");
let runId = 0;
const run = (files = ["a.test.ts", "b.test.ts"], uncached: Array<string> = []) =>
new Promise<number | null>((resolve, reject) => {
runId += 1;
const child = NodeChildProcess.spawn(
process.execPath,
[NodePath.join(root, "node_modules/vite-plus/dist/bin.js"), "run", "test-cache"],
Expand All @@ -73,7 +113,12 @@ describe("cachedTestTasks", () => {
...process.env,
NODE_OPTIONS: "",
VP_REMOTE_CACHE: "off",
T3_CACHED_TESTS: JSON.stringify({ files, groupSize: 1, uncached }),
T3_CACHED_TESTS: JSON.stringify({
run: String(runId),
files,
groupSize: 1,
uncached,
}),
VITE_CACHE_PATH: NodePath.join(directory, "cache"),
PATH: `${NodePath.join(root, "node_modules/.bin")}${NodePath.delimiter}${process.env.PATH}`,
},
Expand All @@ -83,12 +128,10 @@ describe("cachedTestTasks", () => {
child.once("error", reject);
child.once("close", resolve);
});
const summary = async () => {
const value: { tasks: Array<{ result: Record<string, unknown> }> } = JSON.parse(
await NodeFSP.readFile(NodePath.join(directory, "cache/v18/last-summary.json"), "utf8"),
);
return value.tasks.filter((task) => "CacheHit" in task.result).length;
};
const summary = async () =>
readRunSummary(NodePath.join(directory, "cache"), String(runId))!.tasks.filter(
(task) => task.result.CacheHit,
).length;
try {
await NodeFSP.mkdir(NodePath.join(directory, "node_modules"));
await NodeFSP.symlink(
Expand Down
37 changes: 36 additions & 1 deletion scripts/lib/cached-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,36 @@ interface CachedTestTask {
};
}

interface TaskRunSummary {
tasks: Array<{
task_name: string;
command: string;
result: { CacheHit?: { source: string; saved_duration_ms: number } };
}>;
}

// vp keeps one last-summary.json per cache, rewritten by every `vp run`, so a run is
// recognized by an uncached task that every test depends on and that carries its id.
const runTask = "test-cache:run";
const runCommand = (run: string) => `node -e "" ${run}`;

export function readRunSummary(cacheRoot: string, run: string): TaskRunSummary | undefined {
try {
for (const entry of NodeFS.readdirSync(cacheRoot)) {
try {
const summary: TaskRunSummary = JSON.parse(
NodeFS.readFileSync(NodePath.join(cacheRoot, entry, "last-summary.json"), "utf8"),
);
if (summary.tasks.some((task) => task.command === runCommand(run))) return summary;
} catch {
continue;
}
}
} catch {
return undefined;
}
}

export function matchesTestPath(file: string, filters: Array<string>) {
return (
filters.length === 0 ||
Expand All @@ -36,7 +66,10 @@ export function cachedTestTasks(
!("groupSize" in request) ||
typeof request.groupSize !== "number" ||
!Number.isSafeInteger(request.groupSize) ||
request.groupSize < 1
request.groupSize < 1 ||
!("run" in request) ||
typeof request.run !== "string" ||
!/^[\w-]+$/.test(request.run)
) {
throw new Error("Invalid cached test request");
}
Expand Down Expand Up @@ -95,6 +128,7 @@ export function cachedTestTasks(
for (const group of groups) {
const key = NodeCrypto.createHash("sha256").update(group.join("\0")).digest("hex");
tasks[`test-cache:${key}`] = {
dependsOn: [runTask],
command: `${command} ${group.map((file) => `'${NodePath.relative(directory, NodePath.join(root, file)).replaceAll("'", "'\"'\"'")}'`).join(" ")}`,
...(directory === root ? {} : { cwd: NodePath.relative(root, directory) }),
cache: uncached.includes(group[0]!)
Expand All @@ -113,6 +147,7 @@ export function cachedTestTasks(
}
return {
...tasks,
[runTask]: { command: runCommand(request.run), cache: false },
"test-cache": { command: 'node -e ""', dependsOn: Object.keys(tasks), cache: false },
};
}
24 changes: 10 additions & 14 deletions scripts/test-cached.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as NodeUtil from "node:util";

import { matchesTestPath } from "./lib/cached-tests.ts";
import { matchesTestPath, readRunSummary } from "./lib/cached-tests.ts";
import { shardedTestFiles } from "./lib/sharded-tests.ts";

const root = NodePath.resolve(import.meta.dirname, "..");
Expand Down Expand Up @@ -67,9 +67,11 @@ if (files.length === 0) {
console.log(
`Cached tests: ${files.length} files, ${groupSize} per task, concurrency ${concurrency}`,
);
const run = NodeCrypto.randomBytes(16).toString("hex");
const env = {
...process.env,
T3_CACHED_TESTS: JSON.stringify({
run,
files,
groupSize,
uncached: values.uncached.map((file) => NodePath.relative(root, NodePath.resolve(file))),
Expand All @@ -91,24 +93,18 @@ process.exitCode = result.status ?? 1;
if (values.report || verifyHits > 0) {
const cacheRoot =
process.env.VITE_CACHE_PATH || NodePath.join(root, "node_modules/.vite/task-cache");
const summaryFiles = NodeFS.readdirSync(cacheRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => NodePath.join(cacheRoot, entry.name, "last-summary.json"))
.filter((file) => NodeFS.existsSync(file))
.sort((a, b) => NodeFS.statSync(b).mtimeMs - NodeFS.statSync(a).mtimeMs);
const summary: {
tasks: Array<{
task_name: string;
result: { CacheHit?: { source: string; saved_duration_ms: number } };
}>;
} = JSON.parse(NodeFS.readFileSync(summaryFiles[0]!, "utf8"));
const summary = readRunSummary(cacheRoot, run);
if (!summary) {
console.error(`No task summary from this run in ${cacheRoot}`);
process.exitCode ||= 1;
}
const rank = (task: string) =>
NodeCrypto.createHash("sha256")
.update(
`${process.env.GITHUB_RUN_ID || "local"}:${process.env.GITHUB_RUN_ATTEMPT || "1"}:${task}`,
)
.digest("hex");
const hits = summary.tasks.filter((task) => task.result.CacheHit);
const hits = (summary?.tasks ?? []).filter((task) => task.result.CacheHit);
const verify = hits
.toSorted((a, b) => rank(a.task_name).localeCompare(rank(b.task_name)))
.slice(0, verifyHits);
Expand Down Expand Up @@ -136,7 +132,7 @@ if (values.report || verifyHits > 0) {
files,
cacheRunMs,
exitCode: result.status,
summary,
summary: summary ?? null,
verification,
};
if (values.report) {
Expand Down
Loading