paavohtl Claude Opus 4.6 (1M context) commited on
Commit
d79fe2d
Β·
1 Parent(s): f72f2a3

feat: add generate-baseline subcommand and -d flag to test runner

Browse files

Adds `generate-baseline` for bulk expected log generation from replays
without baselines, and `-d DIR` to both headless and generate-baseline
modes for targeting subdirectories (e.g. testdata/replays/worms2d).
Extension matching is now case-insensitive (.WAgame and .wagame).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

crates/openwa-test-runner/CLAUDE.md CHANGED
@@ -4,9 +4,10 @@ Replay test runner (`openwa-test` binary). Supports headless (concurrent, log-di
4
 
5
  ## Subcommands
6
 
7
- - `openwa-test [filter] [-j N] [--no-build] [--wa-path PATH]` β€” Headless: discovers `testdata/replays/*.WAgame` with `*_expected.log`, runs concurrently via `/getlog`, compares output byte-for-byte.
8
  - `openwa-test headful [filter|replay.WAgame] [--no-build] [--wa-path PATH] [--timeout SECS]` β€” Headful: runs with graphics+sound, checks for crashes/panics/gameplay markers. Default timeout 150s.
9
  - `openwa-test trace-desync <replay.WAgame> [--no-build] [--wa-path PATH]` β€” Compares per-frame checksums between baseline and hooked runs.
 
10
 
11
  ## Test Isolation
12
 
@@ -42,9 +43,18 @@ A headful test passes when: no crash, no `[PANIC]` in OpenWA.log, no `[GAMEPLAY
42
  - `OPENWA_LOG_PATH=<path>` β€” Override OpenWA.log location (used for per-instance isolation)
43
  - `OPENWA_ERRORLOG_PATH=<path>` β€” Redirect ERRORLOG.TXT to specified path (crash capture)
44
 
 
 
 
 
 
 
 
 
45
  ## Key Paths
46
 
47
  - Replay files + expected logs: `testdata/replays/*.WAgame` + `*_expected.log`
 
48
  - Per-run output: `testdata/runs/<timestamp>/` (headless), `testdata/runs/headful-<timestamp>/` (headful)
49
  - Headless script: `run-tests.ps1`
50
  - Headful script: `replay-test.ps1`
 
4
 
5
  ## Subcommands
6
 
7
+ - `openwa-test [filter] [-j N] [-d DIR] [--no-build] [--wa-path PATH]` β€” Headless: discovers `*.WAgame`/`*.wagame` with `*_expected.log` in DIR (default `testdata/replays`), runs concurrently via `/getlog`, compares output byte-for-byte.
8
  - `openwa-test headful [filter|replay.WAgame] [--no-build] [--wa-path PATH] [--timeout SECS]` β€” Headful: runs with graphics+sound, checks for crashes/panics/gameplay markers. Default timeout 150s.
9
  - `openwa-test trace-desync <replay.WAgame> [--no-build] [--wa-path PATH]` β€” Compares per-frame checksums between baseline and hooked runs.
10
+ - `openwa-test generate-baseline [filter] [-d DIR] [-j N] [--no-build] [--wa-path PATH]` β€” Generates `*_expected.log` baselines for replays that don't have one yet. Runs with `OPENWA_TRACE_BASELINE=1` (minimal hooks).
11
 
12
  ## Test Isolation
13
 
 
43
  - `OPENWA_LOG_PATH=<path>` β€” Override OpenWA.log location (used for per-instance isolation)
44
  - `OPENWA_ERRORLOG_PATH=<path>` β€” Redirect ERRORLOG.TXT to specified path (crash capture)
45
 
46
+ ## Worms 2D Speedrun Suite
47
+
48
+ ~598 replays in `testdata/replays/worms2d/` (`.wagame` extension). Must run with `-j 1` or `-j 2` β€” higher concurrency causes ~5-7% flakes from shared resource contention. Full suite takes ~3-4 min at `-j 1`. Use for final validation only.
49
+
50
+ ```bash
51
+ openwa-test -d testdata/replays/worms2d -j 1
52
+ ```
53
+
54
  ## Key Paths
55
 
56
  - Replay files + expected logs: `testdata/replays/*.WAgame` + `*_expected.log`
57
+ - Worms 2D replays: `testdata/replays/worms2d/*.wagame` + `*_expected.log`
58
  - Per-run output: `testdata/runs/<timestamp>/` (headless), `testdata/runs/headful-<timestamp>/` (headful)
59
  - Headless script: `run-tests.ps1`
60
  - Headful script: `replay-test.ps1`
crates/openwa-test-runner/src/main.rs CHANGED
@@ -55,6 +55,7 @@ struct Args {
55
  jobs: usize,
56
  no_build: bool,
57
  wa_path: Option<PathBuf>,
 
58
  }
59
 
60
  // ─── Argument parsing ───────────────────────────────────────────────────────
@@ -65,6 +66,7 @@ fn parse_args() -> Args {
65
  jobs: DEFAULT_JOBS,
66
  no_build: false,
67
  wa_path: None,
 
68
  };
69
 
70
  let argv: Vec<String> = env::args().skip(1).collect();
@@ -84,12 +86,20 @@ fn parse_args() -> Args {
84
  args.wa_path = Some(PathBuf::from(&argv[i]));
85
  }
86
  }
 
 
 
 
 
 
87
  s if !s.starts_with('-') => {
88
  args.filter = Some(s.to_string());
89
  }
90
  other => {
91
  eprintln!("Unknown argument: {other}");
92
- eprintln!("Usage: openwa-test [filter] [-j N] [--no-build] [--wa-path PATH]");
 
 
93
  std::process::exit(1);
94
  }
95
  }
@@ -101,21 +111,25 @@ fn parse_args() -> Args {
101
 
102
  // ─── Test discovery ─────────────────────────────────────────────────────────
103
 
104
- fn discover_tests(filter: Option<&str>) -> Vec<TestCase> {
105
- let replays_dir = Path::new(REPLAYS_DIR);
106
  let mut tests = Vec::new();
107
 
108
  let entries = match fs::read_dir(replays_dir) {
109
  Ok(e) => e,
110
  Err(e) => {
111
- eprintln!("Cannot read {REPLAYS_DIR}: {e}");
112
  return tests;
113
  }
114
  };
115
 
116
  for entry in entries.flatten() {
117
  let path = entry.path();
118
- if path.extension().and_then(|e| e.to_str()) != Some("WAgame") {
 
 
 
 
 
119
  continue;
120
  }
121
 
@@ -1314,6 +1328,369 @@ fn report_startup_check_failures(results: &[TestResult], run_dir: &Path) {
1314
  }
1315
  }
1316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1317
  // ─── Main ───────────────────────────────────────────────────────────────────
1318
 
1319
  fn main() {
@@ -1330,15 +1707,20 @@ fn main() {
1330
  run_trace_desync(sub_args);
1331
  return;
1332
  }
 
 
 
 
 
1333
  _ => {}
1334
  }
1335
 
1336
  let args = parse_args();
1337
 
1338
  // Discover tests
1339
- let tests = discover_tests(args.filter.as_deref());
1340
  if tests.is_empty() {
1341
- eprintln!("No replay tests found in {REPLAYS_DIR}/");
1342
  if let Some(f) = &args.filter {
1343
  eprintln!(" (filter: \"{f}\")");
1344
  }
 
55
  jobs: usize,
56
  no_build: bool,
57
  wa_path: Option<PathBuf>,
58
+ dir: PathBuf,
59
  }
60
 
61
  // ─── Argument parsing ───────────────────────────────────────────────────────
 
66
  jobs: DEFAULT_JOBS,
67
  no_build: false,
68
  wa_path: None,
69
+ dir: PathBuf::from(REPLAYS_DIR),
70
  };
71
 
72
  let argv: Vec<String> = env::args().skip(1).collect();
 
86
  args.wa_path = Some(PathBuf::from(&argv[i]));
87
  }
88
  }
89
+ "--dir" | "-d" => {
90
+ i += 1;
91
+ if i < argv.len() {
92
+ args.dir = PathBuf::from(&argv[i]);
93
+ }
94
+ }
95
  s if !s.starts_with('-') => {
96
  args.filter = Some(s.to_string());
97
  }
98
  other => {
99
  eprintln!("Unknown argument: {other}");
100
+ eprintln!(
101
+ "Usage: openwa-test [filter] [-j N] [-d DIR] [--no-build] [--wa-path PATH]"
102
+ );
103
  std::process::exit(1);
104
  }
105
  }
 
111
 
112
  // ─── Test discovery ─────────────────────────────────────────────────────────
113
 
114
+ fn discover_tests(replays_dir: &Path, filter: Option<&str>) -> Vec<TestCase> {
 
115
  let mut tests = Vec::new();
116
 
117
  let entries = match fs::read_dir(replays_dir) {
118
  Ok(e) => e,
119
  Err(e) => {
120
+ eprintln!("Cannot read {}: {e}", replays_dir.display());
121
  return tests;
122
  }
123
  };
124
 
125
  for entry in entries.flatten() {
126
  let path = entry.path();
127
+ let ext_match = path
128
+ .extension()
129
+ .and_then(|e| e.to_str())
130
+ .map(|e| e.eq_ignore_ascii_case("wagame"))
131
+ .unwrap_or(false);
132
+ if !ext_match {
133
  continue;
134
  }
135
 
 
1328
  }
1329
  }
1330
 
1331
+ // ─── Generate-baseline subcommand ───────────────────────────────────────────
1332
+
1333
+ struct GenerateBaselineArgs {
1334
+ dir: PathBuf,
1335
+ filter: Option<String>,
1336
+ jobs: usize,
1337
+ no_build: bool,
1338
+ wa_path: Option<PathBuf>,
1339
+ }
1340
+
1341
+ fn parse_generate_baseline_args(argv: &[String]) -> GenerateBaselineArgs {
1342
+ let mut args = GenerateBaselineArgs {
1343
+ dir: PathBuf::from(REPLAYS_DIR),
1344
+ filter: None,
1345
+ jobs: DEFAULT_JOBS,
1346
+ no_build: false,
1347
+ wa_path: None,
1348
+ };
1349
+ let mut i = 0;
1350
+ while i < argv.len() {
1351
+ match argv[i].as_str() {
1352
+ "-j" | "--jobs" => {
1353
+ i += 1;
1354
+ if i < argv.len() {
1355
+ args.jobs = argv[i].parse().unwrap_or(DEFAULT_JOBS);
1356
+ }
1357
+ }
1358
+ "--no-build" => args.no_build = true,
1359
+ "--wa-path" => {
1360
+ i += 1;
1361
+ if i < argv.len() {
1362
+ args.wa_path = Some(PathBuf::from(&argv[i]));
1363
+ }
1364
+ }
1365
+ "--dir" | "-d" => {
1366
+ i += 1;
1367
+ if i < argv.len() {
1368
+ args.dir = PathBuf::from(&argv[i]);
1369
+ }
1370
+ }
1371
+ s if !s.starts_with('-') => {
1372
+ args.filter = Some(s.to_string());
1373
+ }
1374
+ other => {
1375
+ eprintln!("Unknown argument: {other}");
1376
+ eprintln!(
1377
+ "Usage: openwa-test generate-baseline [filter] [-d DIR] [-j N] [--no-build] [--wa-path PATH]"
1378
+ );
1379
+ std::process::exit(1);
1380
+ }
1381
+ }
1382
+ i += 1;
1383
+ }
1384
+ args
1385
+ }
1386
+
1387
+ /// Discover .WAgame files that do NOT yet have a corresponding _expected.log.
1388
+ fn discover_baseline_targets(dir: &Path, filter: Option<&str>) -> Vec<TestCase> {
1389
+ let entries = match fs::read_dir(dir) {
1390
+ Ok(e) => e,
1391
+ Err(e) => {
1392
+ eprintln!("Cannot read {}: {e}", dir.display());
1393
+ return Vec::new();
1394
+ }
1395
+ };
1396
+
1397
+ let mut tests = Vec::new();
1398
+ for entry in entries.flatten() {
1399
+ let path = entry.path();
1400
+ let ext_match = path
1401
+ .extension()
1402
+ .and_then(|e| e.to_str())
1403
+ .map(|e| e.eq_ignore_ascii_case("wagame"))
1404
+ .unwrap_or(false);
1405
+ if !ext_match {
1406
+ continue;
1407
+ }
1408
+ let stem = match path.file_stem().and_then(|s| s.to_str()) {
1409
+ Some(s) => s.to_string(),
1410
+ None => continue,
1411
+ };
1412
+ if let Some(filter) = filter {
1413
+ if !stem.contains(filter) {
1414
+ continue;
1415
+ }
1416
+ }
1417
+
1418
+ let expected = dir.join(format!("{stem}_expected.log"));
1419
+ if expected.exists() {
1420
+ continue; // Already has a baseline β€” skip
1421
+ }
1422
+
1423
+ let replay_abs = strip_unc(fs::canonicalize(&path).unwrap_or(path.clone()));
1424
+ let output_abs = replay_abs.with_extension("log");
1425
+
1426
+ tests.push(TestCase {
1427
+ name: stem.clone(),
1428
+ replay_path: replay_abs,
1429
+ expected_log: strip_unc(fs::canonicalize(dir).unwrap_or(dir.to_path_buf()))
1430
+ .join(format!("{stem}_expected.log")),
1431
+ output_log: output_abs,
1432
+ });
1433
+ }
1434
+ tests.sort_by(|a, b| a.name.cmp(&b.name));
1435
+ tests
1436
+ }
1437
+
1438
+ /// Run a single baseline generation: launch with OPENWA_TRACE_BASELINE=1, then
1439
+ /// copy the output log to become the expected log.
1440
+ fn run_baseline_test(
1441
+ test: &TestCase,
1442
+ launcher: &Path,
1443
+ wa_exe: &Path,
1444
+ run_dir: &Path,
1445
+ ) -> TestResult {
1446
+ let start = Instant::now();
1447
+
1448
+ // Remove stale output log
1449
+ let _ = fs::remove_file(&test.output_log);
1450
+
1451
+ // Per-instance log paths
1452
+ let openwa_log = run_dir.join(format!("{}.openwa.log", test.name));
1453
+ let errorlog_path = run_dir.join(format!("{}.errorlog.txt", test.name));
1454
+
1455
+ let result = Command::new(launcher)
1456
+ .arg(wa_exe)
1457
+ .arg("/getlog")
1458
+ .arg(&test.replay_path)
1459
+ .env("OPENWA_HEADLESS", "1")
1460
+ .env("OPENWA_TRACE_BASELINE", "1")
1461
+ .env("OPENWA_LOG_PATH", &openwa_log)
1462
+ .env("OPENWA_ERRORLOG_PATH", &errorlog_path)
1463
+ .stdout(std::process::Stdio::null())
1464
+ .stderr(std::process::Stdio::null())
1465
+ .creation_flags(CREATE_NO_WINDOW)
1466
+ .status();
1467
+
1468
+ let duration = start.elapsed();
1469
+
1470
+ match result {
1471
+ Err(e) => TestResult {
1472
+ name: test.name.clone(),
1473
+ passed: false,
1474
+ duration,
1475
+ diff_lines: Vec::new(),
1476
+ error: Some(format!("Failed to launch: {e}")),
1477
+ crashed: None,
1478
+ },
1479
+ Ok(status) => {
1480
+ let exit_code = status.code().unwrap_or(0);
1481
+
1482
+ if duration >= Duration::from_secs(TIMEOUT_SECS) {
1483
+ return TestResult {
1484
+ name: test.name.clone(),
1485
+ passed: false,
1486
+ duration,
1487
+ diff_lines: Vec::new(),
1488
+ error: Some("Timeout".to_string()),
1489
+ crashed: None,
1490
+ };
1491
+ }
1492
+
1493
+ // Check for crash
1494
+ let errorlog_content = fs::read(&errorlog_path)
1495
+ .ok()
1496
+ .filter(|b| !b.is_empty())
1497
+ .map(|b| String::from_utf8_lossy(&b).into_owned());
1498
+
1499
+ if is_crash_exit_code(exit_code) || errorlog_content.is_some() {
1500
+ let unsigned = exit_code as u32;
1501
+ let name = if is_crash_exit_code(exit_code) {
1502
+ ntstatus_name(unsigned)
1503
+ } else {
1504
+ parse_errorlog_exception(errorlog_content.as_deref())
1505
+ };
1506
+ return TestResult {
1507
+ name: test.name.clone(),
1508
+ passed: false,
1509
+ duration,
1510
+ diff_lines: Vec::new(),
1511
+ error: None,
1512
+ crashed: Some(CrashInfo {
1513
+ exit_code: unsigned,
1514
+ name,
1515
+ errorlog_content,
1516
+ }),
1517
+ };
1518
+ }
1519
+
1520
+ // Check output log exists
1521
+ if !test.output_log.exists() {
1522
+ return TestResult {
1523
+ name: test.name.clone(),
1524
+ passed: false,
1525
+ duration,
1526
+ diff_lines: Vec::new(),
1527
+ error: Some(format!("No output log generated (exit code: {exit_code})")),
1528
+ crashed: None,
1529
+ };
1530
+ }
1531
+
1532
+ // Copy output log β†’ expected log
1533
+ match fs::copy(&test.output_log, &test.expected_log) {
1534
+ Ok(_) => {
1535
+ let _ = fs::remove_file(&test.output_log);
1536
+ TestResult {
1537
+ name: test.name.clone(),
1538
+ passed: true,
1539
+ duration,
1540
+ diff_lines: Vec::new(),
1541
+ error: None,
1542
+ crashed: None,
1543
+ }
1544
+ }
1545
+ Err(e) => TestResult {
1546
+ name: test.name.clone(),
1547
+ passed: false,
1548
+ duration,
1549
+ diff_lines: Vec::new(),
1550
+ error: Some(format!("Failed to copy output to expected log: {e}")),
1551
+ crashed: None,
1552
+ },
1553
+ }
1554
+ }
1555
+ }
1556
+ }
1557
+
1558
+ /// Thread pool for baseline generation (same pattern as run_tests_parallel but
1559
+ /// uses run_baseline_test).
1560
+ fn run_baseline_parallel(
1561
+ tests: Vec<TestCase>,
1562
+ jobs: usize,
1563
+ launcher: &Path,
1564
+ wa_exe: &Path,
1565
+ run_dir: &Path,
1566
+ ) -> Vec<TestResult> {
1567
+ let (tx, rx) = mpsc::channel::<(usize, TestResult)>();
1568
+ let (work_tx, work_rx) = mpsc::channel::<(usize, TestCase)>();
1569
+ let work_rx = std::sync::Arc::new(std::sync::Mutex::new(work_rx));
1570
+
1571
+ let launcher = launcher.to_path_buf();
1572
+ let wa_exe = wa_exe.to_path_buf();
1573
+ let run_dir = run_dir.to_path_buf();
1574
+
1575
+ let mut handles = Vec::new();
1576
+ for _ in 0..jobs.min(tests.len()) {
1577
+ let rx = work_rx.clone();
1578
+ let tx = tx.clone();
1579
+ let launcher = launcher.clone();
1580
+ let wa_exe = wa_exe.clone();
1581
+ let run_dir = run_dir.clone();
1582
+
1583
+ handles.push(thread::spawn(move || loop {
1584
+ let item = {
1585
+ let guard = rx.lock().unwrap();
1586
+ guard.recv()
1587
+ };
1588
+ match item {
1589
+ Ok((idx, test)) => {
1590
+ let result = run_baseline_test(&test, &launcher, &wa_exe, &run_dir);
1591
+ let _ = tx.send((idx, result));
1592
+ }
1593
+ Err(_) => break,
1594
+ }
1595
+ }));
1596
+ }
1597
+ drop(tx);
1598
+
1599
+ for (idx, test) in tests.into_iter().enumerate() {
1600
+ if idx > 0 {
1601
+ thread::sleep(Duration::from_millis(50));
1602
+ }
1603
+ let _ = work_tx.send((idx, test));
1604
+ }
1605
+ drop(work_tx);
1606
+
1607
+ let mut results: Vec<Option<TestResult>> = vec![None; handles.len() + 700];
1608
+ let mut count = 0;
1609
+ for (idx, result) in rx {
1610
+ if idx >= results.len() {
1611
+ results.resize(idx + 1, None);
1612
+ }
1613
+ print_result(&result);
1614
+ results[idx] = Some(result);
1615
+ count += 1;
1616
+ }
1617
+
1618
+ for h in handles {
1619
+ let _ = h.join();
1620
+ }
1621
+
1622
+ results.into_iter().take(count).flatten().collect()
1623
+ }
1624
+
1625
+ fn run_generate_baseline(args: GenerateBaselineArgs) {
1626
+ let tests = discover_baseline_targets(&args.dir, args.filter.as_deref());
1627
+ if tests.is_empty() {
1628
+ eprintln!(
1629
+ "No replays without baselines found in {}",
1630
+ args.dir.display()
1631
+ );
1632
+ if let Some(f) = &args.filter {
1633
+ eprintln!(" (filter: \"{f}\")");
1634
+ }
1635
+ std::process::exit(1);
1636
+ }
1637
+
1638
+ // Build
1639
+ if !args.no_build {
1640
+ eprint!("Building... ");
1641
+ match build() {
1642
+ Ok(d) => eprintln!("done ({:.1}s)", d.as_secs_f64()),
1643
+ Err(e) => {
1644
+ eprintln!("FAILED: {e}");
1645
+ std::process::exit(1);
1646
+ }
1647
+ }
1648
+ }
1649
+
1650
+ let wa_exe = find_wa_exe(args.wa_path.as_deref()).unwrap_or_else(|| {
1651
+ eprintln!("Cannot find WA.exe. Use --wa-path or set OPENWA_WA_PATH.");
1652
+ std::process::exit(1);
1653
+ });
1654
+ let launcher = find_launcher().unwrap_or_else(|| {
1655
+ eprintln!("Cannot find openwa-launcher.exe. Run cargo build first.");
1656
+ std::process::exit(1);
1657
+ });
1658
+
1659
+ let timestamp = timestamp();
1660
+ let run_dir = PathBuf::from(RUNS_DIR).join(format!("baseline-{timestamp}"));
1661
+ let _ = fs::create_dir_all(&run_dir);
1662
+ let run_dir = strip_unc(fs::canonicalize(&run_dir).unwrap_or(run_dir));
1663
+
1664
+ let jobs = args.jobs.max(1);
1665
+ println!(
1666
+ "Generating baselines for {} replay{} ({} concurrent, OPENWA_TRACE_BASELINE=1)...\n",
1667
+ tests.len(),
1668
+ if tests.len() == 1 { "" } else { "s" },
1669
+ jobs
1670
+ );
1671
+
1672
+ let wall_start = Instant::now();
1673
+ let results = run_baseline_parallel(tests, jobs, &launcher, &wa_exe, &run_dir);
1674
+ let wall_time = wall_start.elapsed();
1675
+
1676
+ let generated = results.iter().filter(|r| r.passed).count();
1677
+ let failed = results.len() - generated;
1678
+
1679
+ print_summary(&results, wall_time);
1680
+ write_summary(&results, wall_time, &run_dir.join("summary.txt"));
1681
+
1682
+ println!(
1683
+ "\nBaseline generation: {} generated, {} failed",
1684
+ generated, failed
1685
+ );
1686
+
1687
+ cleanup_temp_files(&wa_exe);
1688
+
1689
+ if failed > 0 {
1690
+ std::process::exit(1);
1691
+ }
1692
+ }
1693
+
1694
  // ─── Main ───────────────────────────────────────────────────────────────────
1695
 
1696
  fn main() {
 
1707
  run_trace_desync(sub_args);
1708
  return;
1709
  }
1710
+ Some("generate-baseline") => {
1711
+ let sub_args = parse_generate_baseline_args(&argv[1..]);
1712
+ run_generate_baseline(sub_args);
1713
+ return;
1714
+ }
1715
  _ => {}
1716
  }
1717
 
1718
  let args = parse_args();
1719
 
1720
  // Discover tests
1721
+ let tests = discover_tests(&args.dir, args.filter.as_deref());
1722
  if tests.is_empty() {
1723
+ eprintln!("No replay tests found in {}/", args.dir.display());
1724
  if let Some(f) = &args.filter {
1725
  eprintln!(" (filter: \"{f}\")");
1726
  }