From f1a7eb2c62fb315f19bf1cfe1562f86ab93987b6 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 06:06:38 +0000 Subject: [PATCH 1/2] chore: rewrite test progress watcher in JavaScript Per review on #277: the bash version embedded a Python heredoc, adding a third scripting language when this repo already has first-class JS support. Rewritten as node, matching the existing scripts/*.js conventions (shebang, JSDoc header, CommonJS) and wired up as `yarn watch:tests`. Parses the JUnit XML attributes with a regex rather than adding a parser dependency -- fast-xml-parser resolves here but only transitively, so depending on it could break silently if the dep tree shifts. Only a few root-element attributes are needed. Verified against the bash version on the same results directory: identical output. Also checked the missing-directory path (exits 1), repeated polling, and failure aggregation (failures + errors). One bug caught while comparing: the first JS attempt read the leading `` declaration instead of the ` Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- docs/build/wsl_unison_environment.md | 3 +- package.json | 1 + scripts/watch_test_progress.js | 125 +++++++++++++++++++++++++++ scripts/watch_test_progress.sh | 47 ---------- 4 files changed, 128 insertions(+), 48 deletions(-) create mode 100644 scripts/watch_test_progress.js delete mode 100644 scripts/watch_test_progress.sh diff --git a/docs/build/wsl_unison_environment.md b/docs/build/wsl_unison_environment.md index d89c73181..07b27b0fe 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 bbda0e0f7..0a45c02d5 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": { diff --git a/scripts/watch_test_progress.js b/scripts/watch_test_progress.js new file mode 100644 index 000000000..b86f5b2bc --- /dev/null +++ b/scripts/watch_test_progress.js @@ -0,0 +1,125 @@ +#!/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 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; +} + +/** + * Pull the counts off a JUnit `` element. + * + * Deliberately regex rather than an XML library: the only thing needed is a + * handful of attributes on the root element, and this repo has no direct XML + * parser dependency worth adding for it. Files being written concurrently are + * expected and simply yield null until complete. + * + * @returns {{name: string, tests: number, failures: number, time: string}|null} + */ +function readSuite(file) { + let xml; + try { + xml = fs.readFileSync(file, 'utf8'); + } catch { + return null; // vanished or unreadable mid-write + } + + // Skip the leading `` declaration; the element we want is next. + const start = xml.indexOf('', start) + 1); + + const attr = (key) => { + const match = header.match(new RegExp(`${key}="([^"]*)"`)); + return match ? match[1] : ''; + }; + + return { + name: (attr('name').split('.').pop()) || '?', + tests: Number(attr('tests')) || 0, + failures: (Number(attr('failures')) || 0) + (Number(attr('errors')) || 0), + time: attr('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 79b74688c..000000000 --- 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 From 719f44034a4b135e1ef824ad98a97b82679d2041 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 06:11:17 +0000 Subject: [PATCH 2/2] chore: add fast-xml-parser as a dev dep and use it in the watcher Per review: rather than working around the missing dependency with a regex, declare it. fast-xml-parser was already resolving here, but only transitively via @react-native-community/cli -- depending on that would have worked today and could break silently if the dep tree shifted. Now an explicit devDependency (it is only used by a dev script, never shipped), and readSuite parses properly instead of string-matching attributes off the root element. Verified output is unchanged against the same results directory, that failures and errors still aggregate, and that a half-written file is skipped rather than crashing the poll -- Gradle writes these while the build runs, so partial reads are normal rather than exceptional. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- package.json | 1 + scripts/watch_test_progress.js | 35 +++++++++++++++------------------- yarn.lock | 14 +++++++++++++- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 0a45c02d5..30087c92c 100644 --- a/package.json +++ b/package.json @@ -95,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 index b86f5b2bc..a08deff25 100644 --- a/scripts/watch_test_progress.js +++ b/scripts/watch_test_progress.js @@ -24,6 +24,7 @@ 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'; @@ -46,39 +47,33 @@ function parseArgs(argv) { return options; } +const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' }); + /** * Pull the counts off a JUnit `` element. * - * Deliberately regex rather than an XML library: the only thing needed is a - * handful of attributes on the root element, and this repo has no direct XML - * parser dependency worth adding for it. Files being written concurrently are - * expected and simply yield null until complete. + * 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 xml; + let suite; try { - xml = fs.readFileSync(file, 'utf8'); + suite = parser.parse(fs.readFileSync(file, 'utf8')).testsuite; } catch { - return null; // vanished or unreadable mid-write + return null; // unreadable or half-written } + if (!suite) return null; - // Skip the leading `` declaration; the element we want is next. - const start = xml.indexOf('', start) + 1); - - const attr = (key) => { - const match = header.match(new RegExp(`${key}="([^"]*)"`)); - return match ? match[1] : ''; - }; + const num = (key) => Number(suite[key]) || 0; return { - name: (attr('name').split('.').pop()) || '?', - tests: Number(attr('tests')) || 0, - failures: (Number(attr('failures')) || 0) + (Number(attr('errors')) || 0), - time: attr('time'), + name: String(suite['@_name'] || '').split('.').pop() || '?', + tests: num('@_tests'), + failures: num('@_failures') + num('@_errors'), + time: suite['@_time'], }; } diff --git a/yarn.lock b/yarn.lock index 2f5470472..e91a33260 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