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