@mycadet/cli 0.13.0-dev.202610200020.9f20440 → 0.13.0-dev.202610201855.522ff0d
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@mycadet/assistant-api/constants/personality-sliders.test.ts +0 -8
- package/node_modules/@mycadet/assistant-api/constants/personality-sliders.ts +5 -41
- package/node_modules/@mycadet/assistant-api/events/message-complete.ts +0 -5
- package/node_modules/@mycadet/assistant-api/index.ts +0 -2
- package/node_modules/@mycadet/assistant-api/responses/conversation-message.ts +0 -10
- package/node_modules/@mycadet/environments/src/seeds.ts +6 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/seeds.ts +6 -2
- package/node_modules/@mycadet/local-mode/src/config.ts +0 -2
- package/node_modules/@mycadet/local-mode/src/index.ts +1 -1
- package/node_modules/@mycadet/local-mode/src/lockfile-contract.test.ts +1 -22
- package/node_modules/@mycadet/local-mode/src/lockfile-contract.ts +26 -7
- package/node_modules/@mycadet/local-mode/src/util.ts +1 -20
- package/node_modules/@mycadet/shared/github-repository.ts +5 -0
- package/node_modules/@mycadet/shared/package.json +10 -0
- package/node_modules/@mycadet/shared/scripts/run-isolated-tests.ts +700 -0
- package/package.json +3 -2
- package/src/__tests__/app-command.test.ts +164 -0
- package/src/__tests__/client-web-config.test.ts +0 -1
- package/src/__tests__/cloud-releases.test.ts +1 -1
- package/src/__tests__/local-version.test.ts +16 -0
- package/src/__tests__/nginx-ingress.test.ts +248 -630
- package/src/__tests__/ps-cloud-status.test.ts +85 -11
- package/src/__tests__/root-process-lockfile.test.ts +29 -2
- package/src/__tests__/tunnel-holder-heal.test.ts +119 -0
- package/src/__tests__/tunnel-stop.test.ts +6 -13
- package/src/__tests__/tunnel.test.ts +50 -193
- package/src/__tests__/wake.test.ts +6 -38
- package/src/__tests__/web-dist.test.ts +267 -89
- package/src/commands/app.ts +113 -0
- package/src/commands/backup.ts +5 -4
- package/src/commands/clean.ts +2 -1
- package/src/commands/client.ts +5 -68
- package/src/commands/completions.ts +12 -1
- package/src/commands/connect.ts +2 -1
- package/src/commands/debug.ts +2 -1
- package/src/commands/exec.ts +7 -6
- package/src/commands/gateway/token.ts +2 -1
- package/src/commands/gateway.ts +2 -1
- package/src/commands/hatch.ts +74 -80
- package/src/commands/login.ts +7 -6
- package/src/commands/logs.ts +2 -1
- package/src/commands/ps.ts +136 -6
- package/src/commands/restart.ts +2 -1
- package/src/commands/restore.ts +10 -9
- package/src/commands/retire.ts +4 -3
- package/src/commands/setup.ts +5 -4
- package/src/commands/sleep.ts +3 -2
- package/src/commands/teleport.ts +2 -1
- package/src/commands/terminal.ts +7 -6
- package/src/commands/tunnel.ts +196 -242
- package/src/commands/uninstall.ts +2 -1
- package/src/commands/upgrade.ts +2 -1
- package/src/commands/use.ts +2 -1
- package/src/commands/wake.ts +3 -2
- package/src/index.ts +5 -4
- package/src/lib/__tests__/web-dist.test.ts +1 -27
- package/src/lib/assistant-config.ts +101 -37
- package/src/lib/cli-name.ts +14 -0
- package/src/lib/cloud-client.ts +2 -1
- package/src/lib/cloud-login-poll.ts +2 -1
- package/src/lib/cloud-releases.ts +1 -1
- package/src/lib/docker.ts +32 -21
- package/src/lib/hatch-local.ts +1 -3
- package/src/lib/hatch-next-steps.ts +1 -9
- package/src/lib/ingress-config.ts +0 -31
- package/src/lib/local-version.ts +25 -4
- package/src/lib/local.ts +34 -9
- package/src/lib/nginx-ingress.ts +143 -244
- package/src/lib/provider-secrets.ts +0 -5
- package/src/lib/statefulset.ts +0 -1
- package/src/lib/tunnel.ts +244 -43
- package/src/lib/tunnels/ngrok.ts +57 -9
- package/src/lib/tunnels/tailscale.ts +2 -1
- package/src/lib/web-dist.ts +134 -56
- package/src/__tests__/flags.test.ts +0 -318
- package/src/__tests__/shell-mode.test.ts +0 -83
- package/src/commands/flags.ts +0 -255
- package/src/lib/flag-args.test.ts +0 -84
- package/src/lib/flag-args.ts +0 -74
- package/src/lib/github-repository.ts +0 -2
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { availableParallelism, tmpdir } from "node:os";
|
|
11
|
+
import { basename, join, relative } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { Glob } from "bun";
|
|
14
|
+
|
|
15
|
+
export interface IsolatedTestOptions {
|
|
16
|
+
/** Test files outside the collected globs that should always run. */
|
|
17
|
+
extraFiles?: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Drops a collected file from the run (assistant uses this for benchmark,
|
|
20
|
+
* known-broken, and experimental-only files).
|
|
21
|
+
*/
|
|
22
|
+
filter?: (file: string) => boolean;
|
|
23
|
+
/** Extra arguments passed through to every `bun test` invocation. */
|
|
24
|
+
bunTestArgs?: string[];
|
|
25
|
+
/** Runs once before collection (assistant: bundled catalog sync). */
|
|
26
|
+
setup?: () => void;
|
|
27
|
+
/**
|
|
28
|
+
* Builds a test fixture once up front in a subprocess (assistant: the
|
|
29
|
+
* pre-migrated DB). The fixture root is exported as `envVar` and the build
|
|
30
|
+
* subprocess receives the fixture's "migrated" directory as its argument.
|
|
31
|
+
*/
|
|
32
|
+
fixture?: { script: string; envVar: string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Run only test files changed vs main; fails open when the diff cannot be
|
|
36
|
+
// computed — a stale or absent base must never green-light a PR.
|
|
37
|
+
const TEST_DIFF_ONLY = process.env.TEST_DIFF_ONLY === "true";
|
|
38
|
+
const TEST_DIFF_BASE = "origin/main";
|
|
39
|
+
// One pattern set for every consumer: globs that match nothing in a given
|
|
40
|
+
// package are simply empty, so the union stays harmless everywhere.
|
|
41
|
+
const PATTERNS = [
|
|
42
|
+
"src/**/*.test.{ts,tsx}",
|
|
43
|
+
"scripts/**/*.test.{ts,js}",
|
|
44
|
+
".storybook/**/*.test.{ts,tsx}",
|
|
45
|
+
"eslint-rules/**/*.test.mjs",
|
|
46
|
+
];
|
|
47
|
+
// Shallow depth for fetching refs the diff needs. Pull-request checkouts run
|
|
48
|
+
// at a limited depth, so `origin/main` is often absent; this is deep enough
|
|
49
|
+
// for any realistic merge-base while staying ~10x cheaper than a full clone.
|
|
50
|
+
const DIFF_FETCH_DEPTH = "200";
|
|
51
|
+
// Wall-clock kill per test file: bun processes that pass but don't exit (open
|
|
52
|
+
// handles) would otherwise wedge a worker until the job timeout. SIGTERM
|
|
53
|
+
// first, then SIGKILL after a grace period, mirroring `timeout -k 10`.
|
|
54
|
+
const PER_TEST_TIMEOUT_MS =
|
|
55
|
+
(Number.parseInt(process.env.PER_TEST_TIMEOUT ?? "", 10) || 120) * 1000;
|
|
56
|
+
const KILL_GRACE_MS = 10_000;
|
|
57
|
+
|
|
58
|
+
export async function runIsolatedTests({
|
|
59
|
+
extraFiles = [],
|
|
60
|
+
filter,
|
|
61
|
+
bunTestArgs = [],
|
|
62
|
+
setup,
|
|
63
|
+
fixture,
|
|
64
|
+
}: IsolatedTestOptions = {}): Promise<void> {
|
|
65
|
+
const cwd = process.cwd();
|
|
66
|
+
setup?.();
|
|
67
|
+
|
|
68
|
+
// Timing-sensitive suites (real timers, `waitFor` deadlines) fail once the
|
|
69
|
+
// cores are saturated, so the default leaves half of them free.
|
|
70
|
+
const defaultConcurrency = Math.min(
|
|
71
|
+
8,
|
|
72
|
+
Math.max(2, Math.floor(availableParallelism() / 2)),
|
|
73
|
+
);
|
|
74
|
+
const concurrency = Math.max(
|
|
75
|
+
1,
|
|
76
|
+
Number.parseInt(
|
|
77
|
+
process.env.TEST_CONCURRENCY ?? process.env.TEST_WORKERS ?? "",
|
|
78
|
+
10,
|
|
79
|
+
) || defaultConcurrency,
|
|
80
|
+
);
|
|
81
|
+
const shard = parseShard(process.env.TEST_SHARD);
|
|
82
|
+
// Bun's default per-test timeout is 5s. Under saturated CI cores, RTL
|
|
83
|
+
// `waitFor` loops and slow integration tests can stretch past that even
|
|
84
|
+
// though they settle locally. TEST_TIMEOUT_MS / BUN_TEST_TIMEOUT lift the
|
|
85
|
+
// ceiling; 30s still fails a genuine hang, just after a slower one.
|
|
86
|
+
const timeoutMs =
|
|
87
|
+
Number.parseInt(process.env.TEST_TIMEOUT_MS ?? "", 10) ||
|
|
88
|
+
Number.parseInt(process.env.BUN_TEST_TIMEOUT ?? "", 10) ||
|
|
89
|
+
30000;
|
|
90
|
+
|
|
91
|
+
const allFiles = [
|
|
92
|
+
// `dot` is off by default, and the walker skips dot-directories even
|
|
93
|
+
// when a pattern spells one out, so suites under `.storybook/` need
|
|
94
|
+
// it on to be found at all.
|
|
95
|
+
// https://bun.sh/docs/api/glob#scan
|
|
96
|
+
...PATTERNS.flatMap((pattern) => [
|
|
97
|
+
...new Glob(pattern).scanSync({ cwd, dot: true }),
|
|
98
|
+
]),
|
|
99
|
+
...extraFiles,
|
|
100
|
+
]
|
|
101
|
+
.sort()
|
|
102
|
+
.filter((file) => filter?.(file) ?? true);
|
|
103
|
+
if (allFiles.length === 0) {
|
|
104
|
+
console.error("No test files found");
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const diffed = TEST_DIFF_ONLY ? diffTestFiles(allFiles, cwd) : null;
|
|
109
|
+
const scoped = diffed ?? allFiles;
|
|
110
|
+
if (diffed && scoped.length === 0) {
|
|
111
|
+
console.log(
|
|
112
|
+
`No test files changed vs ${TEST_DIFF_BASE} — nothing to run.`,
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Longest-first ordering (when a durations file exists) before sharding, so
|
|
117
|
+
// round-robin shards split the slow files evenly.
|
|
118
|
+
const sorted = sortLongestFirst(scoped);
|
|
119
|
+
const files = shard
|
|
120
|
+
? sorted.filter((_, index) => index % shard.total === shard.index - 1)
|
|
121
|
+
: sorted;
|
|
122
|
+
if (files.length === 0) {
|
|
123
|
+
console.log(`Nothing to run in shard ${shard?.index}/${shard?.total}`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Build the fixture once, up front, in a dedicated subprocess. Each test
|
|
128
|
+
// process inherits the fixtures root through the exported env var.
|
|
129
|
+
const fixturesRoot = mkdtempSync(join(tmpdir(), "cadet-test-fixtures-"));
|
|
130
|
+
if (fixture) {
|
|
131
|
+
const fixtureDir = join(fixturesRoot, "migrated");
|
|
132
|
+
const proc = Bun.spawn(["bun", "run", fixture.script, fixtureDir], {
|
|
133
|
+
cwd,
|
|
134
|
+
stdout: "inherit",
|
|
135
|
+
stderr: "inherit",
|
|
136
|
+
});
|
|
137
|
+
const code = await proc.exited;
|
|
138
|
+
if (code !== 0) {
|
|
139
|
+
console.error(`Failed to build test fixture (exit ${code})`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
process.env[fixture.envVar] = fixturesRoot;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(
|
|
146
|
+
`Running ${files.length} test file(s) (${concurrency} workers)${
|
|
147
|
+
shard ? `, shard ${shard.index}/${shard.total}` : ""
|
|
148
|
+
}`,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const resultsDir = mkdtempSync(join(tmpdir(), "cadet-test-run-"));
|
|
152
|
+
const coverage = process.env.COVERAGE === "true";
|
|
153
|
+
let coverageBase = "";
|
|
154
|
+
if (coverage) {
|
|
155
|
+
coverageBase = join(cwd, "coverage");
|
|
156
|
+
rmSync(coverageBase, { recursive: true, force: true });
|
|
157
|
+
mkdirSync(coverageBase, { recursive: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const results: FileResult[] = [];
|
|
161
|
+
|
|
162
|
+
// A rolling pool keeps every slot busy, so a slow file occupies only its
|
|
163
|
+
// own slot while the others keep pulling work.
|
|
164
|
+
let next = 0;
|
|
165
|
+
async function worker(): Promise<void> {
|
|
166
|
+
while (next < files.length) {
|
|
167
|
+
const file = files[next++];
|
|
168
|
+
results.push(
|
|
169
|
+
await runOneFile(file, { cwd, timeoutMs, bunTestArgs, resultsDir, coverage }),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await Promise.all(
|
|
174
|
+
Array.from({ length: Math.min(concurrency, files.length) }, worker),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Record durations for longest-first scheduling in future runs. Written
|
|
178
|
+
// before the failure exit so durations persist even when tests fail.
|
|
179
|
+
const durationsOutput = process.env.TEST_DURATIONS_OUTPUT ?? "";
|
|
180
|
+
if (durationsOutput) {
|
|
181
|
+
const sortedResults = [...results].sort((a, b) => b.elapsedMs - a.elapsedMs);
|
|
182
|
+
writeFileSync(
|
|
183
|
+
durationsOutput,
|
|
184
|
+
sortedResults.map((r) => `${r.elapsedMs}\t${r.file}`).join("\n") + "\n",
|
|
185
|
+
);
|
|
186
|
+
console.log(`Wrote test durations to ${durationsOutput}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const failures = results.filter((r) => r.outcome === "fail");
|
|
190
|
+
if (failures.length > 0) {
|
|
191
|
+
console.log("");
|
|
192
|
+
for (const result of failures) {
|
|
193
|
+
console.log("──────────────────────────────────────────");
|
|
194
|
+
console.log(`FAIL: ${result.file}`);
|
|
195
|
+
console.log("──────────────────────────────────────────");
|
|
196
|
+
process.stdout.write(result.output);
|
|
197
|
+
console.log("");
|
|
198
|
+
}
|
|
199
|
+
console.log("========================================");
|
|
200
|
+
console.log(` FAILED TEST FILES (${failures.length}):`);
|
|
201
|
+
console.log("========================================");
|
|
202
|
+
for (const result of failures) {
|
|
203
|
+
console.log(` ✗ ${result.file}`);
|
|
204
|
+
}
|
|
205
|
+
console.log("========================================");
|
|
206
|
+
rmSync(resultsDir, { recursive: true, force: true });
|
|
207
|
+
rmSync(fixturesRoot, { recursive: true, force: true });
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (coverage) {
|
|
212
|
+
const mergedPath = join(coverageBase, "lcov.info");
|
|
213
|
+
if (mergeLcov(resultsDir, mergedPath)) {
|
|
214
|
+
console.log("Coverage report written to coverage/lcov.info");
|
|
215
|
+
} else {
|
|
216
|
+
console.log("Warning: no coverage data was generated");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
rmSync(resultsDir, { recursive: true, force: true });
|
|
221
|
+
rmSync(fixturesRoot, { recursive: true, force: true });
|
|
222
|
+
|
|
223
|
+
const passed = results.filter((r) => r.outcome !== "fail").length;
|
|
224
|
+
console.log(
|
|
225
|
+
`\n${passed} passed, ${failures.length} failed (${files.length} test files${
|
|
226
|
+
shard ? `, shard ${shard.index}/${shard.total}` : ""
|
|
227
|
+
})`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Running a single test file
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
interface RunResult {
|
|
236
|
+
exitCode: number;
|
|
237
|
+
output: string;
|
|
238
|
+
timedOut: boolean;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const hasFailedAssertion = (out: string): boolean => /^\(fail\)/m.test(out);
|
|
242
|
+
const reachedEndOfRun = (out: string): boolean =>
|
|
243
|
+
/^Ran \d+ tests? across/m.test(out);
|
|
244
|
+
|
|
245
|
+
async function runBunTest(
|
|
246
|
+
file: string,
|
|
247
|
+
opts: {
|
|
248
|
+
cwd: string;
|
|
249
|
+
timeoutMs: number;
|
|
250
|
+
bunTestArgs: string[];
|
|
251
|
+
coverageArgs: string[];
|
|
252
|
+
},
|
|
253
|
+
): Promise<RunResult> {
|
|
254
|
+
// `bun test` reads a bare argument as a filename filter, and a filter never
|
|
255
|
+
// matches a path under a dot-directory, so every file is passed as an
|
|
256
|
+
// explicit relative path.
|
|
257
|
+
// https://bun.sh/docs/cli/test#run-specific-tests
|
|
258
|
+
const path =
|
|
259
|
+
file.startsWith("./") || file.startsWith("/") ? file : `./${file}`;
|
|
260
|
+
const args = [
|
|
261
|
+
"test",
|
|
262
|
+
...opts.coverageArgs,
|
|
263
|
+
...opts.bunTestArgs,
|
|
264
|
+
`--timeout=${opts.timeoutMs}`,
|
|
265
|
+
path,
|
|
266
|
+
];
|
|
267
|
+
const proc = Bun.spawn(["bun", ...args], {
|
|
268
|
+
cwd: opts.cwd,
|
|
269
|
+
stdout: "pipe",
|
|
270
|
+
stderr: "pipe",
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
let timedOut = false;
|
|
274
|
+
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
275
|
+
let graceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
276
|
+
killTimer = setTimeout(() => {
|
|
277
|
+
timedOut = true;
|
|
278
|
+
proc.kill("SIGTERM");
|
|
279
|
+
graceTimer = setTimeout(() => {
|
|
280
|
+
try {
|
|
281
|
+
proc.kill("SIGKILL");
|
|
282
|
+
} catch {
|
|
283
|
+
/* already exited */
|
|
284
|
+
}
|
|
285
|
+
}, KILL_GRACE_MS);
|
|
286
|
+
}, PER_TEST_TIMEOUT_MS);
|
|
287
|
+
|
|
288
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
289
|
+
new Response(proc.stdout).text(),
|
|
290
|
+
new Response(proc.stderr).text(),
|
|
291
|
+
proc.exited,
|
|
292
|
+
]);
|
|
293
|
+
if (killTimer) {
|
|
294
|
+
clearTimeout(killTimer);
|
|
295
|
+
}
|
|
296
|
+
if (graceTimer) {
|
|
297
|
+
clearTimeout(graceTimer);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { exitCode, output: stdout + stderr, timedOut };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
type Outcome = "pass" | "fail" | "hung-pass";
|
|
304
|
+
|
|
305
|
+
interface FileResult {
|
|
306
|
+
file: string;
|
|
307
|
+
outcome: Outcome;
|
|
308
|
+
elapsedMs: number;
|
|
309
|
+
output: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface RunOneFileOpts {
|
|
313
|
+
cwd: string;
|
|
314
|
+
timeoutMs: number;
|
|
315
|
+
bunTestArgs: string[];
|
|
316
|
+
resultsDir: string;
|
|
317
|
+
coverage: boolean;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function runOneFile(
|
|
321
|
+
file: string,
|
|
322
|
+
opts: RunOneFileOpts,
|
|
323
|
+
): Promise<FileResult> {
|
|
324
|
+
const base = basename(file);
|
|
325
|
+
const coverageArgs: string[] = [];
|
|
326
|
+
if (opts.coverage) {
|
|
327
|
+
const safe = file.replace(/\//g, "_");
|
|
328
|
+
const covDir = join(opts.resultsDir, `cov_${safe}`);
|
|
329
|
+
mkdirSync(covDir, { recursive: true });
|
|
330
|
+
coverageArgs.push(
|
|
331
|
+
"--coverage",
|
|
332
|
+
"--coverage-reporter=lcov",
|
|
333
|
+
`--coverage-dir=${covDir}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const start = performance.now();
|
|
338
|
+
let { exitCode, output, timedOut } = await runBunTest(file, {
|
|
339
|
+
...opts,
|
|
340
|
+
coverageArgs,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Retry once when a failing run produced no conclusive test evidence:
|
|
344
|
+
// - transient loader/runtime crash (non-zero, non-timeout exit)
|
|
345
|
+
// - transient hang (timeout kill before the end-of-run summary)
|
|
346
|
+
// Neither leaves a "(fail)" line; genuine assertion failures print one and
|
|
347
|
+
// are reported immediately (fast feedback, no masking of intermittent
|
|
348
|
+
// failures). A timed-out run that reached the end-of-run summary is the
|
|
349
|
+
// passed-but-hung-at-exit case — no retry needed.
|
|
350
|
+
let retryReason = "";
|
|
351
|
+
if (exitCode !== 0 && !hasFailedAssertion(output)) {
|
|
352
|
+
if (timedOut) {
|
|
353
|
+
if (!reachedEndOfRun(output)) {
|
|
354
|
+
retryReason = "hung without completing";
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
retryReason = `transient crash, exit ${exitCode}`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (retryReason) {
|
|
361
|
+
console.log(` ↻ ${base} (${retryReason} — retrying once)`);
|
|
362
|
+
({ exitCode, output, timedOut } = await runBunTest(file, {
|
|
363
|
+
...opts,
|
|
364
|
+
coverageArgs,
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const elapsedMs = Math.round(performance.now() - start);
|
|
369
|
+
|
|
370
|
+
let outcome: Outcome;
|
|
371
|
+
let line: string;
|
|
372
|
+
if (timedOut) {
|
|
373
|
+
// The process was killed by the timeout. It passed only if there were no
|
|
374
|
+
// failures AND the end-of-run summary is present; without the summary it
|
|
375
|
+
// was killed mid-run.
|
|
376
|
+
if (hasFailedAssertion(output)) {
|
|
377
|
+
outcome = "fail";
|
|
378
|
+
line = ` ✗ ${base} (killed after wall-clock timeout — tests failed and process hung)`;
|
|
379
|
+
} else if (reachedEndOfRun(output)) {
|
|
380
|
+
outcome = "hung-pass";
|
|
381
|
+
line = ` ⚠ ${base} (tests passed but process hung — likely open handles)`;
|
|
382
|
+
} else {
|
|
383
|
+
outcome = "fail";
|
|
384
|
+
line = ` ✗ ${base} (killed after wall-clock timeout — test run did not complete)`;
|
|
385
|
+
}
|
|
386
|
+
} else if (exitCode !== 0) {
|
|
387
|
+
outcome = "fail";
|
|
388
|
+
line = ` ✗ ${base} (${elapsedMs}ms)`;
|
|
389
|
+
} else {
|
|
390
|
+
outcome = "pass";
|
|
391
|
+
line = ` ✓ ${base} (${elapsedMs}ms)`;
|
|
392
|
+
}
|
|
393
|
+
console.log(line);
|
|
394
|
+
|
|
395
|
+
return { file, outcome, elapsedMs, output };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Collection helpers
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Sorts tests longest-first using durations from a previous run, so slow
|
|
404
|
+
* tests start immediately across all workers instead of piling up at the end
|
|
405
|
+
* and becoming long poles. No-op without TEST_DURATIONS_FILE.
|
|
406
|
+
*/
|
|
407
|
+
function sortLongestFirst(files: string[]): string[] {
|
|
408
|
+
const durationsFile = process.env.TEST_DURATIONS_FILE ?? "";
|
|
409
|
+
if (!durationsFile || !existsSync(durationsFile)) {
|
|
410
|
+
return files;
|
|
411
|
+
}
|
|
412
|
+
const durMap = new Map<string, number>();
|
|
413
|
+
for (const line of readFileSync(durationsFile, "utf-8").split("\n")) {
|
|
414
|
+
if (!line) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const tab = line.indexOf("\t");
|
|
418
|
+
if (tab === -1) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const ms = Number.parseInt(line.slice(0, tab), 10);
|
|
422
|
+
const path = line.slice(tab + 1);
|
|
423
|
+
if (path) {
|
|
424
|
+
durMap.set(path, ms);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const known: string[] = [];
|
|
428
|
+
const unknown: string[] = [];
|
|
429
|
+
for (const f of files) {
|
|
430
|
+
(durMap.has(f) ? known : unknown).push(f);
|
|
431
|
+
}
|
|
432
|
+
known.sort((a, b) => (durMap.get(b) ?? 0) - (durMap.get(a) ?? 0));
|
|
433
|
+
console.log(
|
|
434
|
+
`Sorted tests longest-first using ${durationsFile} (${known.length} known, ${unknown.length} new)`,
|
|
435
|
+
);
|
|
436
|
+
return [...known, ...unknown];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Intersects the collected test files with those changed vs main under the
|
|
441
|
+
* calling package's own directory (derived from its cwd). Returns null when
|
|
442
|
+
* the diff cannot be computed (missing base ref, shallow clone), which callers
|
|
443
|
+
* treat as "run everything" — a stale or absent base must never green-light a
|
|
444
|
+
* PR.
|
|
445
|
+
*/
|
|
446
|
+
function diffTestFiles(files: string[], cwd: string): string[] | null {
|
|
447
|
+
// Three levels up from packages/shared/scripts.
|
|
448
|
+
const repoRoot = join(import.meta.dir, "..", "..", "..");
|
|
449
|
+
ensureBaseRef(repoRoot);
|
|
450
|
+
const pkgDir = relative(repoRoot, cwd);
|
|
451
|
+
const proc = Bun.spawnSync(
|
|
452
|
+
["git", "diff", "--name-only", `${TEST_DIFF_BASE}...HEAD`, "--", pkgDir],
|
|
453
|
+
{ cwd: repoRoot, stdout: "pipe", stderr: "pipe" },
|
|
454
|
+
);
|
|
455
|
+
if (proc.exitCode !== 0) {
|
|
456
|
+
console.log(
|
|
457
|
+
`Could not diff vs ${TEST_DIFF_BASE} (exit ${proc.exitCode}: ${proc.stderr.toString().trim()}) — running the full suite.`,
|
|
458
|
+
);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
const prefix = pkgDir === "" ? "" : `${pkgDir}/`;
|
|
462
|
+
const changed = new Set(
|
|
463
|
+
proc.stdout
|
|
464
|
+
.toString()
|
|
465
|
+
.split("\n")
|
|
466
|
+
.filter(Boolean)
|
|
467
|
+
.filter((p) => p.startsWith(prefix))
|
|
468
|
+
.map((p) => p.slice(prefix.length)),
|
|
469
|
+
);
|
|
470
|
+
const scoped = files.filter((f) => changed.has(f));
|
|
471
|
+
console.log(
|
|
472
|
+
`Diff vs ${TEST_DIFF_BASE} touched ${changed.size} file(s); ${scoped.length} of ${files.length} collected test files match.`,
|
|
473
|
+
);
|
|
474
|
+
return scoped;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* PR checkouts run at a limited fetch depth, so `origin/main` is usually
|
|
479
|
+
* absent. Fetch it shallowly (best effort) so the diff can compute; when this
|
|
480
|
+
* fails the diff falls open to the full suite.
|
|
481
|
+
*/
|
|
482
|
+
function ensureBaseRef(repoRoot: string): void {
|
|
483
|
+
const check = Bun.spawnSync(
|
|
484
|
+
["git", "rev-parse", "--verify", "--quiet", TEST_DIFF_BASE],
|
|
485
|
+
{ cwd: repoRoot, stdout: "ignore", stderr: "ignore" },
|
|
486
|
+
);
|
|
487
|
+
if (check.exitCode === 0) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
console.log(
|
|
491
|
+
`${TEST_DIFF_BASE} not fetched — fetching it at depth ${DIFF_FETCH_DEPTH}...`,
|
|
492
|
+
);
|
|
493
|
+
Bun.spawnSync(
|
|
494
|
+
[
|
|
495
|
+
"git",
|
|
496
|
+
"fetch",
|
|
497
|
+
"--no-tags",
|
|
498
|
+
`--depth=${DIFF_FETCH_DEPTH}`,
|
|
499
|
+
"origin",
|
|
500
|
+
"main",
|
|
501
|
+
],
|
|
502
|
+
{ cwd: repoRoot, stdout: "ignore", stderr: "ignore" },
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Parses `TEST_SHARD=<index>/<total>` (1-based), which splits the sorted file
|
|
508
|
+
* list round-robin so CI can fan one suite out across parallel jobs.
|
|
509
|
+
*/
|
|
510
|
+
function parseShard(
|
|
511
|
+
value: string | undefined,
|
|
512
|
+
): { index: number; total: number } | undefined {
|
|
513
|
+
if (!value) {
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
const match = /^(\d+)\/(\d+)$/.exec(value);
|
|
517
|
+
const index = match ? Number(match[1]) : 0;
|
|
518
|
+
const total = match ? Number(match[2]) : 0;
|
|
519
|
+
if (index < 1 || index > total) {
|
|
520
|
+
throw new Error(`TEST_SHARD must look like "1/4", got "${value}"`);
|
|
521
|
+
}
|
|
522
|
+
return { index, total };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// Coverage merge
|
|
527
|
+
//
|
|
528
|
+
// Merges per-file lcov reports, deduplicating source files that appear in
|
|
529
|
+
// multiple runs — raw concatenation would count shared files multiple times.
|
|
530
|
+
// DA/FNDA/BRDA execution counts are summed; FN/DA/BRDA identities keep their
|
|
531
|
+
// first-seen order.
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
function mergeLcov(rawLcovDir: string, mergedPath: string): boolean {
|
|
535
|
+
interface FileCov {
|
|
536
|
+
daOrder: string[]; // line numbers in first-seen order
|
|
537
|
+
da: Map<string, number>; // line -> summed count
|
|
538
|
+
fnOrder: string[]; // "line,name" in first-seen order
|
|
539
|
+
fnSeen: Set<string>;
|
|
540
|
+
fndaOrder: string[]; // "line,name" in first-seen order
|
|
541
|
+
fnda: Map<string, number>; // "line,name" -> summed count
|
|
542
|
+
brdaOrder: string[]; // "line,block,branch" in first-seen order
|
|
543
|
+
brda: Map<string, number>; // key -> summed taken
|
|
544
|
+
brdaNumeric: Set<string>; // keys that had a numeric taken
|
|
545
|
+
}
|
|
546
|
+
const filesMap = new Map<string, FileCov>();
|
|
547
|
+
const fileOrder: string[] = [];
|
|
548
|
+
|
|
549
|
+
const emptyCov = (): FileCov => ({
|
|
550
|
+
daOrder: [],
|
|
551
|
+
da: new Map(),
|
|
552
|
+
fnOrder: [],
|
|
553
|
+
fnSeen: new Set(),
|
|
554
|
+
fndaOrder: [],
|
|
555
|
+
fnda: new Map(),
|
|
556
|
+
brdaOrder: [],
|
|
557
|
+
brda: new Map(),
|
|
558
|
+
brdaNumeric: new Set(),
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Concatenate all per-file lcov files, then walk line by line.
|
|
562
|
+
const covDirs = readdirSync(rawLcovDir).filter((d) => d.startsWith("cov_"));
|
|
563
|
+
let sawAny = false;
|
|
564
|
+
for (const dir of covDirs) {
|
|
565
|
+
const lcovFile = join(rawLcovDir, dir, "lcov.info");
|
|
566
|
+
if (!existsSync(lcovFile)) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
sawAny = true;
|
|
570
|
+
let cur: FileCov | null = null;
|
|
571
|
+
// FN/FNDA are paired by position WITHIN a file's SF block.
|
|
572
|
+
let blockFnAt: string[] = [];
|
|
573
|
+
let blockFndaIdx = 0;
|
|
574
|
+
for (const raw of readFileSync(lcovFile, "utf-8").split("\n")) {
|
|
575
|
+
if (raw.startsWith("SF:")) {
|
|
576
|
+
const sf = raw.slice(3);
|
|
577
|
+
if (!filesMap.has(sf)) {
|
|
578
|
+
filesMap.set(sf, emptyCov());
|
|
579
|
+
fileOrder.push(sf);
|
|
580
|
+
}
|
|
581
|
+
cur = filesMap.get(sf)!;
|
|
582
|
+
blockFnAt = [];
|
|
583
|
+
blockFndaIdx = 0;
|
|
584
|
+
} else if (raw.startsWith("DA:") && cur) {
|
|
585
|
+
const [line, countStr] = raw.slice(3).split(",");
|
|
586
|
+
const count = Number.parseInt(countStr, 10) || 0;
|
|
587
|
+
if (cur.da.has(line)) {
|
|
588
|
+
cur.da.set(line, cur.da.get(line)! + count);
|
|
589
|
+
} else {
|
|
590
|
+
cur.da.set(line, count);
|
|
591
|
+
cur.daOrder.push(line);
|
|
592
|
+
}
|
|
593
|
+
} else if (raw.startsWith("FN:") && cur) {
|
|
594
|
+
const entry = raw.slice(3); // "line,name"
|
|
595
|
+
if (!cur.fnSeen.has(entry)) {
|
|
596
|
+
cur.fnSeen.add(entry);
|
|
597
|
+
cur.fnOrder.push(entry);
|
|
598
|
+
}
|
|
599
|
+
blockFnAt.push(entry);
|
|
600
|
+
} else if (raw.startsWith("FNDA:") && cur) {
|
|
601
|
+
const rest = raw.slice(5); // "count,name"
|
|
602
|
+
const count = Number.parseInt(rest.split(",")[0], 10) || 0;
|
|
603
|
+
// Pair with the FN at the same position within this block.
|
|
604
|
+
const fnFull =
|
|
605
|
+
blockFnAt[blockFndaIdx] ?? rest.slice(rest.indexOf(",") + 1);
|
|
606
|
+
blockFndaIdx++;
|
|
607
|
+
if (cur.fnda.has(fnFull)) {
|
|
608
|
+
cur.fnda.set(fnFull, cur.fnda.get(fnFull)! + count);
|
|
609
|
+
} else {
|
|
610
|
+
cur.fnda.set(fnFull, count);
|
|
611
|
+
cur.fndaOrder.push(fnFull);
|
|
612
|
+
}
|
|
613
|
+
} else if (raw.startsWith("BRDA:") && cur) {
|
|
614
|
+
const parts = raw.slice(5).split(",");
|
|
615
|
+
const key = `${parts[0]},${parts[1]},${parts[2]}`;
|
|
616
|
+
const taken = parts[3];
|
|
617
|
+
if (taken === "-") {
|
|
618
|
+
if (!cur.brdaNumeric.has(key) && !cur.brda.has(key)) {
|
|
619
|
+
cur.brda.set(key, 0);
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
const takenCount = Number.parseInt(taken, 10) || 0;
|
|
623
|
+
if (cur.brdaNumeric.has(key)) {
|
|
624
|
+
cur.brda.set(key, cur.brda.get(key)! + takenCount);
|
|
625
|
+
} else {
|
|
626
|
+
cur.brda.set(key, takenCount);
|
|
627
|
+
}
|
|
628
|
+
cur.brdaNumeric.add(key);
|
|
629
|
+
}
|
|
630
|
+
if (!cur.brdaOrder.includes(key)) {
|
|
631
|
+
cur.brdaOrder.push(key);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!sawAny) {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const out: string[] = [];
|
|
642
|
+
for (const sf of fileOrder) {
|
|
643
|
+
const cov = filesMap.get(sf)!;
|
|
644
|
+
out.push(`SF:${sf}`);
|
|
645
|
+
for (const fn of cov.fnOrder) {
|
|
646
|
+
out.push(`FN:${fn}`);
|
|
647
|
+
}
|
|
648
|
+
let fnf = 0;
|
|
649
|
+
let fnh = 0;
|
|
650
|
+
for (const entry of cov.fndaOrder) {
|
|
651
|
+
const count = cov.fnda.get(entry)!;
|
|
652
|
+
// entry is "line,name" — strip leading "line," to get the name.
|
|
653
|
+
const comma = entry.indexOf(",");
|
|
654
|
+
const name = comma === -1 ? entry : entry.slice(comma + 1);
|
|
655
|
+
out.push(`FNDA:${count},${name}`);
|
|
656
|
+
fnf++;
|
|
657
|
+
if (count > 0) {
|
|
658
|
+
fnh++;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
out.push(`FNF:${fnf}`);
|
|
662
|
+
out.push(`FNH:${fnh}`);
|
|
663
|
+
let brf = 0;
|
|
664
|
+
let brh = 0;
|
|
665
|
+
const brLines: string[] = [];
|
|
666
|
+
for (const key of cov.brdaOrder) {
|
|
667
|
+
if (cov.brdaNumeric.has(key)) {
|
|
668
|
+
const taken = cov.brda.get(key)!;
|
|
669
|
+
brLines.push(`BRDA:${key},${taken}`);
|
|
670
|
+
brf++;
|
|
671
|
+
if (taken > 0) {
|
|
672
|
+
brh++;
|
|
673
|
+
}
|
|
674
|
+
} else {
|
|
675
|
+
brLines.push(`BRDA:${key},-`);
|
|
676
|
+
brf++;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
out.push(...brLines);
|
|
680
|
+
if (brf > 0) {
|
|
681
|
+
out.push(`BRF:${brf}`);
|
|
682
|
+
out.push(`BRH:${brh}`);
|
|
683
|
+
}
|
|
684
|
+
let lf = 0;
|
|
685
|
+
let lh = 0;
|
|
686
|
+
for (const line of cov.daOrder) {
|
|
687
|
+
const count = cov.da.get(line)!;
|
|
688
|
+
out.push(`DA:${line},${count}`);
|
|
689
|
+
lf++;
|
|
690
|
+
if (count > 0) {
|
|
691
|
+
lh++;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
out.push(`LF:${lf}`);
|
|
695
|
+
out.push(`LH:${lh}`);
|
|
696
|
+
out.push("end_of_record");
|
|
697
|
+
}
|
|
698
|
+
writeFileSync(mergedPath, out.join("\n") + "\n");
|
|
699
|
+
return true;
|
|
700
|
+
}
|