-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Expand file tree
/
Copy pathtest-runner.ts
More file actions
487 lines (416 loc) · 16.4 KB
/
Copy pathtest-runner.ts
File metadata and controls
487 lines (416 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
// kilocode_change - new file
//
// Custom test runner that executes each test file in its own isolated process.
// Prevents cross-contamination between test files by ensuring separate PIDs,
// temp directories, in-memory databases, and environment state.
import os from "os"
import path from "path"
import fs from "fs/promises"
import { TestProfile } from "./kilocode/test-profile"
const root = path.resolve(import.meta.dir, "..")
const argv = process.argv.slice(2)
// ---------------------------------------------------------------------------
// Help
// ---------------------------------------------------------------------------
if (argv.includes("--help") || argv.includes("-h")) {
console.log(
[
"",
"Usage: bun run script/test-runner.ts [options] [patterns...]",
"",
"Runs test files in isolated parallel processes to prevent cross-contamination.",
"",
"Options:",
" --ci Enable JUnit XML output to .artifacts/unit/junit.xml",
" --concurrency <N> Max parallel processes (default: min(4, CPU count))",
" --timeout <ms> Per-test timeout passed to bun test (default: 60000)",
" --file-timeout <ms> Per-file process timeout (default: 300000)",
" --retries <N> Extra attempts for failing files (default: 1)",
" --profile <name> Run a curated test profile (env: KILO_TEST_PROFILE)",
" --bail Stop on first failure",
" --dots Show compact dot progress",
" --verbose Show full output for every file",
" -h, --help Show this help",
"",
"Positional:",
" [patterns...] Filter test files by substring match",
"",
].join("\n"),
)
process.exit(0)
}
// ---------------------------------------------------------------------------
// CLI parsing
// ---------------------------------------------------------------------------
function opt(name: string, fallback: number) {
const i = argv.indexOf(`--${name}`)
return i >= 0 && i + 1 < argv.length ? Number(argv[i + 1]) || fallback : fallback
}
function text(name: string) {
const i = argv.indexOf(`--${name}`)
if (i < 0) return
const value = argv[i + 1]
if (value && !value.startsWith("-")) return value
console.error(`Missing value for --${name}`)
process.exit(2)
}
const ci = argv.includes("--ci")
const bail = argv.includes("--bail")
const verbose = argv.includes("--verbose")
const dots = !verbose && (ci || argv.includes("--dots"))
// Cap concurrency at 4 even on bigger runners: the bottleneck is shared
// resources (ports, global filesystem like ~/.local/share/kilo), not CPU.
// Eight parallel processes was triggering port/FS races, not going faster.
const concurrency = opt("concurrency", Math.min(4, os.cpus().length))
const timeout = opt("timeout", 60000)
const deadline = opt("file-timeout", 300000)
const retries = opt("retries", 1)
const flag = text("profile")
const env = process.env.KILO_TEST_PROFILE?.trim() || undefined
if (flag && env && flag !== env) {
console.error(`Conflicting test profiles: --profile=${flag}, KILO_TEST_PROFILE=${env}`)
process.exit(2)
}
const profile = flag ?? env
const valued = new Set(["--concurrency", "--timeout", "--file-timeout", "--retries", "--profile"])
const patterns = argv.filter((arg, i) => {
if (arg.startsWith("-")) return false
if (i > 0 && valued.has(argv[i - 1])) return false
return true
})
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
const tty = !!process.stdout.isTTY
const green = (s: string) => (tty ? `\x1b[32m${s}\x1b[0m` : s)
const red = (s: string) => (tty ? `\x1b[31m${s}\x1b[0m` : s)
const yellow = (s: string) => (tty ? `\x1b[33m${s}\x1b[0m` : s)
const dim = (s: string) => (tty ? `\x1b[2m${s}\x1b[0m` : s)
const bold = (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s)
// ---------------------------------------------------------------------------
// File discovery
// ---------------------------------------------------------------------------
const glob = new Bun.Glob("**/*.test.{ts,tsx}")
const all = (await Array.fromAsync(glob.scan({ cwd: path.join(root, "test") })))
.map((file) => file.replaceAll("\\", "/"))
.sort()
export const skipped = new Set([
// Upstream browser OAuth integration tests bind the fixed callback port and
// race with other parallel OAuth tests in CI.
"mcp/oauth-browser.test.ts",
])
const selected = (() => {
if (!profile) return all
const result = TestProfile.resolve(profile, all)
if (!result.ok) {
console.error(result.error)
process.exit(2)
}
const blocked = result.files.filter((file) => skipped.has(file))
if (blocked.length > 0) {
console.error(`Test profile "${profile}" contains skipped files:\n${blocked.map((file) => `- ${file}`).join("\n")}`)
process.exit(2)
}
console.log(`Using test profile "${profile}": ${result.description} (${result.files.length} files)`)
return result.files
})()
const matched =
patterns.length > 0
? selected.filter((file) =>
patterns.some((pattern) => file.includes(pattern) || path.join("test", file).includes(pattern)),
)
: selected
const files = patterns.length > 0 && !profile ? matched : matched.filter((file) => !skipped.has(file)) // kilocode_change
if (files.length === 0) {
console.log("No test files found")
process.exit(0)
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Result = {
file: string
passed: boolean
code: number
stdout: string
stderr: string
duration: number
timedout: boolean
attempts: number
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : ""
if (ci) await fs.mkdir(xmldir, { recursive: true })
const counter = { done: 0 }
const pad = String(files.length).length
const progress = { width: 80 }
const marks = {
pass: ".",
retry: "R",
fail: "F",
timeout: "T",
} as const
const legend = `Legend: ${marks.pass}=pass ${marks.retry}=pass-after-retry ${marks.fail}=fail ${marks.timeout}=timeout`
// ---------------------------------------------------------------------------
// Run a single test file
// ---------------------------------------------------------------------------
async function run(file: string): Promise<Result> {
const target = path.join("test", file)
const cmd = ["bun", "test", target, "--timeout", String(timeout)]
if (ci) {
const name = file.replace(/[/\\]/g, "_") + ".xml"
cmd.push("--reporter=junit", `--reporter-outfile=${path.join(xmldir, name)}`)
}
const start = performance.now()
const killed = { value: false }
const proc = Bun.spawn(cmd, {
cwd: root,
stdout: "pipe",
stderr: "pipe",
})
const timer = setTimeout(() => {
killed.value = true
proc.kill()
}, deadline)
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
clearTimeout(timer)
return {
file,
passed: code === 0,
code,
stdout,
stderr,
duration: performance.now() - start,
timedout: killed.value,
attempts: 1,
}
}
// ---------------------------------------------------------------------------
// Report a single result
// ---------------------------------------------------------------------------
function mark(result: Result) {
if (result.timedout) return marks.timeout
if (!result.passed) return marks.fail
if (result.attempts > 1) return marks.retry
return marks.pass
}
function report(result: Result) {
counter.done++
if (dots) {
process.stdout.write(mark(result))
if (counter.done % progress.width === 0) process.stdout.write("\n")
return
}
const idx = String(counter.done).padStart(pad)
const secs = (result.duration / 1000).toFixed(1)
const tries = result.attempts > 1 ? dim(` [attempt ${result.attempts}/${retries + 1}]`) : ""
if (result.timedout) {
console.log(
`[${idx}/${files.length}] ${red("TIME")} ${result.file} ${dim(`(${secs}s - exceeded ${deadline / 1000}s)`)}${tries}`,
)
return
}
if (!result.passed) {
console.log(`[${idx}/${files.length}] ${red("FAIL")} ${result.file} ${dim(`(${secs}s)`)}${tries}`)
if (verbose && result.stderr.trim()) console.log(result.stderr)
if (verbose && result.stdout.trim()) console.log(result.stdout)
return
}
if (result.attempts > 1) {
console.log(`[${idx}/${files.length}] ${yellow("FLAKY")} ${result.file} ${dim(`(${secs}s)`)}${tries}`)
if (verbose && result.stdout.trim()) console.log(dim(result.stdout))
return
}
console.log(`[${idx}/${files.length}] ${green("PASS")} ${result.file} ${dim(`(${secs}s)`)}`)
if (verbose && result.stdout.trim()) console.log(dim(result.stdout))
}
// ---------------------------------------------------------------------------
// Parallel execution
// ---------------------------------------------------------------------------
console.log(`\nRunning ${bold(String(files.length))} test files with concurrency ${bold(String(concurrency))}`)
if (dots) console.log(dim(legend))
console.log()
const start = performance.now()
const results: Result[] = []
const queue = [...files]
const stopped = { value: false }
const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => {
while (queue.length > 0 && !stopped.value) {
const file = queue.shift()!
let result = await run(file)
// Retry failing files up to `retries` extra times. Bugs still fail on every
// attempt; contention-based flakes (port races, slow FS, slow spawn) recover.
// Preserve the last attempt's stdout/stderr/duration so a truly broken file
// still shows a useful diagnostic.
while (!result.passed && result.attempts <= retries && !stopped.value) {
const retry = await run(file)
retry.attempts = result.attempts + 1
result = retry
}
results.push(result)
report(result)
if (bail && !result.passed) stopped.value = true
}
})
await Promise.all(workers)
if (dots && counter.done % progress.width !== 0) console.log()
const elapsed = (performance.now() - start) / 1000
// ---------------------------------------------------------------------------
// Failure details
// ---------------------------------------------------------------------------
const failures = results.filter((r) => !r.passed).sort((a, b) => a.file.localeCompare(b.file))
if (failures.length > 0 && !verbose) {
console.log(`\n${bold(red("--- FAILURES ---"))}\n`)
for (const f of failures) {
const tag = f.timedout ? " (TIMED OUT)" : ""
console.log(`${bold(red(f.file))}${tag}:`)
const output = (f.stderr || f.stdout).trim()
if (output)
console.log(
output
.split("\n")
.map((l) => " " + l)
.join("\n"),
)
console.log()
}
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
const passed = results.filter((r) => r.passed).length
const flaky = results.filter((r) => r.passed && r.attempts > 1)
console.log(
`\n${bold(String(results.length))} files | ` +
`${green(passed + " passed")} | ` +
`${failures.length > 0 ? red(failures.length + " failed") : failures.length + " failed"} | ` +
`${flaky.length > 0 ? yellow(flaky.length + " flaky") : flaky.length + " flaky"} | ` +
`${elapsed.toFixed(1)}s\n`,
)
if (flaky.length > 0) {
const sorted = flaky.slice().sort((a, b) => a.file.localeCompare(b.file))
console.log(`${bold(yellow("--- FLAKY (passed on retry) ---"))}\n`)
for (const r of sorted) {
console.log(` ${yellow(r.file)} ${dim(`(passed on attempt ${r.attempts}/${retries + 1})`)}`)
}
console.log()
// Surface flakies to the GitHub Actions UI so reviewers don't have to scan
// the raw log. Annotations show up on the PR; the step summary is visible at
// the bottom of the job page and in the workflow summary email.
if (process.env.GITHUB_ACTIONS === "true") {
for (const r of sorted) {
const repo = `packages/opencode/test/${r.file}`
console.log(`::warning file=${repo},title=Flaky test file::passed on attempt ${r.attempts} of ${retries + 1}`)
}
const summary = process.env.GITHUB_STEP_SUMMARY
if (summary) {
const md = [
"### ⚠️ Flaky test files (passed on retry)",
"",
`${sorted.length} file${sorted.length === 1 ? "" : "s"} needed more than one attempt to pass.`,
"",
"| File | Attempts |",
"|---|---|",
...sorted.map((r) => `| \`${r.file}\` | ${r.attempts}/${retries + 1} |`),
"",
].join("\n")
await fs.appendFile(summary, md + "\n")
}
}
}
// ---------------------------------------------------------------------------
// JUnit XML merge (CI mode)
// ---------------------------------------------------------------------------
if (ci) {
await merge()
await fs.rm(xmldir, { recursive: true, force: true }).catch((err) => {
console.error("cleanup failed:", err)
})
}
process.exit(failures.length > 0 ? 1 : 0)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function merge() {
const dir = path.join(root, ".artifacts", "unit")
await fs.mkdir(dir, { recursive: true })
const suites: string[] = []
const counts = { tests: 0, failures: 0, errors: 0 }
for (const file of files) {
const name = file.replace(/[/\\]/g, "_") + ".xml"
const fpath = path.join(xmldir, name)
const found = await Bun.file(fpath).exists()
if (found) {
const content = await Bun.file(fpath).text()
const extracted = extract(content)
if (extracted) {
suites.push(extracted)
// Counts come from the outer <testsuites ...> root attributes, not from
// regex-scanning the inner content, so nested <testsuite> blocks (bun
// emits one per `describe`) don't get double-counted.
const root = content.match(/<testsuites\b([^>]*)>/)
if (root) {
counts.tests += attr(root[1], "tests")
counts.failures += attr(root[1], "failures")
counts.errors += attr(root[1], "errors")
}
continue
}
}
// No valid XML produced - generate synthetic entry for failed files
const result = results.find((r) => r.file === file)
if (!result || result.passed) continue
const secs = (result.duration / 1000).toFixed(3)
const msg = result.timedout
? `Test file timed out after ${deadline / 1000}s`
: `Test process exited with code ${result.code}`
const detail = esc((result.stderr || result.stdout || msg).slice(0, 10000))
suites.push(
` <testsuite name="${esc(file)}" tests="1" failures="1" errors="0" time="${secs}">\n` +
` <testcase name="${esc(file)}" classname="${esc(file)}" time="${secs}">\n` +
` <failure message="${esc(msg)}">${detail}</failure>\n` +
` </testcase>\n` +
` </testsuite>`,
)
counts.tests++
counts.failures++
}
const body = [
'<?xml version="1.0" encoding="UTF-8"?>',
`<testsuites tests="${counts.tests}" failures="${counts.failures}" errors="${counts.errors}" time="${elapsed.toFixed(3)}">`,
...suites,
"</testsuites>",
"",
].join("\n")
await Bun.write(path.join(dir, "junit.xml"), body)
}
// Grab everything between the outer <testsuites ...> and </testsuites> of a
// per-file JUnit XML. Preserves nested <testsuite> blocks verbatim — the
// previous hand-rolled walker matched the first </testsuite> it found, which
// closed an inner suite and left the outer one dangling in the merged output.
function extract(content: string): string {
const open = content.match(/<testsuites\b[^>]*>/)
if (!open) return ""
const start = open.index! + open[0].length
const end = content.lastIndexOf("</testsuites>")
if (end === -1 || end <= start) return ""
return content.slice(start, end).trim()
}
function attr(attrs: string, name: string): number {
const m = attrs.match(new RegExp(`\\b${name}="(\\d+)"`))
return m ? Number(m[1]) : 0
}
function esc(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
}