1// @effect-diagnostics nodeBuiltinImport:off - exercises the real native runner and local HTTP collector.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeDiagnosticsChannel from "node:diagnostics_channel";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeHttp from "node:http";
6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
8import { expect, it, vi } from "vite-plus/test";
9import startRunHook from "./runHook.ts";
10
11it("sends telemetry from a detached flusher that the task runner does not wait for", async () => {
12 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-flusher-"));
13 const reports: string[] = [];
14 let finishReturned!: () => void;
15 const returned = new Promise<void>((resolve) => {
16 finishReturned = resolve;
17 });
18 let disconnected = false;
19 const server = NodeHttp.createServer(async (request, response) => {
20 let body = "";
21 for await (const chunk of request) body += chunk;
22 reports.push(body);
23 await returned;
24 disconnected = response.destroyed;
25 response.writeHead(204).end();
26 });
27 // The hook detaches and unrefs its flusher, so only this channel lets cleanup wait for it.
28 const flushers: Promise<void>[] = [];
29 const onSpawn = (message: unknown) => {
30 const { process: flusher } = message as { process: NodeChildProcess.ChildProcess };
31 flushers.push(new Promise((resolve) => flusher.once("close", () => resolve())));
32 };
33 NodeDiagnosticsChannel.subscribe("child_process", onSpawn);
34 try {
35 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
36 const address = server.address();
37 if (!address || typeof address === "string") throw new Error("Missing HTTP address");
38 const endpoint = `http://127.0.0.1:${address.port}/projects/test`;
39 await NodeFSP.writeFile(NodePath.join(root, ".task-cache.json"), JSON.stringify({ endpoint }));
40 await NodeFSP.mkdir(NodePath.join(root, ".config/t3-task-cache"), { recursive: true });
41 await NodeFSP.writeFile(
42 NodePath.join(root, ".config/t3-task-cache/credentials.json"),
43 JSON.stringify({ [endpoint]: { name: "maria", token: "secret" } }),
44 );
45 vi.stubEnv("HOME", root);
46 vi.stubEnv("GITHUB_ACTIONS", undefined);
47 vi.stubEnv("T3_TASK_CACHE_TELEMETRY", undefined);
48 vi.stubEnv("VITE_CACHE_PATH", undefined);
49 const finish = startRunHook({ root });
50 const summary = NodePath.join(root, "node_modules/.vite/task-cache/run/last-summary.json");
51 await NodeFSP.mkdir(NodePath.dirname(summary), { recursive: true });
52 await NodeFSP.writeFile(
53 summary,
54 JSON.stringify({
55 tasks: [{ package_name: "fixture", task_name: "build", result: { Spawned: {} } }],
56 }),
57 );
58 await finish?.();
59 finishReturned();
60 await Promise.all(flushers);
61 expect(flushers).toHaveLength(1);
62 expect(reports.map((report) => JSON.parse(report).tasks)).toEqual([
63 [{ package: "fixture", task: "build", source: "executed", savedMs: 0 }],
64 ]);
65 expect(disconnected).toBe(false);
66 } finally {
67 finishReturned();
68 await Promise.all(flushers);
69 NodeDiagnosticsChannel.unsubscribe("child_process", onSpawn);
70 vi.unstubAllEnvs();
71 await new Promise<void>((resolve, reject) =>
72 server.close((error) => (error ? reject(error) : resolve())),
73 );
74 await NodeFSP.rm(root, { recursive: true, force: true });
75 }
76});
77
78it("rejects invalid setup credentials before installing anything and never prints the token", async () => {
79 const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-setup-"));
80 const token = "private-credential-that-must-not-appear-in-output";
81 const server = NodeHttp.createServer((_request, response) => response.writeHead(401).end());
82 try {
83 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
84 const address = server.address();
85 if (!address || typeof address === "string") throw new Error("Missing HTTP address");
86 const result = await new Promise<{ code: number | null; output: string }>((resolve, reject) => {
87 const child = NodeChildProcess.spawn(
88 process.execPath,
89 [NodePath.join(import.meta.dirname, "../scripts/install.ts"), "--non-interactive"],
90 {
91 env: {
92 ...process.env,
93 HOME: home,
94 T3_CACHE_SETUP_URL: `http://127.0.0.1:${address.port}/projects/test`,
95 T3_CACHE_SETUP_NAME: "maria",
96 T3_CACHE_SETUP_TOKEN: token,
97 },
98 stdio: ["ignore", "pipe", "pipe"],
99 },
100 );
101 let output = "";
102 child.stdout.on("data", (chunk) => {
103 output += chunk;
104 });
105 child.stderr.on("data", (chunk) => {
106 output += chunk;
107 });
108 child.once("error", reject);
109 child.once("close", (code) => resolve({ code, output }));
110 });
111 expect(result.code).not.toBe(0);
112 expect(result.output).toContain("Credential verification failed (401)");
113 expect(result.output).not.toContain(token);
114 expect(await NodeFSP.readdir(home)).toEqual([]);
115 } finally {
116 await new Promise<void>((resolve, reject) =>
117 server.close((error) => (error ? reject(error) : resolve())),
118 );
119 await NodeFSP.rm(home, { recursive: true, force: true });
120 }
121});
122
123it("does not start telemetry or create local state without cache credentials", async () => {
124 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-unconfigured-"));
125 try {
126 await NodeFSP.writeFile(
127 NodePath.join(root, ".task-cache.json"),
128 JSON.stringify({ endpoint: "https://cache.invalid/projects/test" }),
129 );
130 const hook = new URL("./runHook.ts", import.meta.url).href;
131 const output = await new Promise<string>((resolve, reject) => {
132 const child = NodeChildProcess.spawn(
133 process.execPath,
134 [
135 "--input-type=module",
136 "-e",
137 `import start from ${JSON.stringify(hook)}; const finish = start({ root: process.cwd() }); if (finish !== undefined) process.exit(1);`,
138 ],
139 {
140 cwd: root,
141 env: { ...process.env, HOME: root, VP_REMOTE_CACHE_URL: "" },
142 stdio: ["ignore", "pipe", "pipe"],
143 },
144 );
145 let output = "";
146 child.stdout.on("data", (chunk) => {
147 output += chunk;
148 });
149 child.stderr.on("data", (chunk) => {
150 output += chunk;
151 });
152 child.once("error", reject);
153 child.once("close", (code) =>
154 code === 0 ? resolve(output) : reject(new Error(output || `Exited ${code}`)),
155 );
156 });
157 expect(output).toBe("");
158 expect(await NodeFSP.readdir(root)).toEqual([".task-cache.json"]);
159 } finally {
160 await NodeFSP.rm(root, { recursive: true, force: true });
161 }
162});
163
164it("installs without masking .profile and configures CI through bash", async () => {
165 const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-login-"));
166 const server = NodeHttp.createServer((_request, response) => response.writeHead(204).end());
167 const run = (args: string[], env: NodeJS.ProcessEnv) =>
168 new Promise<string>((resolve, reject) => {
169 const child = NodeChildProcess.spawn(args[0]!, args.slice(1), {
170 env: { ...process.env, ...env, HOME: home, NODE_OPTIONS: "" },
171 stdio: ["ignore", "pipe", "pipe"],
172 });
173 let output = "";
174 child.stdout.on("data", (chunk) => {
175 output += chunk;
176 });
177 child.stderr.on("data", (chunk) => {
178 output += chunk;
179 });
180 child.once("error", reject);
181 child.once("close", (code) => (code === 0 ? resolve(output) : reject(new Error(output))));
182 });
183 try {
184 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
185 const address = server.address();
186 if (!address || typeof address === "string") throw new Error("Missing HTTP address");
187 await NodeFSP.writeFile(NodePath.join(home, ".profile"), "export EXISTING_PROFILE=preserved\n");
188 await NodeFSP.mkdir(NodePath.join(home, ".config/t3-task-cache"), { recursive: true });
189 await NodeFSP.writeFile(
190 NodePath.join(home, ".config/t3-task-cache/credentials.json"),
191 "{partial",
192 );
193 await run(
194 [
195 process.execPath,
196 NodePath.join(import.meta.dirname, "../scripts/install.ts"),
197 "--non-interactive",
198 ],
199 {
200 T3_CACHE_SETUP_URL: `http://127.0.0.1:${address.port}/projects/test`,
201 T3_CACHE_SETUP_NAME: "maria",
202 T3_CACHE_SETUP_TOKEN: "test-credential-for-isolated-http-server",
203 },
204 );
205 expect(await NodeFSP.readdir(home)).not.toContain(".bash_profile");
206 expect(await NodeFSP.readFile(NodePath.join(home, ".profile"), "utf8")).toContain(
207 "EXISTING_PROFILE=preserved",
208 );
209 const githubEnv = NodePath.join(home, "github-env");
210 await run([process.execPath, NodePath.join(import.meta.dirname, "../scripts/install-ci.ts")], {
211 T3_TASK_CACHE_READ_TOKEN: "read-only-fixture-token-01234567890123456789",
212 GITHUB_REPOSITORY: "pingdotgg/t3code",
213 GITHUB_ACTIONS: "true",
214 GITHUB_ENV: githubEnv,
215 RUNNER_TEMP: home,
216 });
217 const values = await NodeFSP.readFile(githubEnv, "utf8");
218 expect(values).not.toContain("\nNODE_OPTIONS=");
219 const bashEnv = values
220 .split("\n")
221 .find((line) => line.startsWith("BASH_ENV="))
222 ?.slice(9);
223 const output = await run(["bash", "-c", 'printf "%s" "$NODE_OPTIONS"'], { BASH_ENV: bashEnv });
224 expect(output).toContain("--import=file:");
225 expect(output).toContain("/infra/task-cache/src/preload.mjs");
226 await NodeFSP.rm(NodePath.join(home, ".local/share/t3-task-cache/preload.mjs"));
227 const missing = await run(["bash", "-c", `node -e 'console.log("missing preload is safe")'`], {
228 BASH_ENV: NodePath.join(home, ".config/t3-task-cache/env"),
229 });
230 expect(missing).toContain("missing preload is safe");
231
232 await NodeFSP.writeFile(
233 NodePath.join(home, ".bash_profile"),
234 '. "$HOME/.config/t3-task-cache/env"\n',
235 );
236 await run(
237 [
238 process.execPath,
239 NodePath.join(import.meta.dirname, "../scripts/install.ts"),
240 "--uninstall",
241 ],
242 {},
243 );
244 expect(await NodeFSP.readdir(home)).not.toContain(".bash_profile");
245 expect(await NodeFSP.readdir(NodePath.join(home, ".local/share/t3-task-cache"))).toEqual([
246 "preload.mjs",
247 ]);
248 expect(await NodeFSP.readFile(NodePath.join(home, ".profile"), "utf8")).not.toContain(
249 "t3-task-cache/env",
250 );
251 expect(await NodeFSP.readdir(NodePath.join(home, ".config/t3-task-cache"))).not.toContain(
252 "credentials.json",
253 );
254 const remaining = await run(
255 [
256 process.execPath,
257 "--import",
258 NodePath.join(home, ".local/share/t3-task-cache/preload.mjs"),
259 "-e",
260 'console.log("node survives uninstall")',
261 ],
262 {},
263 );
264 expect(remaining).toContain("node survives uninstall");
265 } finally {
266 await new Promise<void>((resolve, reject) =>
267 server.close((error) => (error ? reject(error) : resolve())),
268 );
269 await NodeFSP.rm(home, { recursive: true, force: true });
270 }
271});
272
273it("does not send an old endpoint's token after changing the URL interactively", async () => {
274 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-endpoint-"));
275 const requests: string[] = [];
276 const server = NodeHttp.createServer((request, response) => {
277 requests.push(request.headers.authorization || "");
278 response.writeHead(401).end();
279 });
280 try {
281 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
282 const address = server.address();
283 if (!address || typeof address === "string") throw new Error("Missing HTTP address");
284 const nested = NodePath.join(root, "nested");
285 await NodeFSP.mkdir(nested);
286 await NodeFSP.writeFile(
287 NodePath.join(nested, "package.json"),
288 JSON.stringify({ name: "nested", scripts: { build: 'node -e "console.log(8)"' } }),
289 );
290 await NodeFSP.writeFile(NodePath.join(nested, "pnpm-workspace.yaml"), "packages: []\n");
291 const endpoint = `http://127.0.0.1:${address.port}/projects/test`;
292 const scripts = NodePath.join(root, "infra/task-cache/scripts");
293 await NodeFSP.mkdir(scripts, { recursive: true });
294 await NodeFSP.mkdir(NodePath.join(root, "infra/task-cache/src"));
295 await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}');
296 await NodeFSP.writeFile(NodePath.join(root, ".task-cache.json"), JSON.stringify({ endpoint }));
297 await NodeFSP.copyFile(
298 NodePath.join(import.meta.dirname, "../scripts/install.ts"),
299 NodePath.join(scripts, "install.ts"),
300 );
301 await NodeFSP.copyFile(
302 NodePath.join(import.meta.dirname, "nativeHook.js"),
303 NodePath.join(root, "infra/task-cache/src/nativeHook.js"),
304 );
305 await NodeFSP.mkdir(NodePath.join(root, ".config/t3-task-cache"), { recursive: true });
306 await NodeFSP.writeFile(
307 NodePath.join(root, ".config/t3-task-cache/credentials.json"),
308 JSON.stringify({
309 [endpoint]: { name: "maria", token: "different-saved-endpoint-credential" },
310 }),
311 );
312 const result = await new Promise<{ code: number | null; output: string }>((resolve, reject) => {
313 const child = NodeChildProcess.spawn(
314 process.execPath,
315 [NodePath.join(scripts, "install.ts")],
316 {
317 env: {
318 ...process.env,
319 HOME: root,
320 NODE_OPTIONS: "",
321 T3_CACHE_SETUP_URL: "https://old.example",
322 T3_CACHE_SETUP_NAME: "maria",
323 T3_CACHE_SETUP_TOKEN: "old-endpoint-token-must-not-be-forwarded",
324 },
325 stdio: ["pipe", "pipe", "pipe"],
326 },
327 );
328 let output = "";
329 let sentUrl = false;
330 let sentName = false;
331 child.stdout.on("data", (chunk) => {
332 output += chunk;
333 if (!sentUrl && output.includes("Cache URL")) {
334 sentUrl = true;
335 child.stdin.write(endpoint + "\n");
336 }
337 if (!sentName && output.includes("Developer name")) {
338 sentName = true;
339 child.stdin.write("\n");
340 }
341 });
342 child.stderr.on("data", (chunk) => {
343 output += chunk;
344 });
345 child.once("error", reject);
346 child.once("close", (code) => resolve({ code, output }));
347 });
348 expect(result.code).not.toBe(0);
349 expect(result.output).toContain("Use T3_CACHE_SETUP_TOKEN");
350 expect(requests).toEqual([]);
351 } finally {
352 await new Promise<void>((resolve, reject) =>
353 server.close((error) => (error ? reject(error) : resolve())),
354 );
355 await NodeFSP.rm(root, { recursive: true, force: true });
356 }
357});
358
359it("rejects non-loopback HTTP before transmitting a setup credential", async () => {
360 const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "task-cache-http-"));
361 try {
362 const output = await new Promise<string>((resolve) => {
363 NodeChildProcess.execFile(
364 process.execPath,
365 [NodePath.join(import.meta.dirname, "../scripts/install.ts"), "--non-interactive"],
366 {
367 env: {
368 ...process.env,
369 HOME: home,
370 NODE_OPTIONS: "",
371 T3_CACHE_SETUP_URL: "http://cache.invalid/projects/test",
372 T3_CACHE_SETUP_NAME: "maria",
373 T3_CACHE_SETUP_TOKEN: "test-credential-that-must-not-be-transmitted",
374 },
375 },
376 (_error, stdout, stderr) => resolve(stdout + stderr),
377 );
378 });
379 expect(output).toContain("Use an HTTPS cache endpoint");
380 expect(await NodeFSP.readdir(home)).toEqual([]);
381 } finally {
382 await NodeFSP.rm(home, { recursive: true, force: true });
383 }
384});