1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import { expect } from "vite-plus/test";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as Option from "effect/Option";
9import { afterAll, assert, describe, it } from "@effect/vitest";
10import * as Clock from "effect/Clock";
11import * as Effect from "effect/Effect";
12import * as Fiber from "effect/Fiber";
13import * as Sink from "effect/Sink";
14import * as Stream from "effect/Stream";
15import { ChildProcess, ChildProcessSpawner } from "effect/process";
16import * as TestClock from "effect/testing/TestClock";
17
18import {
19 comparisonBase,
20 defaultConcurrency,
21 docReaderTests,
22 gitLines,
23 runSteps,
24 splitTests,
25 type Step,
26} from "./check-changed.ts";
27import { decide, readWorkspace } from "../.github/scripts/ci-changes.cjs";
28
29const GiB = 1024 ** 3;
30
31describe("check:changed", () => {
32 it("bounds the default concurrency by the scarcer of cores and available memory", () => {
33 assert.strictEqual(defaultConcurrency(32, 128 * GiB), 16);
34 assert.strictEqual(defaultConcurrency(32, 20 * GiB), 3);
35 assert.strictEqual(defaultConcurrency(4, 15 * GiB), 2);
36 assert.strictEqual(defaultConcurrency(4, 2 * GiB), 1);
37 assert.strictEqual(defaultConcurrency(1, 64 * GiB), 1);
38 });
39
40 it("runs the server tests for the one guide they read and leaves other docs inert", () => {
41 const packages = readWorkspace();
42 assert.isTrue(
43 decide(["docs/user/t3-connect-troubleshooting.md"], packages, false).outputs.test_server,
44 );
45 assert.deepStrictEqual(docReaderTests(["docs/user/t3-connect-troubleshooting.md"]), [
46 "apps/server/src/cloud/ConnectReachability.test.ts",
47 ]);
48 assert.deepStrictEqual(decide(["docs/user/install.md"], packages, false).affected, []);
49 });
50
51 it.effect(
52 "keeps weighted steps within the budget, in order, and serial steps one at a time",
53 () =>
54 Effect.gen(function* () {
55 const startedAt: Record<string, number> = {};
56 let active = 0;
57 let maxActive = 0;
58 let serialActive = 0;
59 let maxSerial = 0;
60 const step = (
61 name: string,
62 options: { weight?: number; serial?: boolean } = {},
63 ): Step<never> => {
64 const slots = Math.min(options.weight ?? 1, 3);
65 return {
66 name,
67 ...options,
68 run: Effect.gen(function* () {
69 startedAt[name] = (yield* Clock.currentTimeMillis) / 1000;
70 active += slots;
71 maxActive = Math.max(maxActive, active);
72 if (options.serial) maxSerial = Math.max(maxSerial, ++serialActive);
73 yield* Effect.sleep("1 second");
74 active -= slots;
75 if (options.serial) serialActive--;
76 return { ok: true, output: "" };
77 }),
78 };
79 };
80 const fiber = yield* runSteps(
81 [
82 step("light 1"),
83 // Above the budget, so it holds every slot rather than never starting.
84 step("heavy", { weight: 5 }),
85 step("pair", { weight: 2 }),
86 step("light 2"),
87 step("test 1", { serial: true }),
88 step("test 2", { serial: true }),
89 ],
90 { concurrency: 3, timeoutSeconds: 60 },
91 ).pipe(Effect.forkChild);
92 yield* TestClock.adjust("10 seconds");
93 const results = yield* Fiber.join(fiber);
94
95 assert.deepStrictEqual(
96 results.map((result) => result.outcome),
97 ["pass", "pass", "pass", "pass", "pass", "pass"],
98 );
99 assert.strictEqual(maxActive, 3);
100 // Lighter steps queued behind the heavy one never start ahead of it.
101 assert.deepStrictEqual(startedAt, {
102 "light 1": 0,
103 heavy: 1,
104 pair: 2,
105 "light 2": 2,
106 "test 1": 3,
107 "test 2": 4,
108 });
109 assert.strictEqual(maxSerial, 1);
110 }),
111 );
112
113 it.effect("interrupts a step at its timeout and reports TIMEOUT, apart from FAIL", () =>
114 Effect.gen(function* () {
115 let interrupted = false;
116 const fiber = yield* runSteps(
117 [
118 {
119 name: "hangs",
120 run: Effect.never.pipe(
121 Effect.onInterrupt(() => Effect.sync(() => (interrupted = true))),
122 ),
123 },
124 { name: "fails", run: Effect.succeed({ ok: false, output: "error" }) },
125 { name: "passes", run: Effect.succeed({ ok: true, output: "" }) },
126 ],
127 { concurrency: 3, timeoutSeconds: 10 },
128 ).pipe(Effect.forkChild);
129 yield* TestClock.adjust("9 seconds");
130 assert.isFalse(interrupted);
131 yield* TestClock.adjust("1 second");
132 const results = yield* Fiber.join(fiber);
133
134 assert.isTrue(interrupted);
135 assert.deepStrictEqual(
136 results.map(({ name, outcome, time }) => [name, outcome, time]),
137 [
138 ["hangs", "TIMEOUT", "killed after 10s"],
139 ["fails", "FAIL", "0.0s"],
140 ["passes", "pass", "0.0s"],
141 ],
142 );
143 }),
144 );
145
146 it("runs the .github/scripts node:test files with node --test, not vitest", () => {
147 assert.deepStrictEqual(
148 splitTests([
149 "apps/server/src/ws.test.ts",
150 ".github/scripts/ci-changes.test.cjs",
151 ".github\\scripts\\thread-transfer-report.test.cjs",
152 "scripts/check-changed.test.ts",
153 ]),
154 {
155 vitestTests: ["apps/server/src/ws.test.ts", "scripts/check-changed.test.ts"],
156 nodeTests: [
157 ".github/scripts/ci-changes.test.cjs",
158 ".github\\scripts\\thread-transfer-report.test.cjs",
159 ],
160 },
161 );
162 });
163
164 it.effect("fails instead of returning an empty file list when git exits nonzero", () => {
165 const spawner = (exitCode: number) =>
166 ChildProcessSpawner.make(() =>
167 Effect.succeed(
168 ChildProcessSpawner.makeHandle({
169 pid: ChildProcessSpawner.ProcessId(1),
170 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
171 isRunning: Effect.succeed(false),
172 kill: () => Effect.void,
173 unref: Effect.succeed(Effect.void),
174 stdin: Sink.drain,
175 stdout: Stream.encodeText(Stream.make(exitCode === 0 ? "a.ts\nb.ts\n" : "")),
176 stderr: Stream.encodeText(
177 Stream.make(exitCode === 0 ? "" : "fatal: index file corrupt"),
178 ),
179 all: Stream.empty,
180 getInputFd: () => Sink.drain,
181 getOutputFd: () => Stream.empty,
182 }),
183 ),
184 );
185 return Effect.gen(function* () {
186 const listed = yield* gitLines("/repo", ["ls-files"]).pipe(
187 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner(0)),
188 );
189 assert.deepStrictEqual(listed, ["a.ts", "b.ts"]);
190 const error = yield* gitLines("/repo", ["ls-files"]).pipe(
191 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner(128)),
192 Effect.flip,
193 );
194 assert.strictEqual(
195 error,
196 "check:changed: git ls-files exited with 128: fatal: index file corrupt",
197 );
198 });
199 });
200
201 it("opens the built desktop app only for startup, settlement, and sidebar changes", () => {
202 const packages = readWorkspace();
203 const launches = (file: string) => decide([file], packages, false).outputs.desktop_launch;
204 for (const file of [
205 "apps/server/src/orchestration-v2/ThreadSettlementService.ts",
206 "apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts",
207 "apps/web/src/components/sidebar/SidebarThreadRow.tsx",
208 "apps/desktop/src/backend/DesktopBackendManager.ts",
209 "apps/desktop/src/electron/ElectronProtocol.ts",
210 "packages/contracts/src/settings.ts",
211 "scripts/fixtures/desktop-launch/statev2.sqlite",
212 ]) {
213 assert.isTrue(launches(file), file);
214 }
215 for (const file of [
216 "apps/server/src/orchestration-v2/ThreadSettlementService.test.ts",
217 "apps/web/src/components/chat/ChatView.tsx",
218 "apps/desktop/src/updates/DesktopRemoteUpdates.ts",
219 "packages/contracts/src/orchestration.ts",
220 ]) {
221 assert.isFalse(launches(file), file);
222 }
223 // A script-only change still builds the app it opens.
224 assert.isTrue(decide(["scripts/smoke-desktop-app.ts"], packages, false).outputs.build);
225 });
226
227 it.each([
228 "packages/shared/src/testing/longTempDir.ts",
229 "packages/shared/src/testing/providerEnv.ts",
230 ])("tests every package when the shared test setup file %s changes", (file) => {
231 assert.strictEqual(decide([file], readWorkspace(), false).outputs.test_packages, "");
232 });
233});
234
235// Hooks export GIT_DIR and GIT_INDEX_FILE, and user config or the default
236// ~/.config/git/ignore can ignore or sign fixture files.
237// Git for Windows rejects the null device as a config path, so use an empty file.
238const emptyConfigDir = NodeFS.mkdtempSync(
239 NodePath.join(NodeOS.tmpdir(), "t3-check-changed-config-"),
240);
241const emptyConfig = NodePath.join(emptyConfigDir, "empty.gitconfig");
242NodeFS.writeFileSync(emptyConfig, "");
243afterAll(() => NodeFS.rmSync(emptyConfigDir, { recursive: true, force: true }));
244const fixtureConfig = {
245 "core.excludesFile": emptyConfig,
246 "user.name": "Fixture",
247 "user.email": "fixture@example.invalid",
248};
249const env = {
250 ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_"))),
251 GIT_CONFIG_GLOBAL: emptyConfig,
252 GIT_CONFIG_NOSYSTEM: "1",
253 GIT_CONFIG_COUNT: String(Object.keys(fixtureConfig).length),
254 ...Object.fromEntries(
255 Object.entries(fixtureConfig).flatMap(([key, value], index) => [
256 [`GIT_CONFIG_KEY_${index}`, key],
257 [`GIT_CONFIG_VALUE_${index}`, value],
258 ]),
259 ),
260};
261
262// History A -> B -> C, with HEAD at C. Each remote's main points at A or B.
263function fixture(remotes: Record<string, "A" | "B">) {
264 const root = NodeFS.realpathSync(
265 NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-check-changed-")),
266 );
267 const commits: Record<string, string> = {};
268 try {
269 const write = (file: string, content: string) => {
270 const path = NodePath.join(root, file);
271 NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true });
272 NodeFS.writeFileSync(path, content);
273 };
274 const git = (...args: Array<string>) =>
275 NodeChildProcess.execFileSync("git", args, { cwd: root, encoding: "utf8", env }).trim();
276 git("init");
277 for (const name of ["A", "B", "C"]) {
278 write(`${name}.txt`, name);
279 git("add", ".");
280 git("commit", "-m", name);
281 commits[name] = git("rev-parse", "HEAD");
282 }
283 for (const [remote, commit] of Object.entries(remotes)) {
284 git("remote", "add", remote, NodePath.join(root, "missing"));
285 git("update-ref", `refs/remotes/${remote}/main`, commits[commit]!);
286 }
287 } catch (error) {
288 NodeFS.rmSync(root, { recursive: true, force: true });
289 throw error;
290 }
291 return {
292 commits,
293 root,
294 [Symbol.dispose]() {
295 NodeFS.rmSync(root, { recursive: true, force: true });
296 },
297 };
298}
299
300const compare = (root: string, ref: Option.Option<string>) =>
301 Effect.gen(function* () {
302 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
303 return yield* comparisonBase(root, ref).pipe(
304 Effect.provideService(
305 ChildProcessSpawner.ChildProcessSpawner,
306 ChildProcessSpawner.make((command) => spawner.spawn(ChildProcess.setEnv(command, env))),
307 ),
308 );
309 }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise);
310
311it.each(["upstream", "team/upstream"])(
312 "compares against %s/main when the fork's origin/main lags it",
313 async (remote) => {
314 using project = fixture({ origin: "A", [remote]: "B" });
315 const result = await compare(project.root, Option.none());
316 assert.strictEqual(result.base, project.commits.B);
317 assert.strictEqual(result.ref, remote + "/main");
318 },
319);
320
321it("compares against an explicit ref instead of the closest remote main", async () => {
322 using project = fixture({ origin: "A", upstream: "B" });
323 const result = await compare(project.root, Option.some("origin/main"));
324 assert.strictEqual(result.base, project.commits.A);
325 assert.strictEqual(result.ref, "origin/main");
326});
327
328function launcherFixture() {
329 const root = NodeFS.realpathSync(
330 NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3 checks & (fixture) ")),
331 );
332 const write = (file: string, content: string) => {
333 const path = NodePath.join(root, file);
334 NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true });
335 NodeFS.writeFileSync(path, content);
336 };
337 for (const file of [
338 "scripts/check-changed.ts",
339 "scripts/check-changed-command.ts",
340 ".github/scripts/ci-changes.cjs",
341 ]) {
342 write(file, NodeFS.readFileSync(NodePath.join(import.meta.dirname, "..", file), "utf8"));
343 }
344 write(".gitignore", "node_modules/\nrecords/\n");
345 write("pnpm-workspace.yaml", "packages:\n - packages/*\n");
346 write(
347 "package.json",
348 JSON.stringify({
349 type: "module",
350 scripts: {
351 "knip:check:files": "knip --include files",
352 "knip:check:exports": "knip --exports",
353 },
354 }),
355 );
356 write("packages/example/package.json", JSON.stringify({ name: "example" }));
357 const source = "packages/example/space & (draft) %PATH% ! 研究.ts";
358 write(source, "export const value = 1;\n");
359 write(
360 "node_modules/vite-plus/package.json",
361 JSON.stringify({ type: "module", exports: { "./bin": "./cli.js" } }),
362 );
363 write(
364 "node_modules/vite-plus/cli.js",
365 `
366 import fs from 'node:fs';
367 import path from 'node:path';
368 const args = process.argv.slice(2);
369 fs.writeFileSync(path.join(process.env.RECORDS, process.pid + '.json'), JSON.stringify({ args, cwd: process.cwd() }));
370 console.log('fixture stdout: ' + args[0]);
371 console.error('fixture stderr: ' + args[0]);
372 process.exit(args[0] === process.env.FAIL_STEP ? 23 : 0);
373 `,
374 );
375 // Select the Windows launcher without changing the Effect runtime's host platform.
376 write(
377 "scripts/check-changed.ts",
378 NodeFS.readFileSync(NodePath.join(import.meta.dirname, "check-changed.ts"), "utf8").replace(
379 "vitePlusCommand(args, platform)",
380 'vitePlusCommand(args, "win32")',
381 ),
382 );
383 for (const name of ["effect", "@effect/platform-node", "@t3tools/shared"]) {
384 const dest = NodePath.join(root, "node_modules", name);
385 NodeFS.mkdirSync(NodePath.dirname(dest), { recursive: true });
386 NodeFS.symlinkSync(
387 NodeFS.realpathSync(NodePath.join(import.meta.dirname, "node_modules", name)),
388 dest,
389 "junction",
390 );
391 }
392 const git = (...args: Array<string>) =>
393 NodeChildProcess.execFileSync("git", args, { cwd: root, stdio: "pipe", env });
394 git("init");
395 git("add", ".");
396 git(
397 "-c",
398 "user.name=Fixture",
399 "-c",
400 "user.email=fixture@example.invalid",
401 "-c",
402 "commit.gpgsign=false",
403 "-c",
404 "core.hooksPath=",
405 "commit",
406 "-m",
407 "fixture",
408 );
409 const records = NodePath.join(root, "records");
410 NodeFS.mkdirSync(records);
411 return {
412 root,
413 source,
414 write,
415 run(failStep = "") {
416 return NodeChildProcess.spawnSync(process.execPath, ["scripts/check-changed.ts", "HEAD"], {
417 cwd: root,
418 encoding: "utf8",
419 env: { ...env, TEMP: NodeOS.tmpdir(), RECORDS: records, FAIL_STEP: failStep },
420 });
421 },
422 calls: (): Array<{ args: Array<string>; cwd: string }> =>
423 NodeFS.readdirSync(records).map((file) =>
424 JSON.parse(NodeFS.readFileSync(NodePath.join(records, file), "utf8")),
425 ),
426 [Symbol.dispose]() {
427 NodeFS.rmSync(root, { recursive: true, force: true });
428 },
429 };
430}
431
432it("checks changed source paths through the Windows launcher", () => {
433 using project = launcherFixture();
434 project.write(project.source, "export const value = 2;\n");
435 const result = project.run();
436 expect(result.status, result.stdout + result.stderr).toBe(0);
437 const calls = project.calls();
438 expect(calls.find(({ args }) => args[0] === "lint")).toEqual({
439 args: ["lint", "--no-error-on-unmatched-pattern", project.source],
440 cwd: project.root,
441 });
442 expect(calls.some(({ args }) => args[0] === "fmt" && args.includes(project.source))).toBe(true);
443 expect(calls.some(({ args }) => args[0] === "run" && args.includes("typecheck"))).toBe(true);
444 expect(calls.some(({ args }) => args[0] === "install")).toBe(false);
445 expect(result.stdout).toContain("fixture stdout: lint");
446 expect(result.stdout).toContain("fixture stderr: lint");
447});
448
449it("lints an Astro component and runs knip when only it changed", () => {
450 using project = launcherFixture();
451 const page = "packages/example/Page.astro";
452 project.write(page, "---\nconst title = 1;\n---\n<h1>{title}</h1>\n");
453 const result = project.run();
454 expect(result.status, result.stdout + result.stderr).toBe(0);
455 const calls = project.calls();
456 expect(calls.find(({ args }) => args[0] === "lint")?.args).toEqual([
457 "lint",
458 "--no-error-on-unmatched-pattern",
459 page,
460 ]);
461 const commands = calls.map(({ args }) => args);
462 expect(commands).toContainEqual(["exec", "knip", "--include", "files"]);
463 expect(commands).toContainEqual(["exec", "knip", "--exports"]);
464});
465
466it.each(["install", "lint"])("reports a failing %s step with its output", (step) => {
467 using project = launcherFixture();
468 project.write(project.source, "export const value = 2;\n");
469 project.write(
470 "packages/example/package.json",
471 JSON.stringify({ name: "example", version: "1.0.1" }),
472 );
473 const result = project.run(step);
474 expect(result.status, result.stdout + result.stderr).toBe(1);
475 const install = project.calls().find(({ args }) => args[0] === "install");
476 expect(install?.args, result.stdout + result.stderr).toEqual([
477 "install",
478 "--lockfile-only",
479 "--ignore-scripts",
480 ]);
481 expect(install?.cwd).not.toBe(project.root);
482 expect(result.stdout + result.stderr).toContain(`fixture stdout: ${step}`);
483 expect(result.stdout + result.stderr).toContain(`fixture stderr: ${step}`);
484 expect(result.stdout).toContain(step === "install" ? "FAIL unused patches" : "FAIL lint");
485 if (step === "lint") expect(result.stdout).toContain("pass unused patches");
486 expect(
487 NodeFS.existsSync(NodePath.join(project.root, "node_modules/.cache/check-changed.json")),
488 ).toBe(false);
489});