feat: add generate-baseline subcommand and -d flag to test runner
Browse filesAdds `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 `
|
| 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!(
|
|
|
|
|
|
|
| 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 {
|
| 112 |
return tests;
|
| 113 |
}
|
| 114 |
};
|
| 115 |
|
| 116 |
for entry in entries.flatten() {
|
| 117 |
let path = entry.path();
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 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 |
}
|