diff --git a/docs/build/wsl_unison_environment.md b/docs/build/wsl_unison_environment.md
index d89c7318..07b27b0f 100644
--- a/docs/build/wsl_unison_environment.md
+++ b/docs/build/wsl_unison_environment.md
@@ -131,7 +131,8 @@ ways to get visibility, in order of how much they cost:
**1. Watch the result XMLs (no config change, works on a build already running)**
```bash
-./scripts/watch_test_progress.sh
+yarn watch:tests
+# or: node scripts/watch_test_progress.js --results
--interval 15
```
Each test class writes its JUnit XML as it finishes, so this reports
diff --git a/package.json b/package.json
index bbda0e0f..30087c92 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"android:sign:debug-apks": "node scripts/sign_android.js",
"android:build:debug:signed": "yarn android:build:debug && yarn android:sign:debug-apks",
"clean-logs": "npx ts-node scripts/clean_clipboard_logs.ts",
+ "watch:tests": "node scripts/watch_test_progress.js",
"test": "jest"
},
"dependencies": {
@@ -94,6 +95,7 @@
"eslint-plugin-node": "latest",
"eslint-plugin-prettier": "latest",
"eslint-plugin-react-hooks": "^5.2.0",
+ "fast-xml-parser": "^4.5.3",
"gts": "^5.0.0",
"husky": "^9.1.6",
"jest": "^29.6.2",
diff --git a/scripts/watch_test_progress.js b/scripts/watch_test_progress.js
new file mode 100644
index 00000000..a08deff2
--- /dev/null
+++ b/scripts/watch_test_progress.js
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+
+/**
+ * Live progress for a running Gradle unit-test build.
+ *
+ * @description Gradle's Test task prints nothing per-test by default, so a long
+ * run looks identical to a hung one: `--console=plain` shows
+ * `> Task :app:testX8664DebugUnitTest` and then silence for many
+ * minutes. This reads the JUnit XML files as they land instead,
+ * which works on a build that is ALREADY RUNNING -- no config
+ * change and no restart needed.
+ *
+ * The build.gradle `testLogging` block covers new runs. This is for
+ * the case where a build is already in flight, or where you want a
+ * compact rolling summary rather than a full Gradle log.
+ *
+ * @usage node scripts/watch_test_progress.js [--results ] [--interval ] [--once]
+ *
+ * @param {string} --results - Directory holding JUnit XML results.
+ * Defaults to the Windows-side x86_64 debug output.
+ * @param {number} --interval - Seconds between polls (default 15).
+ * @param {boolean} --once - Print a single snapshot and exit.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { XMLParser } = require('fast-xml-parser');
+
+const DEFAULT_RESULTS =
+ '/mnt/c/dev/CN/android/app/build/test-results/testX8664DebugUnitTest';
+const DEFAULT_INTERVAL_SECONDS = 15;
+
+/** Parse `--flag value` and bare `--flag` pairs out of argv. */
+function parseArgs(argv) {
+ const options = { results: DEFAULT_RESULTS, interval: DEFAULT_INTERVAL_SECONDS, once: false };
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === '--once') {
+ options.once = true;
+ } else if (arg === '--results') {
+ options.results = argv[++i];
+ } else if (arg === '--interval') {
+ options.interval = Number(argv[++i]) || DEFAULT_INTERVAL_SECONDS;
+ }
+ }
+ return options;
+}
+
+const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
+
+/**
+ * Pull the counts off a JUnit `` element.
+ *
+ * Gradle writes these files while the build runs, so a partially-written file
+ * is normal rather than exceptional -- both the read and the parse are allowed
+ * to fail, yielding null until the file is complete.
+ *
+ * @returns {{name: string, tests: number, failures: number, time: string}|null}
+ */
+function readSuite(file) {
+ let suite;
+ try {
+ suite = parser.parse(fs.readFileSync(file, 'utf8')).testsuite;
+ } catch {
+ return null; // unreadable or half-written
+ }
+ if (!suite) return null;
+
+ const num = (key) => Number(suite[key]) || 0;
+
+ return {
+ name: String(suite['@_name'] || '').split('.').pop() || '?',
+ tests: num('@_tests'),
+ failures: num('@_failures') + num('@_errors'),
+ time: suite['@_time'],
+ };
+}
+
+/** Read every result file in the directory, oldest-written first. */
+function collectSuites(resultsDir) {
+ return fs
+ .readdirSync(resultsDir)
+ .filter((f) => f.endsWith('.xml'))
+ .map((f) => path.join(resultsDir, f))
+ .sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs)
+ .map(readSuite)
+ .filter(Boolean);
+}
+
+function printSnapshot(resultsDir) {
+ const suites = collectSuites(resultsDir);
+
+ const tests = suites.reduce((sum, s) => sum + s.tests, 0);
+ const failures = suites.reduce((sum, s) => sum + s.failures, 0);
+
+ const stamp = new Date().toTimeString().slice(0, 8);
+ const failed = failures ? ` FAILURES=${failures}` : '';
+ console.log(`[${stamp}] classes=${suites.length} tests=${tests}${failed}`);
+
+ for (const suite of suites.slice(-2)) {
+ console.log(` ${suite.name}: ${suite.tests} tests, ${suite.time}s`);
+ }
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+
+ if (!fs.existsSync(options.results)) {
+ console.error(`No results directory yet: ${options.results}`);
+ process.exit(1);
+ }
+
+ printSnapshot(options.results);
+ if (options.once) return;
+
+ setInterval(() => printSnapshot(options.results), options.interval * 1000);
+}
+
+main();
diff --git a/scripts/watch_test_progress.sh b/scripts/watch_test_progress.sh
deleted file mode 100644
index 79b74688..00000000
--- a/scripts/watch_test_progress.sh
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/bin/bash
-#
-# Live progress for a running Gradle unit-test build.
-#
-# Gradle's Test task prints nothing per-test by default, so a long run looks
-# identical to a hung one -- `--console=plain` shows "> Task :app:testX..."
-# and then silence for minutes. This reads the JUnit XML files as they land
-# instead, which works on a build that is ALREADY RUNNING (no config change,
-# no restart).
-#
-# Usage: ./scripts/watch_test_progress.sh [results_dir] [interval_seconds]
-
-set -uo pipefail
-
-RESULTS="${1:-/mnt/c/dev/CN/android/app/build/test-results/testX8664DebugUnitTest}"
-INTERVAL="${2:-15}"
-
-[ -d "$RESULTS" ] || { echo "No results dir yet: $RESULTS" >&2; exit 1; }
-
-while true; do
- python3 - "$RESULTS" <<'PY'
-import glob, os, sys, xml.etree.ElementTree as ET
-from datetime import datetime
-
-results = sys.argv[1]
-files = sorted(glob.glob(os.path.join(results, "*.xml")), key=os.path.getmtime)
-
-classes = tests = failures = 0
-latest = []
-for path in files:
- try:
- root = ET.parse(path).getroot()
- except ET.ParseError:
- continue # still being written
- classes += 1
- tests += int(root.get("tests") or 0)
- failures += int(root.get("failures") or 0) + int(root.get("errors") or 0)
- latest.append((root.get("name", "?").split(".")[-1], root.get("tests"), root.get("time")))
-
-stamp = datetime.now().strftime("%H:%M:%S")
-flag = f" FAILURES={failures}" if failures else ""
-print(f"[{stamp}] classes={classes} tests={tests}{flag}")
-for name, n, secs in latest[-2:]:
- print(f" {name}: {n} tests, {secs}s")
-PY
- sleep "$INTERVAL"
-done
diff --git a/yarn.lock b/yarn.lock
index 2f547047..e91a3326 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6702,6 +6702,7 @@ __metadata:
expo: ~54.0.0
expo-application: ~7.0.0
expo-status-bar: ~3.0.9
+ fast-xml-parser: ^4.5.3
gts: ^5.0.0
husky: ^9.1.6
jest: ^29.6.2
@@ -10314,6 +10315,17 @@ __metadata:
languageName: node
linkType: hard
+"fast-xml-parser@npm:^4.5.3":
+ version: 4.5.7
+ resolution: "fast-xml-parser@npm:4.5.7"
+ dependencies:
+ strnum: ^1.0.5
+ bin:
+ fxparser: src/cli/cli.js
+ checksum: 5d79fa19eb0f6e6f5e0cb660e91ccf695146b0333124fc4bd7d95a6ce0a07cee602532c1cdfc606fafbda6100d822df9247381a42f4b7156b5df17d1395e1fb7
+ languageName: node
+ linkType: hard
+
"fastest-levenshtein@npm:^1.0.16":
version: 1.0.16
resolution: "fastest-levenshtein@npm:1.0.16"
@@ -18290,7 +18302,7 @@ __metadata:
languageName: node
linkType: hard
-"strnum@npm:^1.1.1":
+"strnum@npm:^1.0.5, strnum@npm:^1.1.1":
version: 1.1.2
resolution: "strnum@npm:1.1.2"
checksum: a85219eda13e97151c95e343a9e5960eacfb0a0ff98104b4c9cb7a212e3008bddf0c9714c9c37c2e508be78e741a04afc80027c2dc18509d1b5ffd4c37191fc2