You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/10/20 20:04:18 UTC

(juneau) branch master updated: Show one summary line per running unit under each juneau-run progress bar (Maven modules, pytest files, Prechecks checks and Playwright files), add a juneau-run Playwright reporter injected via PW_TEST_REPORTER that drives the bar from its own markers and writes the JSON report, and stop the html reporter opening on failure outside a terminal

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 0ab538a052 Show one summary line per running unit under each juneau-run progress bar (Maven modules, pytest files, Prechecks checks and Playwright files), add a juneau-run Playwright reporter injected via PW_TEST_REPORTER that drives the bar from its own markers and writes the JSON report, and stop the html reporter opening on failure outside a terminal
0ab538a052 is described below

commit 0ab538a05220ce93c2271c6ba98b747cb9349770
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Oct 20 16:04:11 2026 -0400

    Show one summary line per running unit under each juneau-run progress bar (Maven modules, pytest files, Prechecks checks and Playwright files), add a juneau-run Playwright reporter injected via PW_TEST_REPORTER that drives the bar from its own markers and writes the JSON report, and stop the html reporter opening on failure outside a terminal
---
 .../runreport/PlaywrightJsonReportReader_Test.java |  16 +
 .../server/runreport/playwright-juneau-run.json    |   1 +
 juneau-run/README.md                               |   1 +
 juneau-run/docs/run-protocol-v1.md                 |  27 +-
 juneau-run/src/assembly/juneau-scripts.xml         |   7 +-
 .../playwright_reporter/juneau_run_playwright.cjs  | 187 +++++++
 juneau-run/src/main/python/juneau_run.py           | 561 ++++++++++++++++-----
 juneau-run/src/main/python/juneau_scripts/run.py   |  29 +-
 juneau-run/src/main/python/juneau_scripts/tools.py |   4 +-
 .../main/python/pytest_plugin/juneau_run_pytest.py |   2 +-
 .../src/test/js/juneau_run_playwright.test.cjs     | 294 +++++++++++
 juneau-run/src/test/python/scripts_support.py      |   7 +-
 juneau-run/src/test/python/test_bus.py             |  30 +-
 juneau-run/src/test/python/test_history.py         |  26 +-
 juneau-run/src/test/python/test_injection.py       |  24 +
 juneau-run/src/test/python/test_markers.py         |  96 ++++
 juneau-run/src/test/python/test_maven_setup_row.py | 118 ++---
 juneau-run/src/test/python/test_module_line.py     |  15 +-
 juneau-run/src/test/python/test_parsers.py         | 283 ++++++++++-
 .../src/test/python/test_playwright_reporter.py    |  28 +
 juneau-run/src/test/python/test_progress_parser.py |  66 ++-
 juneau-run/src/test/python/test_progress_plugin.py |   9 +-
 juneau-run/src/test/python/test_render.py          | 303 +++++++----
 juneau-run/src/test/python/test_scripts_checks.py  |  32 +-
 .../src/test/python/test_scripts_packaging.py      |   7 +-
 juneau-run/src/test/python/test_scripts_run.py     |   8 +
 juneau-run/src/test/python/test_unit_model.py      | 321 ++++++++++++
 scripts/tests/test_push.py                         |  37 ++
 scripts/tests/test_release_run.py                  |  17 +
 29 files changed, 2215 insertions(+), 341 deletions(-)

diff --git a/juneau-rest/juneau-run-report/src/test/java/org/apache/juneau/rest/server/runreport/PlaywrightJsonReportReader_Test.java b/juneau-rest/juneau-run-report/src/test/java/org/apache/juneau/rest/server/runreport/PlaywrightJsonReportReader_Test.java
index f5fc6c814b..bfe9d84359 100644
--- a/juneau-rest/juneau-run-report/src/test/java/org/apache/juneau/rest/server/runreport/PlaywrightJsonReportReader_Test.java
+++ b/juneau-rest/juneau-run-report/src/test/java/org/apache/juneau/rest/server/runreport/PlaywrightJsonReportReader_Test.java
@@ -79,4 +79,20 @@ class PlaywrightJsonReportReader_Test extends TestBase {
 		assertEquals(2, r.tests().size());
 		assertTrue(r.truncated());
 	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// The report juneau-run's own Playwright reporter writes: juneau_run_playwright.test.cjs checks that its report of a
+	// fake suite equals playwright-juneau-run.json.
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_juneauRunReporter() {
+		var r = new PlaywrightJsonReportReader().read(resource("playwright-juneau-run.json"));
+		assertEquals(List.of("fail", "skip", "pass", "pass"), statuses(r));
+		assertEquals(List.of("top [firefox]", "top [chromium]", "Login › works [chromium]", "Login › flaky one [chromium]"),
+			r.tests().stream().map(ReportTest::name).toList());
+		assertBean(r.tests().get(0), "suite,msg,ms", "tests/a.spec.ts,boom,14");
+		assertEquals("boom\nat a.spec.ts:11", r.tests().get(0).trace());
+		assertNull(r.tests().get(3).msg());
+		assertTrue(r.warnings().isEmpty());
+	}
 }
diff --git a/juneau-rest/juneau-run-report/src/test/resources/org/apache/juneau/rest/server/runreport/playwright-juneau-run.json b/juneau-rest/juneau-run-report/src/test/resources/org/apache/juneau/rest/server/runreport/playwright-juneau-run.json
new file mode 100644
index 0000000000..c08c457a79
--- /dev/null
+++ b/juneau-rest/juneau-run-report/src/test/resources/org/apache/juneau/rest/server/runreport/playwright-juneau-run.json
@@ -0,0 +1 @@
+{"config":{"rootDir":"/repo","version":"1.62.1"},"suites":[{"title":"tests/a.spec.ts","file":"tests/a.spec.ts","specs":[{"title":"top","ok":false,"file":"tests/a.spec.ts","line":11,"column":5,"tests":[{"projectName":"firefox","expectedStatus":"passed","status":"unexpected","retries":1,"results":[{"retry":0,"status":"failed","duration":5,"error":{"message":"\u001b[31mboom\u001b[39m\nat a.spec.ts:11"}},{"retry":1,"status":"timedOut","duration":9,"error":{"message":"Test timeout of 9ms exce [...]
\ No newline at end of file
diff --git a/juneau-run/README.md b/juneau-run/README.md
index 3a654e5c5e..4cb6d83cfe 100644
--- a/juneau-run/README.md
+++ b/juneau-run/README.md
@@ -95,6 +95,7 @@ screen. `full` (the default) prints raw output as before; `none` draws nothing.
 | `JUNEAU_RUN_LABEL_WIDTH` | Minimum width of the row labels (default 10). A parent sets it for a nested script so the rows line up. |
 | `JUNEAU_RUN_HISTORY=0` | Turns off recording and estimating run history (default on). Without history the view shows no percent, time estimates or Overall line. |
 | `JUNEAU_RUN_HISTORY_DIR` | Replaces `~/.cache/juneau-run/history`, where per-repository run history is kept. The tests point it at a temp dir. |
+| `JUNEAU_RUN_PLAYWRIGHT_REPORTER=0` | In the command's environment or juneau-run's, leaves out juneau-run's Playwright reporter (`juneau_run_playwright.cjs`, which draws one line per running test file, counts the bar and, under markers with `RUN_ARTIFACTS`, writes `<step>-playwright.json`). It is added through `PW_TEST_REPORTER`, which Playwright appends to the `reporter:` list in `playwright.config`, so the config's reporters still run and the output is a plain run's. Outside a termina [...]
 | `JUNEAU_RUN_PYTEST_PLUGIN=0` | Skips injecting `-p juneau_run_pytest` into pytest runs (the plugin that reports test progress). The suite under test gets only the plugin-only `pytest_plugin/` directory (in the zip, `<zip>/pytest_plugin`) on `PYTHONPATH`, never the engine or toolkit. |
 
 With `RUN_MARKERS=1` stdout carries the run-protocol markers, so no view is drawn there; the condensed log still gets
diff --git a/juneau-run/docs/run-protocol-v1.md b/juneau-run/docs/run-protocol-v1.md
index 7f9fe6f4af..490197e91e 100644
--- a/juneau-run/docs/run-protocol-v1.md
+++ b/juneau-run/docs/run-protocol-v1.md
@@ -58,6 +58,7 @@ An adopting `push.py` accepts `--test-only`:
 - **`step.parent`** (optional) is the id of the enclosing step.
   - Sub-step ids are `<parent>/<child>`.
   - Sub-steps carry their own `n`, the reactor index, for ordering within the parent.
+  - A script's own units are sub-steps too: a Prechecks row emits one `prechecks/<check>` child step per check.
   - A consumer that ignores `parent` shows sub-steps as flat steps, which is still correct.
 - **`tests`:** `{ev:"tests", step, total, fail, err, skip}`.
   - The numbers are absolute cumulative totals for that step, not deltas. The latest event for a step supersedes earlier ones.
@@ -121,6 +122,26 @@ tool's terminal gets no input at all.
 
 See `../README.md`: the bootstrap snippet that loads `juneau_run.py` from the pinned `org.apache.juneau:juneau-run` artifact, the output routing
 options (`JUNEAU_RUN_CONSOLE`, `JUNEAU_RUN_FULL_LOG`, `JUNEAU_RUN_CONDENSED_LOG`) and the parsers (`maven`, `pytest`, `playwright`, `gradle`, `npm`, `bazel`, `generic`; `gradle` adds `--console=plain` unless the command sets `--console`).
-Reporter flags a parser adds, only when markers are on and `RUN_ARTIFACTS` is set:
-- pytest: `--junitxml=$RUN_ARTIFACTS/<step>-pytest.xml`
-- Playwright: `--reporter=line,json` with `PLAYWRIGHT_JSON_OUTPUT_NAME=$RUN_ARTIFACTS/<step>-playwright.json`
+Reporters a parser adds when markers are on:
+- pytest: `--junitxml=$RUN_ARTIFACTS/<step>-pytest.xml` (a temp directory when `RUN_ARTIFACTS` isn't set)
+- Playwright: no flag. juneau-run's own reporter (`juneau_run_playwright.cjs`) is named in `PW_TEST_REPORTER`, and
+  `JUNEAU_RUN_PLAYWRIGHT_JSON=$RUN_ARTIFACTS/<step>-playwright.json` when `RUN_ARTIFACTS` is set
+
+Playwright appends the `PW_TEST_REPORTER` reporter to the `reporter:` list in `playwright.config` (or to its default
+reporter), so the config's own reporters (html, junit, ...) still run and Playwright prints what a plain run prints,
+plus, outside a terminal, the reporter's `##playwright` lines. It sends one per test event, which draws one console line
+per running test file and counts the step's bar (once per test, however often it is retried), and writes the JSON report
+at the end in a subset of Playwright's json reporter format (suites, specs, tests, results). The bar's total is the
+number of tests at the start, so a run stopped by `--max-failures` ends short of it; a test whose retry never ran is
+counted at the end with its outcome so far. Outside a terminal `PLAYWRIGHT_HTML_OPEN=never` is set too (unless the
+command sets it or `PW_TEST_HTML_REPORT_OPEN`): the html reporter's default opens the report after a failure, serving it
+and waiting, whenever stdin is a terminal. `PW_TEST_REPORTER` is undocumented in Playwright: a version that ignores it
+runs as a plain run would, without the per-file lines or the JSON report, and once tests have run (not after `--list`,
+`--help` or a configuration error) the step notes "Playwright reporter not loaded"; the bar then counts the line
+reporter's `[n/N]` progress, when the config uses it. Under a terminal there is no such note.
+Without markers, the reporter is added only inside a script's console session that isn't interactive; a plain run keeps
+the command and environment as given. Under a terminal it is added only for the JSON report, with
+`JUNEAU_RUN_PLAYWRIGHT_MARKERS=0`, so it prints nothing; outside one an inherited `JUNEAU_RUN_PLAYWRIGHT_MARKERS=0` is
+dropped. `JUNEAU_RUN_PLAYWRIGHT_REPORTER=0`, in the command's environment or juneau-run's, leaves it out, and so does a
+command that names its own reporter (`--reporter x`, `--reporter=x`, or a `PW_TEST_REPORTER` already set). A report left
+at the JSON path by an earlier run is removed first, so a run cancelled before its end reports none.
diff --git a/juneau-run/src/assembly/juneau-scripts.xml b/juneau-run/src/assembly/juneau-scripts.xml
index f5bc4821ad..600cb74bf4 100644
--- a/juneau-run/src/assembly/juneau-scripts.xml
+++ b/juneau-run/src/assembly/juneau-scripts.xml
@@ -18,7 +18,8 @@
 <!--
 	The juneau_scripts toolkit as org.apache.juneau:juneau-run:<version>:zip.  Layout: juneau_scripts/** plus
 	bootstrap.py at the root (a zip on sys.path imports juneau_scripts) and the pytest plugin alone in pytest_plugin/ (the suite under test gets only
-	zip/pytest_plugin on PYTHONPATH), plus the repository's LICENSE and NOTICE.
+	zip/pytest_plugin on PYTHONPATH), the Playwright reporter in playwright_reporter/ (juneau_run.py extracts it, since node can't
+	load from a zip), plus the repository's LICENSE and NOTICE.
 -->
 <assembly
 	xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
@@ -49,6 +50,10 @@
 			<source>${project.basedir}/src/main/python/pytest_plugin/juneau_run_pytest.py</source>
 			<outputDirectory>pytest_plugin</outputDirectory>
 		</file>
+		<file>
+			<source>${project.basedir}/src/main/js/playwright_reporter/juneau_run_playwright.cjs</source>
+			<outputDirectory>playwright_reporter</outputDirectory>
+		</file>
 		<file>
 			<source>${project.basedir}/../LICENSE</source>
 		</file>
diff --git a/juneau-run/src/main/js/playwright_reporter/juneau_run_playwright.cjs b/juneau-run/src/main/js/playwright_reporter/juneau_run_playwright.cjs
new file mode 100644
index 0000000000..f3e9d81975
--- /dev/null
+++ b/juneau-run/src/main/js/playwright_reporter/juneau_run_playwright.cjs
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+"use strict";
+
+// juneau-run's Playwright reporter: the Playwright counterpart of the pytest plugin.  juneau_run.py names it in
+// PW_TEST_REPORTER, which Playwright appends to the config's reporters, and reads one "##playwright {json}" line per
+// event from stdout, drawing a line per running test file:
+//   {"ev":"hello","v":1}                      once, first
+//   {"ev":"begin","f":{<file>: <tests>}}      the tests in each file (once per project), so a file closes exactly
+//   {"ev":"start","f":<file>}                 a test starts (every attempt)
+//   {"ev":"test","f":<file>,"o":<outcome>}    a test's final attempt ended: pass, fail or skip, once per test (at the
+//                                             end for a test whose retry never ran)
+// Files are relative to the config's rootDir, with "/" separators.  JUNEAU_RUN_PLAYWRIGHT_MARKERS=0 sends no lines.
+// With JUNEAU_RUN_PLAYWRIGHT_JSON set, onEnd writes a report there in the shape of Playwright's json reporter (suites,
+// specs, tests, results).  No dependencies.
+
+const fs = require("fs");
+const path = require("path");
+
+const PREFIX = "##playwright ";
+
+class JuneauRunReporter {
+  constructor(options = {}) {
+    const env = options.env || process.env;
+    this.write = options.write || ((text) => process.stdout.write(text));
+    this.stderr = options.stderr || ((text) => process.stderr.write(text));
+    this.markers = env.JUNEAU_RUN_PLAYWRIGHT_MARKERS !== "0";
+    this.jsonPath = env.JUNEAU_RUN_PLAYWRIGHT_JSON || "";
+    this.rootDir = "";
+    this.version = undefined;
+    this.suite = undefined;
+    this.errors = [];
+    this.ended = new Set(); // ids of tests that sent their test line
+  }
+
+  printsToStdio() {
+    return false; // Playwright's own output stays as in a plain run: it still adds line or dot to an html-only config
+  }
+
+  emit(event) {
+    if (this.markers) {
+      this.write(PREFIX + JSON.stringify(event) + "\n");
+    }
+  }
+
+  file(test) {
+    const file = (test.location && test.location.file) || "";
+    return file && path.relative(this.rootDir, file).split(path.sep).join("/"); // "" draws no line
+  }
+
+  onBegin(config, suite) {
+    this.rootDir = config.rootDir || "";
+    this.version = config.version;
+    this.suite = suite;
+    const files = {};
+    for (const test of suite.allTests()) {
+      const file = this.file(test);
+      files[file] = (files[file] || 0) + 1;
+    }
+    this.emit({ ev: "hello", v: 1 });
+    this.emit({ ev: "begin", f: files });
+  }
+
+  onTestBegin(test) {
+    this.emit({ ev: "start", f: this.file(test) });
+  }
+
+  onTestEnd(test, result) {
+    // pass means as expected, so a test.fail() test that fails passes.  An unexpected skip (serial mode skips the rest
+    // of its group after a failure) is retried like a failure; interrupted is final and neutral, never retried.
+    const status = result.status;
+    const ok = status === (test.expectedStatus || "passed");
+    if (!ok && status !== "interrupted" && result.retry < test.retries) {
+      return; // a retry follows: only the final attempt draws a dot
+    }
+    if (this.ended.has(test.id)) {
+      return; // serial mode reruns the group's passed tests on a retry: the first final result stands
+    }
+    this.ended.add(test.id);
+    const outcome = status === "skipped" || status === "interrupted" ? "skip" : ok ? "pass" : "fail";
+    this.emit({ ev: "test", f: this.file(test), o: outcome });
+  }
+
+  onError(error) {
+    this.errors.push({ message: (error && error.message) || String(error) });
+  }
+
+  onEnd(result) {
+    // --max-failures counts only last attempts, so the run can stop with a retry queued (Ctrl-C and the global timeout
+    // too): such a test sends its outcome so far, and its file closes with it
+    for (const test of this.suite ? this.suite.allTests() : []) {
+      if ((test.results || []).length && !this.ended.has(test.id)) {
+        this.ended.add(test.id);
+        const outcome = test.outcome();
+        const o = outcome === "unexpected" ? "fail" : outcome === "skipped" ? "skip" : "pass";
+        this.emit({ ev: "test", f: this.file(test), o });
+      }
+    }
+    if (!this.jsonPath) {
+      return;
+    }
+    try {
+      fs.mkdirSync(path.dirname(this.jsonPath), { recursive: true });
+      fs.writeFileSync(this.jsonPath, JSON.stringify(this.report(result)));
+    } catch (e) {
+      this.stderr(`juneau-run: could not write the Playwright report ${this.jsonPath}: ${e.message}\n`);
+    }
+  }
+
+  report(result) {
+    const files = [];
+    const suites = new Map(); // file and describe path -> its suite
+    const specs = new Map(); // suite path, title and location -> its spec
+    const stats = { duration: result.duration, expected: 0, skipped: 0, unexpected: 0, flaky: 0 };
+    for (const test of this.suite ? this.suite.allTests() : []) {
+      const file = this.file(test);
+      let key = file;
+      let node = suites.get(key);
+      if (!node) {
+        node = { title: file, file, specs: [], suites: [] };
+        suites.set(key, node);
+        files.push(node);
+      }
+      for (const title of describes(test)) {
+        key += "\0" + title;
+        let child = suites.get(key);
+        if (!child) {
+          child = { title, file, specs: [], suites: [] };
+          suites.set(key, child);
+          node.suites.push(child);
+        }
+        node = child;
+      }
+      const { line, column } = test.location || {};
+      const specKey = `${key}\0\0${test.title}\0${line}:${column}`;
+      let spec = specs.get(specKey);
+      if (!spec) {
+        spec = { title: test.title, ok: true, file, line, column, tests: [] };
+        specs.set(specKey, spec);
+        node.specs.push(spec);
+      }
+      const outcome = test.outcome();
+      spec.ok = spec.ok && outcome !== "unexpected";
+      stats[outcome] = (stats[outcome] || 0) + 1;
+      const project = test.parent && test.parent.project && test.parent.project();
+      spec.tests.push({
+        projectName: (project && project.name) || "",
+        expectedStatus: test.expectedStatus,
+        status: outcome,
+        retries: test.retries,
+        results: (test.results || []).map((r) => ({
+          retry: r.retry,
+          status: r.status,
+          duration: r.duration,
+          ...(r.error ? { error: { message: r.error.message } } : {}),
+        })),
+      });
+    }
+    return { config: { rootDir: this.rootDir, version: this.version }, suites: files, errors: this.errors, stats,
+      status: result.status };
+  }
+}
+
+// The titles of the describe blocks around a test, outermost first.
+function describes(test) {
+  const titles = [];
+  for (let s = test.parent; s && s.type === "describe"; s = s.parent) {
+    titles.unshift(s.title);
+  }
+  return titles;
+}
+
+module.exports = JuneauRunReporter;
diff --git a/juneau-run/src/main/python/juneau_run.py b/juneau-run/src/main/python/juneau_run.py
index ae69d2a782..f2030ee4df 100644
--- a/juneau-run/src/main/python/juneau_run.py
+++ b/juneau-run/src/main/python/juneau_run.py
@@ -54,6 +54,7 @@ import time
 import tty as _tty
 import unicodedata
 import xml.etree.ElementTree as ET
+import zipfile
 from collections import OrderedDict
 from contextlib import contextmanager, suppress
 from pathlib import Path
@@ -180,6 +181,9 @@ class Step:
         self.summary = None
         self.warned = False
         self.paused = False
+        self._open_units = set()     # unit ids open in this step: a second open of one is a ValueError
+        self._marker_ids = set()     # child-step ids its units used, so a cut slug that repeats one gets -<n>
+        self._unit_n = 0
 
     def fail(self, exit=None):  # NOSONAR python:S5806 - parameter names mirror protocol field names
         self.status = "fail"
@@ -208,6 +212,68 @@ class Step:
         Bus-only, no run-protocol marker; the row's status is unchanged."""
         publish("note", level="detail", text=text, href=None, step=self.id)
 
+    @contextmanager
+    def unit(self, id, title):  # NOSONAR python:S5806 - parameter names mirror protocol field names
+        """One line under this step's row for one item of its work (a check, say): unit_start, the block, unit_end.
+        Not a sub-step: the board gets one unit, never a row.  Under markers it is also a v1 child step,
+        `<step id>/<slug>`.  Yields a UnitHandle; an exception ends the unit fail and propagates."""
+        if id in self._open_units:
+            raise ValueError(f"unit {id!r} is already open in step {self.id!r}")
+        marker = self._marker_id(id)
+        self._open_units.add(id)
+        self._unit_n += 1
+        emit("step", id=marker, n=self._unit_n, title=title, parent=self.id)
+        publish("unit_start", step=self.id, id=id, title=title, src="script")
+        u = UnitHandle(self.id, id)
+        t0 = time.monotonic()
+        try:
+            yield u
+        except BaseException:
+            u.status = "fail"
+            raise
+        finally:
+            ms = int((time.monotonic() - t0) * 1000)
+            self._open_units.discard(id)
+            emit("end", id=marker, status=u.status, ms=ms)
+            publish("unit_end", step=self.id, id=id, status=u.status, ms=ms, src="script")
+
+    def _marker_id(self, id):  # NOSONAR python:S5806 - parameter names mirror protocol field names
+        """`<step id>/<slug>`, within the 64-character step-id limit and unique within the step."""
+        room = 64 - len(self.id) - 1
+        if room < 1:
+            raise ValueError(f"step id {self.id!r} leaves no room for a unit's child-step id (64 characters at most)")
+        slug = (re.sub(r"[^a-z0-9]+", "-", str(id).lower()).strip("-") or "step")[:room]
+        candidate, n = slug, 1
+        while f"{self.id}/{candidate}" in self._marker_ids:
+            n += 1
+            tail = f"-{n}"
+            if len(tail) < room:
+                candidate = slug[:room - len(tail)] + tail
+            elif len(str(n)) <= room:
+                candidate = str(n)      # a row id of 61 or 62 characters leaves no room for a slug and a tail
+            else:
+                raise ValueError(f"step id {self.id!r} has no room left for another unit's child-step id")
+        self._marker_ids.add(f"{self.id}/{candidate}")
+        return f"{self.id}/{candidate}"
+
+
+class UnitHandle:
+    """What Step.unit() yields.  Its status defaults to ok; fail(), or any fail dot, ends the unit fail."""
+
+    def __init__(self, step, id):  # NOSONAR python:S5806 - parameter names mirror protocol field names
+        self.step = step
+        self.id = id
+        self.status = "ok"
+
+    def dot(self, outcome):
+        """One result (pass, fail or skip) on the unit's line."""
+        if outcome == "fail":
+            self.status = "fail"
+        publish("unit_dot", step=self.step, id=self.id, outcome=outcome)
+
+    def fail(self):
+        self.status = "fail"
+
 
 @contextmanager
 def step(id, n, title, *, parent=None, label=None):  # NOSONAR python:S5806 - parameter names mirror protocol field names
@@ -311,7 +377,7 @@ def artifacts_dir():
 # Bus
 #
 # Everything the console view shows is an event published here.  Markers are not a subscriber: emit() keeps its own
-# call sites, so the v1 protocol cannot drift.  Events with no marker form (say, failure, class_done, ...) never reach
+# call sites, so the v1 protocol cannot drift.  Events with no marker form (say, failure, unit_dot, ...) never reach
 # emit().
 # ---------------------------------------------------------------------------------------------------------------------
 
@@ -653,20 +719,26 @@ def format_final(status, ms, failed_title, full_log):
     return "❌ Failed" + (f" at {failed_title}" if failed_title else "") + (f" · full log: {full_log}" if full_log else "")
 
 
-class _Mod:
-    """One reactor module under a step row."""
+BAR_SRCS = ("sub", "row")     # unit sources that keep Maven module-bar and Setup bookkeeping (boxes, cells, poms)
+
+
+class Unit:
+    """One running piece of work under a step row, drawn as a line while in progress.  src says where it came from:
+    sub (a parser child step: Maven module, Gradle task, Bazel target), row (a sub-row, step(..., parent=)), file (a
+    pytest or Playwright test file) or script (Step.unit())."""
 
-    def __init__(self, id, title, t0):  # NOSONAR python:S5806 - parameter names mirror protocol field names
+    def __init__(self, id, title, t0, src="sub"):  # NOSONAR python:S5806 - parameter names mirror protocol field names
         self.id = id
         self.title = title
         self.t0 = t0
-        self.outcomes = []      # class_done outcomes, in order
+        self.src = src
+        self.outcomes = []      # unit_dot outcomes, in order
         self.status = None      # None while running
         self.ms = None
         self.totals = None
         self.box = None         # pass, fail or skip once granted
         self.unit_index = None  # its cell in the row's module bar once counted
-        self.started = False    # a module_start arrived for it (the in-flight signal for the module bar)
+        self.started = False    # a unit_start arrived for it (the in-flight signal for the module bar)
         self.dropped = False    # a tests-mode module that ran no tests: left out of the bar and of its total
 
     @property
@@ -729,7 +801,7 @@ class _Row:
         self.ms = None
         self.summary = None
         self.totals = None
-        self.modules = OrderedDict()
+        self.units = OrderedDict()
         self.order = []         # module ids, in the order their boxes were granted
         self.notes = []         # (level, text), held until the row ends: info ones for the screen at `all`, warn
                                 # and error ones (full text) for the condensed log
@@ -740,7 +812,7 @@ class _Row:
         self.reason_level = None    # warn or error; an error reason is never replaced by a warn
         self.hints = []         # lines printed beneath the row when it ends failed (spec 5.1)
         self.held_failure = False   # failure text is among the hints: they print however the row ends
-        self.failed_mods = []   # titles of its failed modules, in failure order, for the row's text
+        self.failed_units = []  # titles of its failed units (modules, test files), in failure order, for its text
         self.size = None        # reactor size N
         self.poms = set()       # the Setup poms: built before the reactor's first real module
         self.tail = set()       # poms built after a real module: on neither the Setup row nor the bar
@@ -764,18 +836,28 @@ class _Row:
         """Modules that never get a cell in the module bar: the Setup poms and the trailing ones."""
         return self.poms | self.tail
 
+    @property
+    def bar_units(self):
+        """Whether any unit takes part in the module bar (file and script units never do)."""
+        return any(m.src in BAR_SRCS for m in self.units.values())
+
+    @property
+    def units_running(self):
+        """Whether any unit is still in progress (its line, not the row's progress text, is what draws)."""
+        return any(m.in_progress and m.id not in self.unbarred for m in self.units.values())
+
     def boxes(self):
-        return [self.modules[m].box for m in self.order]
+        return [self.units[m].box for m in self.order]
 
     def unit_view(self):
         """unit_cells for drawing: the modules that have started but not ended fill the cells right after the finished
         ones as UNIT_RUN.  A module bar fills in finish order, so an in-flight module has no cell of its own yet; this
-        shows how many are running, not which.  Only a Maven module bar has the signal (module_start); other unit
+        shows how many are running, not which.  Only a Maven module bar has the signal (unit_start); other unit
         kinds (tests, tasks, files) never show in-flight and keep PENDING for what has not finished."""
         if self.unit_kind != "modules" or not self.running:
             return self.unit_cells
-        running = sum(1 for m in self.modules.values()
-                      if m.started and m.status is None and m.unit_index is None and not m.dropped and m.id not in self.unbarred)
+        running = sum(1 for m in self.units.values() if m.src in BAR_SRCS and m.started and m.status is None
+                      and m.unit_index is None and not m.dropped and m.id not in self.unbarred)
         view = bytearray(self.unit_cells)
         for i in range(self.units_done, min(self.units_total, self.units_done + running)):
             view[i] = UNIT_RUN
@@ -802,7 +884,7 @@ class Board:
         self.clock = clock
         self.t0 = clock()
         self.rows = OrderedDict()
-        self.module_rows = {}       # module id -> its _Row
+        self.unit_rows = {}         # unit id -> its _Row
         self.failure_lines = set()  # stripped lines already shown by a failure event, so notes don't repeat them
         self.failed_title = None
         self.aliases = {}           # a run_tool sub-row's step id -> its parent row's id (parent=, release.py)
@@ -838,7 +920,7 @@ class Board:
     def setup_failed(self):
         """The titles of the Setup row's failed pom modules, in reactor order."""
         owner = self.rows.get(self.setup.row) if self.setup is not None else None
-        return [owner.modules[i].title if owner is not None and i in owner.modules else i
+        return [owner.units[i].title if owner is not None and i in owner.units else i
                 for i in self.setup.ids if self.setup.boxes[i] == "fail"] if self.setup is not None else []
 
     def _end_setup(self):
@@ -857,7 +939,7 @@ class Board:
         """The row a note, say, hint or failure belongs to: the step's (or its module's), else the newest running
         row."""
         if step_id:
-            r = self._target(step_id) or self.module_rows.get(step_id)
+            r = self._target(step_id) or self.unit_rows.get(step_id)
             if r is not None:
                 return r
         return self._latest_running()
@@ -898,24 +980,27 @@ class Board:
         return r if r is not None else self.rows.get(self.aliases.get(step_id))
 
     def _adopt(self, step_id):
-        """The row for a reactor or module_start event.  When a sub-row's tool sends the first one, that tool's
+        """The row for a reactor or unit_start event.  When a sub-row's tool sends the first one, that tool's
         modules take over the parent row's bar: the boxes the row's sub-rows had (one per command) are dropped."""
         r = self._target(step_id)
-        if r is not None and step_id in self.aliases and step_id in r.modules:
-            for sub in [m for m in r.modules if m in self.aliases]:
-                del r.modules[sub]
-                self.module_rows.pop(sub, None)
-            r.order = [m for m in r.order if m in r.modules]
+        if r is not None and step_id in self.aliases and step_id in r.units:
+            for sub in [m for m in r.units if m in self.aliases]:
+                del r.units[sub]
+                self.unit_rows.pop(sub, None)
+            r.order = [m for m in r.order if m in r.units]
         return r
 
-    def _mod(self, f, create=False):
+    def _unit(self, f, key="id", create=False, src="sub"):
+        """The row and unit an event names (by f[key]); create opens a missing one, of src, with its id as its
+        title."""
         r = self._target(f["step"])
         if r is None:
             return None, None
-        m = r.modules.get(f["module"])
+        m = r.units.get(f[key])
         if m is None and create:
-            m = r.modules[f["module"]] = _Mod(f["module"], f["module"], self.clock())
-            self.module_rows[f["module"]] = r
+            m = r.units[f[key]] = Unit(f[key], f[key], self.clock(), src)
+            if m.src in BAR_SRCS:
+                self.unit_rows[f[key]] = r      # file and script ids are only unique within their row
         return r, m
 
     @staticmethod
@@ -984,7 +1069,7 @@ class Board:
         parent = self.rows.get(f.get("parent"))
         if parent is not None:      # a sub-row: a box on its parent's row until its tool sends modules (_adopt)
             self.aliases[f["id"]] = parent.id
-            return self._on_module_start({"step": parent.id, "module": f["id"], "title": f["title"]})
+            return self._on_unit_start({"step": parent.id, "id": f["id"], "title": f["title"], "src": "row"})
         row = self.rows[f["id"]] = _Row(f["id"], f["n"], f["title"], f.get("label"), self.clock())
         if self.estimator is not None:
             try:
@@ -993,18 +1078,25 @@ class Board:
                 row.estimate = None
         return []
 
-    def _on_module_start(self, f):
-        r = self._adopt(f["step"])
+    def _on_unit_start(self, f):
+        src = f.get("src", "sub")
+        r = self._adopt(f["step"]) if src in BAR_SRCS else self._target(f["step"])
         if r is not None:
-            m = r.modules.get(f["module"])
-            if m is None:
-                r.modules[f["module"]] = _Mod(f["module"], f["title"], self.clock())
+            m = r.units.get(f["id"])
+            if m is None or (src not in BAR_SRCS and m.status is not None):
+                m = r.units[f["id"]] = Unit(f["id"], f["title"], self.clock(), src)    # new, or a closed file/script unit again
+            elif src in BAR_SRCS:
+                # a pom module, created by the reactor event, or a unit an orphan dot or end opened as a script unit
+                m.title, m.t0, m.src = f["title"], self.clock(), src
+                if m.status is not None:      # an orphan end closed it: running again
+                    m.status, m.ms, m.outcomes = None, None, []
             else:
-                m.title, m.t0 = f["title"], self.clock()     # a pom module, created by the reactor event
-            r.modules[f["module"]].started = True
-            self.module_rows[f["module"]] = r
-            if f["module"] in r.poms:     # a pom has no work to wait for; under -T its end only comes with the summary
-                self._setup_box(r, r.modules[f["module"]], "pass")
+                return []       # a file or script unit already open: keep its elapsed (xdist interleaving, retries)
+            m.started = True
+            if src in BAR_SRCS:
+                self.unit_rows[f["id"]] = r     # file and script ids are only unique within their row
+            if f["id"] in r.poms:     # a pom has no work to wait for; under -T its end only comes with the summary
+                self._setup_box(r, m, "pass")
                 return self._setup_settled()
         return []
 
@@ -1024,7 +1116,7 @@ class Board:
                 del r.unit_cells[r.units_total:]
                 r.unit_cells.extend(bytes(r.units_total - len(r.unit_cells)))
             for pom in f["poms"]:
-                self._mod({"step": r.id, "module": pom}, create=True)
+                self._unit({"step": r.id, "module": pom}, key="module", create=True)
             if r.poms and self.setup is None:
                 self.setup = _Setup(r.id, f["poms"], self.clock())
             elif self.setup is not None and self.setup.row == r.id:
@@ -1103,11 +1195,13 @@ class Board:
             r.unit_cells[f["index"]] = self.OUTCOME_BYTE.get(f["outcome"], UNIT_PASS)
         return []
 
-    def _on_class_done(self, f):
-        _, m = self._mod(f, create=True)
+    def _on_unit_dot(self, f):
+        _, m = self._unit(f, create=True, src=f.get("src", "script"))   # an orphan with no src gets no bar
         if m is not None:
             m.outcomes.append(f["outcome"])
             r = self._target(f["step"])
+            if m.src == "file" and m.status is not None and f["outcome"] == "fail":
+                self._fail_file(r, m)       # a late teardown error: named, not reopened, no line
             if m.dropped and r is not None:
                 self._undrop_unit(r, m)     # tests ran after all (module_tested only means "past its test phase")
                 self._count_unit(r, m, self._tests_outcome(m))
@@ -1125,9 +1219,16 @@ class Board:
             return "skip" if m.totals[3] == m.totals[0] else "pass"
         return "skip"
 
+    @staticmethod
+    def _fail_file(r, m):
+        """A failed file unit names its file in the row's text; a failed script unit never does (its row keeps the
+        check's reason)."""
+        if r is not None and m.title not in r.failed_units:
+            r.failed_units.append(m.title)
+
     def _on_module_compiled(self, f):
-        r, m = self._mod(f, create=True)
-        if m is None:
+        r, m = self._unit(f, key="module", create=True)
+        if m is None or m.src not in BAR_SRCS:
             return []
         if m.id in r.unbarred:
             return []                 # pom modules live on the Setup row (or, trailing, nowhere), never in a bar
@@ -1141,11 +1242,11 @@ class Board:
         return []
 
     def _on_module_tested(self, f):
-        r, m = self._mod(f, create=True)
-        if m is not None and m.id not in r.unbarred and m.box != "fail":
+        r, m = self._unit(f, key="module", create=True)
+        if m is not None and m.src in BAR_SRCS and m.id not in r.unbarred and m.box != "fail":
             outcome = self._tests_outcome(m)
             self._grant(r, m, outcome)
-            if r.tests_mode:      # under -T module_end waits for the Reactor Summary: count the unit when its tests end
+            if r.tests_mode:      # under -T unit_end waits for the Reactor Summary: count the unit when its tests end
                 if m.outcomes or (m.totals and m.totals[0]):
                     self._count_unit(r, m, outcome)     # all-skipped tests still count, as skip
                 else:
@@ -1156,15 +1257,21 @@ class Board:
         totals = _totals(f)
         if self._target(f["step"]) is not None:
             self._target(f["step"]).totals = totals
-        elif f["step"] in self.module_rows:
-            self.module_rows[f["step"]].modules[f["step"]].totals = totals
+        elif f["step"] in self.unit_rows:
+            self.unit_rows[f["step"]].units[f["step"]].totals = totals
         return []
 
-    def _on_module_end(self, f):
-        r, m = self._mod(f, create=True)
+    def _on_unit_end(self, f):
+        r, m = self._unit(f, create=True, src=f.get("src", "script"))   # an orphan with no src gets no bar, as a dot's
         if m is None:
             return []
-        m.status, m.ms = f["status"], f["ms"]
+        ms = f["ms"] if f.get("ms") is not None else int((self.clock() - m.t0) * 1000)
+        if m.src not in BAR_SRCS:       # a file or script unit: no bar cell, no Setup box, no finished line
+            m.status, m.ms = f["status"], ms
+            if m.src == "file" and (m.status == "fail" or "fail" in m.outcomes):
+                self._fail_file(r, m)
+            return []
+        m.status, m.ms = f["status"], ms
         if f.get("totals"):
             m.totals = _totals(f["totals"])
         failed = m.status == "fail" or bool(m.totals and m.totals[1] + m.totals[2])
@@ -1181,8 +1288,8 @@ class Board:
                 blocks = self._setup_settled()
         elif m.id in r.tail:    # a trailing pom: no Setup box, no bar cell; its failure still fails the row and is named
             m.box = "fail" if failed else "pass"
-            if failed and m.title not in r.failed_mods:
-                r.failed_mods.append(m.title)
+            if failed and m.title not in r.failed_units:
+                r.failed_units.append(m.title)
         else:
             if failed:
                 self._grant(r, m, "fail")
@@ -1190,8 +1297,8 @@ class Board:
                 self._grant(r, m, "skip" if m.status == "skip" else "pass")
             # a module already boxed red (compile or test failure) stays a failed unit when finish() closes it as skip
             failed = failed or m.box == "fail"
-            if failed and m.title not in r.failed_mods:
-                r.failed_mods.append(m.title)
+            if failed and m.title not in r.failed_units:
+                r.failed_units.append(m.title)
             if r.tests_mode and not failed and m.unit_index is None and not (m.outcomes or (m.totals and m.totals[0])):
                 self._drop_unit(r, m)       # ended without ever running a test (no module_tested, or a skipped module)
             else:
@@ -1211,12 +1318,12 @@ class Board:
         return []
 
     def _on_step_end(self, f):
-        if f["id"] not in self.rows and f["id"] in self.module_rows:
+        if f["id"] not in self.rows and f["id"] in self.unit_rows:
             if f["id"] in self.aliases:
-                self.module_rows[f["id"]].quiet = 0     # a sub-row's tool exited: its parent row is not silent
-                self.module_rows[f["id"]].progress = None
-            return self._on_module_end({"step": self.module_rows[f["id"]].id, "module": f["id"],
-                                        "status": f["status"], "ms": f["ms"], "totals": f.get("totals")})
+                self.unit_rows[f["id"]].quiet = 0     # a sub-row's tool exited: its parent row is not silent
+                self.unit_rows[f["id"]].progress = None
+            return self._on_unit_end({"step": self.unit_rows[f["id"]].id, "id": f["id"], "src": "row",
+                                      "status": f["status"], "ms": f["ms"], "totals": f.get("totals")})
         if f["id"] not in self.rows and f["id"] in self.aliases:
             r = self._target(f["id"])   # a sub-row whose tool took over the parent's bar; the parent ends later
             if r is not None:
@@ -1235,11 +1342,17 @@ class Board:
             r.summary = f["summary"]
         if f.get("totals"):
             r.totals = _totals(f["totals"])
-        for m in r.modules.values():   # never ended: it ran nothing we saw (close_children ends the open ones)
-            if m.box is None and m.id not in r.unbarred:
+        for m in r.units.values():   # never ended: it ran nothing we saw (close_children ends the open ones)
+            if m.src in BAR_SRCS and m.box is None and m.id not in r.unbarred:
                 self._grant(r, m, "fail" if "fail" in m.outcomes else "pass" if m.outcomes else "skip")
-                if m.box == "fail" and m.title not in r.failed_mods:
-                    r.failed_mods.append(m.title)
+                if m.box == "fail" and m.title not in r.failed_units:
+                    r.failed_units.append(m.title)
+        for m in r.units.values():     # still open: closed here, so no line outlives its row (no finished line)
+            if m.status is None:
+                m.status = "fail" if "fail" in m.outcomes or m.box == "fail" or r.status == "fail" else "ok"
+                m.ms = int((self.clock() - m.t0) * 1000)
+                if m.src == "file" and "fail" in m.outcomes:
+                    self._fail_file(r, m)   # only a file with a failed test: an interrupted run doesn't name them all
         if r.status == "fail" and self.failed_title is None:
             self.failed_title = r.label
         blocks = self._end_setup() if self.setup is not None and self.setup.row == r.id else []
@@ -1378,10 +1491,12 @@ def row_icon(row, now, color):
 
 
 def _failed_text(row, expected):
-    """A row with failed modules: `N failed: <first> [+N-1]`, or in tests mode the totals and `: <first> [+N-1]`."""
-    names = row.failed_mods[0] + (f" +{len(row.failed_mods) - 1}" if len(row.failed_mods) > 1 else "")
-    totals = fmt_totals(row.totals, False, expected) if row.tests_mode else ""
-    return f"{totals}: {names}" if totals else f"{len(row.failed_mods)} failed: {names}"
+    """A row with failed units: `N failed: <first> [+N-1]`, or in tests mode, or for failed test files, the totals and
+    `: <first> [+N-1]`."""
+    names = row.failed_units[0] + (f" +{len(row.failed_units) - 1}" if len(row.failed_units) > 1 else "")
+    files = row.totals and any(m.src == "file" and m.title in row.failed_units for m in row.units.values())
+    totals = fmt_totals(row.totals, False, expected) if row.tests_mode or files else ""
+    return f"{totals}: {names}" if totals else f"{len(row.failed_units)} failed: {names}"
 
 
 def row_text(row, glyphs, now, pending=False):
@@ -1390,11 +1505,11 @@ def row_text(row, glyphs, now, pending=False):
     time, and whether text may be cut to fit (a reason or failed-module text).  Elapsed is empty under a second, so
     instant rows (Install) show none."""
     cells, parts = None, []
-    # A module row can carry a unit bar too, hence the `row.modules` test below.  A unit bar's PENDING cells also
+    # A module row can carry a unit bar too, hence the `row.bar_units` test below.  A unit bar's PENDING cells also
     # show on a finished row whose units did not all report.
     unreported = not row.running and row.unit_kind == "modules" and not row.units_done
     # A finished module bar that never counted a unit (a -q build sends no module events) shows no bar at all.
-    if not unreported and (row.modules or row.size or row.units_total or row.unit_kind == "modules"):
+    if not unreported and (row.bar_units or row.size or row.units_total or row.unit_kind == "modules"):
         if row.unit_kind:
             cells = unit_bar(row.unit_view(), row.units_total, glyphs)
             parts.append(f"{row.units_done}/{row.units_total}")
@@ -1414,7 +1529,7 @@ def row_text(row, glyphs, now, pending=False):
             elapsed = timer_text(ms / 1000, total)
     expected = row.estimate.tests if row.running and row.estimate is not None else None
     cut = True
-    if row.failed_mods:
+    if row.failed_units:
         text = _failed_text(row, expected)
     elif row.reason:
         text = row.reason
@@ -1535,7 +1650,8 @@ class PlainRenderer:
         totals = [r.totals for r in running if r.totals]
         total = sum(t[0] for t in totals)
         failed = sum(t[1] + t[2] for t in totals)
-        k = sum(1 for r in running for m in r.modules.values() if m.in_progress and m.id not in r.unbarred)
+        k = sum(1 for r in running for m in r.units.values()
+                if m.src in BAR_SRCS and m.in_progress and m.id not in r.unbarred)
         overall = board.overall()
         figure = (f" · {overall.percent}% · ~{fmt_elapsed(overall.remaining * 1000)}"
                   f"{'+' if overall.lower_bound else ''} left") if overall else ""
@@ -1726,7 +1842,7 @@ class LiveRenderer:
         rest = "  ".join(p for p in (count, text) if p)
         if pending and r.quiet:
             rest += f" · quiet {r.quiet}s"
-        if r.running and r.progress and (not r.unit_kind or r.unit_kind == "checks"):
+        if r.running and r.progress and (not r.unit_kind or (r.unit_kind == "checks" and not r.units_running)):
             rest = f"{rest} · {r.progress}" if rest else r.progress
         return _fit_row(label, cells, rest, elapsed, cols, wrap=bool(r.unit_kind))
 
@@ -1743,42 +1859,40 @@ class LiveRenderer:
         self._draw()
 
     def region(self, board, cols, max_lines):
-        """The running rows in start order (each with its Setup row and sub-lines), then the pending rows in plan
-        order, then Overall.  When it is taller than max_lines the pending rows fold first (into "○ +N more pending",
-        kept while anything is pending), then the module sub-lines (+N more running), then the lines above Overall
-        are cut: Overall itself is never dropped."""
+        """The running rows in start order (each with its Setup row and a line per running unit), then the pending
+        rows in plan order, then Overall.  When it is taller than max_lines the pending rows fold first (into
+        "○ +N more pending", kept while anything is pending), then the unit lines, newest first, onto one packed
+        names line per row, then the lines above Overall are cut: Overall itself is never dropped."""
         running = board.running()
-        inflight = [(r, m) for r in running for m in r.modules.values() if m.in_progress and m.id not in r.unbarred]
-        subs = [(r, m) for r, m in inflight if m.outcomes or m.totals]
-        collapsed = {}
-        for r in running:
-            names = [m.title for owner, m in inflight if owner is r and not (m.outcomes or m.totals)]
-            if names:
-                prefix = common_prefix([m.title for m in r.modules.values() if m.id not in r.unbarred])
-                collapsed[r.id] = "    " + collapse_names([t[len(prefix):] or t for t in names], cols - 2)
-        blocks = {id(m): self._sub_lines(m, cols) for _, m in subs}
+        now = self.clock()
+        subs = [(r, m) for r in running for m in r.units.values() if m.in_progress and m.id not in r.unbarred]
         current = {r.id: "    " + _vcut(r.progress, max(1, cols - 4)) for r in running
-                   if r.progress and r.unit_kind in ("tests", "files")}   # what a bar-only row is running now
+                   if r.progress and r.unit_kind in ("tests", "files") and not r.units_running}   # no unit line to show
         setup_running = (board.setup is not None and not board.setup.ended
                          and any(r.id == board.setup.row for r in running))
         overall = board.overall()
         row_text_ = {r.id: self._row(board, r, cols, pending=True) for r in running}
         row_lines = sum(t.count("\n") + 1 for t in row_text_.values())     # a wrapped unit bar spans several lines
-        fixed = row_lines + len(collapsed) + len(current) + (1 if setup_running else 0) + (1 if overall else 0)
+        fixed = row_lines + len(current) + (1 if setup_running else 0) + (1 if overall else 0)
         pending = board.pending()
         shown = len(pending)
-        if pending and fixed + sum(len(b) for b in blocks.values()) + shown > max_lines:
-            shown = max(0, max_lines - fixed - sum(len(b) for b in blocks.values()) - 1)
+        if pending and fixed + len(subs) + shown > max_lines:
+            shown = max(0, max_lines - fixed - len(subs) - 1)
             if shown + 1 >= len(pending):
                 shown = len(pending)
         pending_lines = [_vcut(pending_line(label, self.color), cols) for _, label in pending[:shown]]
         if shown < len(pending):
             pending_lines.append(pending_line(f"+{len(pending) - shown} more pending", self.color))
         budget = max_lines - fixed - len(pending_lines)
-        folded = {}
-        while subs and sum(len(blocks[id(m)]) for _, m in subs) + len(folded) > budget:
-            r, _ = subs.pop(0)
-            folded[r.id] = folded.get(r.id, 0) + 1
+        folded = {}     # row id -> its folded units: the newest started go first, so the slow ones stay in view
+        gone = set()
+        # newest first; of units started at the same instant the one listed later (started later) folds first
+        for i in sorted(range(len(subs)), key=lambda i: (subs[i][1].t0, i), reverse=True):
+            if len(subs) - len(gone) + len(folded) <= budget:
+                break
+            gone.add(i)
+            folded.setdefault(subs[i][0].id, []).append(subs[i][1])
+        subs = [rm for i, rm in enumerate(subs) if i not in gone]
         lines = []
         for r in running:
             if setup_running and board.setup.row == r.id:
@@ -1786,22 +1900,30 @@ class LiveRenderer:
             lines.append(row_text_[r.id])
             if r.id in current:
                 lines.append(current[r.id])
-            if r.id in collapsed:
-                lines.append(collapsed[r.id])
+            mine = [m for owner, m in subs if owner is r]
+            width = max((_vlen(m.title) for m in mine), default=1)     # _sub_lines caps it
+            lines += [line for m in mine for line in self._sub_lines(m, cols, width, now)]
             if r.id in folded:
-                lines.append(f"    +{folded[r.id]} more running")
-            lines += [line for owner, m in subs if owner is r for line in blocks[id(m)]]
+                names = [m.title for m in reversed(folded[r.id])]     # folded newest first: listed oldest first
+                prefix = common_prefix([m.title for m in r.units.values() if m.id not in r.unbarred])
+                lines.append("    " + collapse_names([t[len(prefix):] or t for t in names], cols - 2))
         lines += pending_lines
         lines = [part for line in lines for part in line.split("\n")]
         if not overall:
             return lines[:max(1, max_lines)]
         return lines[:max(0, max_lines - 1)] + [_vcut(overall_text(overall), cols)]
 
-    def _sub_lines(self, m, cols):
-        """One line per module: the newest dots that fit.  When they do not all fit, the last cells hold "+N", N being
-        the older results dropped (red if any of them failed, else green), so the strip keeps its full width."""
-        prefix = f"    {_vcut(m.title, max(1, cols // 2))}  "
-        per = max(1, cols - _vlen(prefix))
+    def _sub_lines(self, m, cols, width=None, now=None):
+        """A unit's line: its title padded to width (its row's longest shown title, at most half the line and room for
+        a dot), the newest dots that fit, then its elapsed time ending at column cols.  When the dots do not all fit,
+        the last cells hold "+N", N being the older results dropped (red if any of them failed, else green), so the
+        strip keeps its full width and elapsed never moves.  The line is never wider than cols."""
+        elapsed = fmt_elapsed(((self.clock() if now is None else now) - m.t0) * 1000)
+        # 9: the indent, the gap after the title, one dot and the gap before elapsed
+        width = max(1, min(cols // 2, cols - 9 - len(elapsed), _vlen(m.title) if width is None else width))
+        title = _vcut(m.title, width)
+        prefix = "    " + title + " " * (width - _vlen(title)) + "  "
+        per = max(1, cols - _vlen(prefix) - len(elapsed) - 2)
         cells = [self.dots[o] for o in m.outcomes]
         if len(cells) > per:
             for digits in range(1, len(str(len(cells))) + 1):
@@ -1813,7 +1935,9 @@ class LiveRenderer:
             text = _vcut(f"+{hidden}", per)
             count = f"\x1b[{31 if 'fail' in m.outcomes[:hidden] else 32}m{text}\x1b[0m" if self.color else text
             cells = cells[hidden:] + [count] if per > len(text) else [count]
-        return [prefix + "".join(cells)]
+        strip = "".join(cells)
+        line = prefix + strip + " " * max(2, cols - _vlen(prefix) - _vlen(strip) - len(elapsed)) + elapsed
+        return [line if _vlen(line) <= cols else _vcut(line, cols)]     # a terminal too narrow for even one dot
 
     def _draw(self, force=False):
         if self.closed or self.paused or self.prompting or self.board is None:
@@ -2568,11 +2692,13 @@ class History:
         if row is not None:
             row["tests"] = f["total"]
 
-    def _on_module_end(self, f):
+    def _on_unit_end(self, f):
+        if f.get("src") != "sub":
+            return          # only parser child steps are recorded (sub-rows, files and script units are not)
         row = self._row(f["step"])
         if row is None:
             return
-        module = f["module"]
+        module = f["id"]
         key = self._key(f["step"], module)
         if key in row["subs"] or len(row["subs"]) < self.SUBS_CAP:
             row["subs"][key] = round(f["ms"] / 1000, 3)
@@ -2763,6 +2889,8 @@ class Sinks:
 #   ("progress", id, text)               bus only (step_progress), never a marker
 #   ("units", id, total, kind)           ("unit", id, done, outcome)   bus only (units / unit), never a marker
 #   ("unit_fix", id, index, outcome)     ("series", id, name, sub, value)   bus only (unit_fix / series)
+#   ("unit_start", unit, title, src)     ("unit_dot", unit, outcome)   ("unit_end", unit, status, src)
+#                                        bus only, under the parser's own step: a unit line, never a child step
 #   poll() and finish() return the same tuples; render(line) returns text, not events (see Parser.render)
 # ---------------------------------------------------------------------------------------------------------------------
 
@@ -3406,6 +3534,37 @@ def find_pytest_plugin():
     return None
 
 
+_PLAYWRIGHT_REPORTER = "juneau_run_playwright.cjs"
+_playwright_extracted = None       # the reporter extracted from the zip, once per process
+
+
+def find_playwright_reporter():
+    """The Playwright reporter file for --reporter, or None.  On disk it is playwright_reporter/ beside the engine, or
+    the source tree's src/main/js/playwright_reporter/.  Node can't load from inside a zip, so from the toolkit zip the
+    member is extracted once per process into a temp directory that is removed at exit."""
+    global _playwright_extracted
+    for where in (_engine_dir() / "playwright_reporter", _engine_dir().parent / "js" / "playwright_reporter"):
+        if (where / _PLAYWRIGHT_REPORTER).is_file():
+            return str(where / _PLAYWRIGHT_REPORTER)
+    scripts = sys.modules.get("juneau_scripts")
+    path = getattr(scripts, "__file__", None) or ""
+    marker = ".zip" + os.sep
+    if marker not in path:
+        return None
+    if _playwright_extracted is None:
+        try:
+            with zipfile.ZipFile(path[:path.index(marker) + len(".zip")]) as z:
+                data = z.read("playwright_reporter/" + _PLAYWRIGHT_REPORTER)
+        except (OSError, KeyError, zipfile.BadZipFile):
+            return None
+        directory = tempfile.mkdtemp(prefix="juneau-run-playwright-")
+        atexit.register(shutil.rmtree, directory, ignore_errors=True)
+        target = Path(directory) / _PLAYWRIGHT_REPORTER
+        target.write_bytes(data)
+        _playwright_extracted = str(target)
+    return _playwright_extracted
+
+
 _RUNNERS = ("uv", "poetry", "pipenv", "pdm", "hatch", "rye")
 # Launcher flags that take a separate value; their value (e.g. `--with pytest`) is not the program being run.
 _RUNNER_VALUE_FLAGS = {"--with", "--with-requirements", "--with-editable", "--python", "-p", "--project", "--directory",
@@ -3489,6 +3648,9 @@ class PytestParser(Parser):
         self.file_state = {}         # file -> [any failed, all skipped]
         self.file_cell = {}          # finished file -> its bar index
         self.files_done = 0
+        # File units (plugin file markers): one line per running test file, closed with its last test
+        self.unit_open = {}          # open file -> whether any of its tests failed so far
+        self.unit_closed = set()
 
     def prepare(self, cmd, env, cwd):
         existing = None
@@ -3534,9 +3696,9 @@ class PytestParser(Parser):
         if kind == "hello":
             self.plugin = True
             return []
-        if kind == "run":
+        if kind == "file":
             name = m.get("f")
-            return [("progress", self.step_id, name[:400])] if isinstance(name, str) and name else []
+            return self._open_unit(name[:400]) if isinstance(name, str) and name else []
         if kind == "total":
             n = m.get("n")
             if isinstance(n, int) and n >= 0:
@@ -3560,6 +3722,10 @@ class PytestParser(Parser):
             self.errors += 1
             events = []
             fname = self._file_of(m.get("id"))
+            if fname in self.unit_closed:
+                events.append(("unit_dot", fname, "fail"))     # names the file without reopening it
+            elif fname in self.unit_open:
+                self.unit_open[fname] = True
             if self.files is not None:
                 if fname in self.file_cell:
                     events.append(("unit_fix", self.step_id, self.file_cell[fname], "fail"))
@@ -3595,8 +3761,31 @@ class PytestParser(Parser):
                     self.files_done += 1
                     events.insert(0, ("unit", self.step_id, self.files_done,
                                       "fail" if state[0] else "skip" if state[1] else "pass"))
-            return events
-        return [("unit", self.step_id, self.done, unit), self._tests_event()]
+            return events + self._unit_dot(fname, unit)
+        return [("unit", self.step_id, self.done, unit), self._tests_event()] + self._unit_dot(self._file_of(m.get("id")), unit)
+
+    def _open_unit(self, fname):
+        """A file's unit line opens the first time the file is named (by its file marker, or by an orphan test)."""
+        if fname in self.unit_open or fname in self.unit_closed:
+            return []           # xdist names an interleaved file again: its elapsed keeps running
+        self.unit_open[fname] = False
+        return [("unit_start", fname, fname, "file")]
+
+    def _unit_dot(self, fname, unit):
+        """A finished test: a dot on its file's line, and the file's unit closes with its last test when the total
+        marker gave per-file counts (else in finish)."""
+        if fname in self.unit_closed:
+            return [("unit_dot", fname, unit)]
+        events = self._open_unit(fname)
+        events.append(("unit_dot", fname, unit))
+        self.unit_open[fname] = self.unit_open[fname] or unit == "fail"
+        if self.files is not None and self.file_seen.get(fname, 0) >= self.files.get(fname, float("inf")):
+            events.append(self._close_unit(fname))
+        return events
+
+    def _close_unit(self, fname):
+        self.unit_closed.add(fname)
+        return ("unit_end", fname, "fail" if self.unit_open.pop(fname) else "ok", "file")
 
     @staticmethod
     def _file_of(node_id):
@@ -3682,15 +3871,20 @@ class PytestParser(Parser):
         return events
 
     def finish(self, exit_code):
+        events = [self._close_unit(f) for f in list(self.unit_open)]   # no per-file counts, or the run stopped short
         if self.report_path and os.path.exists(self.report_path):
-            return [("report", self.step_id, "junitxml", self.report_path)]
-        return []
+            events.append(("report", self.step_id, "junitxml", self.report_path))
+        return events
 
 
 class PlaywrightParser(Parser):
-    """Playwright line reporter: JSON report plus counts from progress and the final summary."""
+    """Playwright: counts from the final summary, and from the juneau_run_playwright reporter's ##playwright markers,
+    which also draw one unit line per running test file, closed with its last test.  Without the reporter (it never
+    said hello) the bar counts the line reporter's [n/N] progress instead."""
 
     name = "playwright"
+    MARKER = "##playwright "
+    OUTCOMES = ("pass", "fail", "skip")
     PROGRESS = re.compile(r"^\[(\d+)/(\d+)\] ")
     FAILURE = re.compile(r"^\s+(\d+)\) ")
     SUMMARY = re.compile(r"^\s+(\d+) (passed|failed|skipped|flaky|interrupted|did not run)\b")
@@ -3704,21 +3898,60 @@ class PlaywrightParser(Parser):
         self.failures = 0
         self.block_left = 0
         self.final = {}
+        self.files = {}              # file -> tests it holds, from the reporter's begin marker
+        self.unit_seen = {}          # file -> tests finished so far
+        self.unit_open = {}          # open file -> whether any of its tests failed so far
+        self.unit_closed = set()
+        self.injected = False        # prepare() added the reporter with its markers on
+        self.hello = False           # the reporter said hello: the bar counts its final test lines
+        self.done = 0                # tests finished, from the reporter
+        self.failed = self.skipped = 0
 
     def prepare(self, cmd, env, cwd):
+        """Adds the juneau_run_playwright reporter through PW_TEST_REPORTER, which Playwright appends to the reporters
+        of the playwright.config (or to its default reporter), so they still run and print as in a plain run.  The
+        variable is undocumented: a Playwright that ignores it runs as plain, and finish() notes the missing reporter.
+        Only when something reads it: markers, or a session's Console when not interactive.  Not when the caller names
+        a reporter (--reporter, or a PW_TEST_REPORTER of its own), JUNEAU_RUN_PLAYWRIGHT_REPORTER=0, or it can't be
+        found.  Under markers with RUN_ARTIFACTS the reporter writes the JSON report too.  Under a terminal it prints no
+        markers and is added only for that report, so what Playwright prints is unchanged.  Outside one the html
+        reporter never opens its report: with stdin a terminal it would serve it and wait."""
         env = dict(env if env is not None else os.environ)
-        if enabled():
-            if not any(a.startswith("--reporter") for a in cmd):
-                cmd = list(cmd) + ["--reporter=line,json"]
-            directory = artifacts_dir()
-            if "PLAYWRIGHT_JSON_OUTPUT_NAME" not in env and directory is not None:
-                env["PLAYWRIGHT_JSON_OUTPUT_NAME"] = str(directory / f"{self.step_id.replace('/', '-')}-playwright.json")
-        self.report_path = env.get("PLAYWRIGHT_JSON_OUTPUT_NAME")
+        named = "PW_TEST_REPORTER" in env or any(a == "--reporter" or a.startswith("--reporter=") for a in cmd)
+        directory = artifacts_dir() if enabled() else None
+        wanted = directory is not None if self.interactive else enabled() or _console is not None
+        opt_out = env.get("JUNEAU_RUN_PLAYWRIGHT_REPORTER", os.environ.get("JUNEAU_RUN_PLAYWRIGHT_REPORTER")) == "0"
+        where = find_playwright_reporter() if wanted and not named and not opt_out else None
+        if where:
+            env["PW_TEST_REPORTER"] = where
+            if self.interactive:
+                env["JUNEAU_RUN_PLAYWRIGHT_MARKERS"] = "0"
+            else:
+                env.pop("JUNEAU_RUN_PLAYWRIGHT_MARKERS", None)      # an injected reporter is never silent
+                if "PLAYWRIGHT_HTML_OPEN" not in env and "PW_TEST_HTML_REPORT_OPEN" not in env:
+                    env["PLAYWRIGHT_HTML_OPEN"] = "never"
+            if directory is not None:
+                env.setdefault("JUNEAU_RUN_PLAYWRIGHT_JSON",
+                               str(directory / f"{self.step_id.replace('/', '-')}-playwright.json"))
+            self.injected = not self.interactive
+            self.report_path = env.get("JUNEAU_RUN_PLAYWRIGHT_JSON")
+            if self.report_path:
+                try:
+                    Path(self.report_path).unlink()    # a run cancelled before onEnd mustn't announce the last one's
+                except OSError:
+                    pass
+        else:
+            self.report_path = env.get("PLAYWRIGHT_JSON_OUTPUT_NAME") if enabled() else None   # the caller's own json
         return cmd, env
 
     def handle(self, line):
+        at = line.find(self.MARKER)
+        if at >= 0:     # a test's stdout with no trailing newline puts its text in front of the marker
+            return (self.handle(line[:at]) if at else []) + self._reporter_event(line[at + len(self.MARKER):])
         events = []
         m = self.PROGRESS.match(line)
+        if m and self.hello:
+            return []           # the reporter's test lines count, once per test however often it is retried
         if m:
             self.run_count = int(m.group(1))
             total = int(m.group(2))
@@ -3734,7 +3967,8 @@ class PlaywrightParser(Parser):
             self.block_left = 0
             events.append(("cond", line))
             total = sum(v for k, v in self.final.items() if k != "did not run")
-            events.append(("tests", self.step_id, total, self.final.get("failed", 0), 0, self.final.get("skipped", 0)))
+            skipped = self.final.get("skipped", 0) + self.final.get("interrupted", 0)    # as the reporter's skip dot
+            events.append(("tests", self.step_id, total, self.final.get("failed", 0), 0, skipped))
             return events
         if self.FAILURE.match(line):
             self.failures += 1
@@ -3746,10 +3980,75 @@ class PlaywrightParser(Parser):
             events.append(("cond", line))
         return events
 
+    def _reporter_event(self, text):
+        try:
+            m = json.loads(text)
+        except ValueError:
+            return []
+        if not isinstance(m, dict):
+            return []
+        kind, name = m.get("ev"), m.get("f")
+        if kind == "hello":
+            self.hello = True
+            return []
+        if kind == "begin" and isinstance(name, dict):
+            self.files = {str(f)[:400]: n for f, n in name.items() if isinstance(n, int)}
+            total = sum(self.files.values())
+            if not self.hello or total == self.total:
+                return []
+            self.total = total
+            return [("units", self.step_id, total, "tests")]
+        if not isinstance(name, str):
+            return []
+        name = name[:400]
+        if kind == "start":
+            return self._open_unit(name) if name else []
+        if kind == "test" and m.get("o") in self.OUTCOMES:
+            return self._count(m["o"]) + (self._unit_dot(name, m["o"]) if name else [])
+        return []
+
+    def _count(self, outcome):
+        if not self.hello:
+            return []
+        self.done += 1
+        self.failed += outcome == "fail"
+        self.skipped += outcome == "skip"
+        if self.final:      # onEnd's line for a test whose retry never ran comes after the summary, whose counts stand
+            return [("unit", self.step_id, self.done, outcome)]
+        return [("unit", self.step_id, self.done, outcome), ("tests", self.step_id, self.done, self.failed, 0, self.skipped)]
+
+    def _open_unit(self, name):
+        """A file's line opens with its first test; a retry's start, or a later test, doesn't reset its elapsed."""
+        if name in self.unit_open or name in self.unit_closed:
+            return []
+        self.unit_open[name] = False
+        return [("unit_start", name, name, "file")]
+
+    def _unit_dot(self, name, outcome):
+        if name in self.unit_closed:
+            return [("unit_dot", name, outcome)]
+        events = self._open_unit(name)
+        events.append(("unit_dot", name, outcome))
+        self.unit_open[name] = self.unit_open[name] or outcome == "fail"
+        self.unit_seen[name] = self.unit_seen.get(name, 0) + 1
+        if self.unit_seen[name] >= self.files.get(name, float("inf")):
+            events.append(self._close_unit(name))
+        return events
+
+    def _close_unit(self, name):
+        self.unit_closed.add(name)
+        return ("unit_end", name, "fail" if self.unit_open.pop(name) else "ok", "file")
+
     def finish(self, exit_code):
+        # a file still open (interrupted, --max-failures, a crashed worker) fails only with a fail dot, as pytest's: the
+        # row fails from the exit code, and names only the files that failed
+        events = [self._close_unit(f) for f in list(self.unit_open)]
+        # tests ran (Playwright printed progress or a summary) yet no hello: not after a config error, --list or --help
+        if self.injected and not self.hello and (self.run_count or self.final):
+            events.append(("note", "warn", "Playwright reporter not loaded: no per-file lines", self.step_id))
         if self.report_path and os.path.exists(self.report_path):
-            return [("report", self.step_id, "playwright-json", self.report_path)]
-        return []
+            events.append(("report", self.step_id, "playwright-json", self.report_path))
+        return events
 
 
 class GradleParser(Parser):
@@ -3978,7 +4277,7 @@ class _Dispatcher:
         self.pending = {}                        # tests target -> latest totals not yet emitted
         self.last_sent = {}
         self.summary = {}
-        self.latest = {}   # tests target -> its latest totals, for module_end
+        self.latest = {}   # tests target -> its latest totals, for unit_end
 
     def apply(self, events):
         for ev in events:
@@ -3987,13 +4286,14 @@ class _Dispatcher:
                 _, sub, n, title = ev
                 self.open[sub] = time.monotonic()
                 emit("step", id=sub, n=n, title=title, parent=self.step_id)
-                publish("module_start", step=self.step_id, module=sub, title=title)
+                publish("unit_start", step=self.step_id, id=sub, title=title, src="sub")
             elif kind == "sub_end":
                 _, sub, status, ms = ev
                 self.flush_tests(sub)
                 self.open.pop(sub, None)
                 emit("end", id=sub, status=status, ms=ms)
-                publish("module_end", step=self.step_id, module=sub, status=status, ms=ms, totals=self.latest.get(sub))
+                publish("unit_end", step=self.step_id, id=sub, status=status, ms=ms, totals=self.latest.get(sub),
+                        src="sub")
             elif kind == "tests":
                 _, target, total, fail, err, skip = ev
                 if target == self.step_id:
@@ -4009,7 +4309,7 @@ class _Dispatcher:
                 _, level, text, target = ev
                 note(level, text, step=target)
             elif kind == "class_done":
-                publish("class_done", step=self.step_id, module=ev[1], outcome=ev[2])
+                publish("unit_dot", step=self.step_id, id=ev[1], outcome=ev[2], src="sub")
             elif kind == "compiled":
                 publish("module_compiled", step=self.step_id, module=ev[1], ok=ev[2])
             elif kind == "tested":
@@ -4027,6 +4327,12 @@ class _Dispatcher:
                 publish("unit", step=ev[1], done=ev[2], outcome=ev[3])
             elif kind == "unit_fix":
                 publish("unit_fix", step=ev[1], index=ev[2], outcome=ev[3])
+            elif kind == "unit_start":
+                publish("unit_start", step=self.step_id, id=ev[1], title=ev[2], src=ev[3])
+            elif kind == "unit_dot":
+                publish("unit_dot", step=self.step_id, id=ev[1], outcome=ev[2])
+            elif kind == "unit_end":
+                publish("unit_end", step=self.step_id, id=ev[1], status=ev[2], src=ev[3])    # no ms: the board times it
             elif kind == "series":
                 publish("series", step=ev[1], name=ev[2], sub=ev[3], value=ev[4])
             elif kind == "cond":
@@ -4040,13 +4346,14 @@ class _Dispatcher:
 
     def close_children(self, status):
         """Producer rule: every open child gets its `end`, children first, before the parent's.  The bus gets the
-        matching module_end."""
+        matching unit_end."""
         for sub in reversed(list(self.open)):
             started = self.open[sub]
             self.flush_tests(sub)
             ms = int((time.monotonic() - started) * 1000)
             emit("end", id=sub, status=status, ms=ms)
-            publish("module_end", step=self.step_id, module=sub, status=status, ms=ms, totals=self.latest.get(sub))
+            publish("unit_end", step=self.step_id, id=sub, status=status, ms=ms, totals=self.latest.get(sub),
+                    src="sub")
         self.open.clear()
         for target in list(self.pending):
             self.flush_tests(target)
diff --git a/juneau-run/src/main/python/juneau_scripts/run.py b/juneau-run/src/main/python/juneau_scripts/run.py
index 61750d26b9..de8f60aad3 100644
--- a/juneau-run/src/main/python/juneau_scripts/run.py
+++ b/juneau-run/src/main/python/juneau_scripts/run.py
@@ -153,6 +153,21 @@ class _NullStep:
         """Mirrors juneau_run.Step.pause: in a plain run the text is printed."""
         print(f"⏸ {text}", flush=True)
 
+    @contextlib.contextmanager
+    def unit(self, id, title):  # NOSONAR python:S5806 - mirrors juneau_run.Step.unit
+        """Mirrors juneau_run.Step.unit: in a plain run there is no line to draw."""
+        yield _NullUnit()
+
+
+class _NullUnit:
+    status = "ok"
+
+    def dot(self, outcome):
+        pass
+
+    def fail(self):
+        pass
+
 
 @contextlib.contextmanager
 def step(id, title, *, n=None, parent=None, label=None):  # NOSONAR python:S5806 - mirrors juneau_run.step
@@ -344,9 +359,11 @@ def drive_checks(id, st, checks, *, dry_run=False):  # NOSONAR python:S5806 - mi
     else:
         jr.publish("units", step=id, total=len(checks), kind="checks")
         for i, c in enumerate(checks):
-            jr.publish("step_progress", step=id, text=c.name)
-            jr.tick()
-            v = verdicts[c.name] = _checks.evaluate(c, dry_run=dry_run)
+            with _unit(st, _slug(c.name), c.name) as u:    # the check's line under the row, while it runs
+                jr.tick()
+                v = verdicts[c.name] = _checks.evaluate(c, dry_run=dry_run)
+                if v.status == "fail":
+                    u.fail()
             jr.publish("unit", step=id, done=i + 1, outcome=v.status)
             if v.status != "pass":
                 jr.emit("note", level="error" if v.status == "fail" else "warn",
@@ -365,6 +382,12 @@ def drive_checks(id, st, checks, *, dry_run=False):  # NOSONAR python:S5806 - mi
     return CheckResult(level != "error", verdicts, refused)
 
 
+def _unit(st, id, title):  # NOSONAR python:S5806 - mirrors juneau_run.Step.unit
+    """st.unit(id, title), or a null unit when st comes from an older juneau_run whose Step has no unit()."""
+    unit = getattr(st, "unit", None)
+    return unit(id, title) if unit is not None else contextlib.nullcontext(_NullUnit())
+
+
 _run_checks = checks     # Session.checks's parameter of the same name hides it
 
 
diff --git a/juneau-run/src/main/python/juneau_scripts/tools.py b/juneau-run/src/main/python/juneau_scripts/tools.py
index 4db924992c..5710778e80 100644
--- a/juneau-run/src/main/python/juneau_scripts/tools.py
+++ b/juneau-run/src/main/python/juneau_scripts/tools.py
@@ -116,8 +116,8 @@ def playwright_ensure(directory):
 
 
 def playwright(args, *, cwd, **cmd_kwargs):
-    """npx playwright test <args> with the playwright parser, which adds its reporter flags to this command line.
-    A wrapper script that runs Playwright itself must use run.cmd instead."""
+    """npx playwright test <args> with the playwright parser, which adds its reporter through the environment
+    (PW_TEST_REPORTER), so a wrapper script that runs Playwright itself works too: run.cmd(..., tool="playwright")."""
     return run.cmd(["npx", "playwright", "test", *args], title=cmd_kwargs.pop("title", "playwright test"),
                    tool="playwright", cwd=cwd, **cmd_kwargs)
 
diff --git a/juneau-run/src/main/python/pytest_plugin/juneau_run_pytest.py b/juneau-run/src/main/python/pytest_plugin/juneau_run_pytest.py
index b05bd8f44f..1d9e419eaa 100644
--- a/juneau-run/src/main/python/pytest_plugin/juneau_run_pytest.py
+++ b/juneau-run/src/main/python/pytest_plugin/juneau_run_pytest.py
@@ -137,7 +137,7 @@ def pytest_runtest_logstart(nodeid, location):
     name = nodeid.split("::", 1)[0][:400]
     if name != _current_file[0]:
         _current_file[0] = name
-        _emit(config, {"ev": "run", "f": name})     # the test file now running, for the row's current-file line
+        _emit(config, {"ev": "file", "f": name})    # the test file now running: its unit line under the row
 
 
 def pytest_runtest_logreport(report):
diff --git a/juneau-run/src/test/js/juneau_run_playwright.test.cjs b/juneau-run/src/test/js/juneau_run_playwright.test.cjs
new file mode 100644
index 0000000000..c255a524d0
--- /dev/null
+++ b/juneau-run/src/test/js/juneau_run_playwright.test.cjs
@@ -0,0 +1,294 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+"use strict";
+
+// node --test cases for the Playwright reporter, against fake config, suite, test and result objects (no Playwright
+// install).  test_playwright_reporter.py runs this file in the Maven gate.
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { test } = require("node:test");
+
+const Reporter = require(path.join(__dirname, "..", "..", "main", "js", "playwright_reporter", "juneau_run_playwright.cjs"));
+
+const ROOT = path.join(path.sep, "repo");
+
+let ids = 0;
+
+function fakeTest(file, retries = 0, expectedStatus = "passed") {
+  return { id: `t${++ids}`, location: { file: path.join(ROOT, ...file.split("/")) }, retries, expectedStatus };
+}
+
+function run(fn, env = {}) {
+  const lines = [];
+  const reporter = new Reporter({ write: (text) => lines.push(text), env });
+  fn(reporter);
+  return lines.map((line) => {
+    assert.ok(line.startsWith("##playwright ") && line.endsWith("\n"), line);
+    return JSON.parse(line.slice("##playwright ".length));
+  });
+}
+
+test("begin says hello, then counts each file's tests relative to rootDir", () => {
+  const tests = [fakeTest("tests/a.spec.ts"), fakeTest("tests/a.spec.ts"), fakeTest("tests/sub/b.spec.ts")];
+  const events = run((r) => r.onBegin({ rootDir: ROOT }, { allTests: () => tests }));
+  assert.deepEqual(events, [{ ev: "hello", v: 1 }, { ev: "begin", f: { "tests/a.spec.ts": 2, "tests/sub/b.spec.ts": 1 } }]);
+});
+
+test("a test under two projects counts twice", () => {
+  const tests = [fakeTest("a.spec.ts"), fakeTest("a.spec.ts")];
+  const [, begin] = run((r) => r.onBegin({ rootDir: ROOT }, { allTests: () => tests }));
+  assert.deepEqual(begin.f, { "a.spec.ts": 2 });
+});
+
+test("start is sent on every attempt", () => {
+  const t = fakeTest("a.spec.ts", 1);
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [t] });
+    r.onTestBegin(t, { retry: 0 });
+    r.onTestBegin(t, { retry: 1 });
+  });
+  assert.deepEqual(events.slice(2), [{ ev: "start", f: "a.spec.ts" }, { ev: "start", f: "a.spec.ts" }]);
+});
+
+test("each status's final attempt sends one test line", () => {
+  const cases = [["passed", "pass"], ["skipped", "skip"], ["failed", "fail"], ["timedOut", "fail"], ["interrupted", "skip"]];
+  for (const [status, outcome] of cases) {
+    const t = fakeTest("a.spec.ts");
+    const events = run((r) => {
+      r.onBegin({ rootDir: ROOT }, { allTests: () => [t] });
+      r.onTestEnd(t, { status, retry: 0 });
+    });
+    assert.deepEqual(events.slice(2), [{ ev: "test", f: "a.spec.ts", o: outcome }], status);
+  }
+});
+
+test("a test.fail() test passes when it fails and fails when it passes, retried only on the unexpected", () => {
+  const cases = [[0, ["failed"], "pass"], [0, ["passed"], "fail"], [1, ["failed"], "pass"], [1, ["passed", "failed"], "pass"],
+    [1, ["passed", "passed"], "fail"]];
+  for (const [retries, statuses, outcome] of cases) {
+    const t = fakeTest("a.spec.ts", retries, "failed");
+    const events = run((r) => {
+      r.onBegin({ rootDir: ROOT }, { allTests: () => [t] });
+      statuses.forEach((status, retry) => r.onTestEnd(t, { status, retry }));
+    });
+    assert.deepEqual(events.slice(2), [{ ev: "test", f: "a.spec.ts", o: outcome }], `${retries} ${statuses}`);
+  }
+});
+
+test("an interrupted test is final even with retries left, and a neutral dot: Playwright counts it apart", () => {
+  const t = fakeTest("a.spec.ts", 2);
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [t] });
+    r.onTestEnd(t, { status: "interrupted", retry: 0 });
+  });
+  assert.deepEqual(events.slice(2), [{ ev: "test", f: "a.spec.ts", o: "skip" }]);
+});
+
+test("serial mode's mass skip waits for the retry, so a skipped test's real result is the one sent", () => {
+  const [a, b, c] = [fakeTest("a.spec.ts", 1), fakeTest("a.spec.ts", 1), fakeTest("a.spec.ts", 1)];
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [a, b, c] });
+    r.onTestEnd(a, { status: "passed", retry: 0 });
+    r.onTestEnd(b, { status: "failed", retry: 0 });
+    r.onTestEnd(c, { status: "skipped", retry: 0 });     // skipped by B's failure, expected to pass
+    r.onTestEnd(a, { status: "passed", retry: 1 });
+    r.onTestEnd(b, { status: "passed", retry: 1 });
+    r.onTestEnd(c, { status: "failed", retry: 1 });
+  });
+  assert.deepEqual(events.slice(2).map((e) => e.o), ["pass", "pass", "fail"]);
+});
+
+test("an unexpected skip on the last attempt, or a test.skip(), is final", () => {
+  const late = fakeTest("a.spec.ts", 1);
+  const skipped = fakeTest("a.spec.ts", 2, "skipped");
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [late, skipped] });
+    r.onTestEnd(late, { status: "skipped", retry: 1 });
+    r.onTestEnd(skipped, { status: "skipped", retry: 0 });
+  });
+  assert.deepEqual(events.slice(2).map((e) => e.o), ["skip", "skip"]);
+});
+
+test("with markers off it prints nothing", () => {
+  const t = fakeTest("a.spec.ts");
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [t] });
+    r.onTestBegin(t, { retry: 0 });
+    r.onTestEnd(t, { status: "passed", retry: 0 });
+    r.onEnd({ status: "passed", duration: 1 });
+  }, { JUNEAU_RUN_PLAYWRIGHT_MARKERS: "0" });
+  assert.deepEqual(events, []);
+});
+
+test("serial mode's rerun of a passed test sends no second test line", () => {
+  const first = fakeTest("a.spec.ts", 1);
+  const second = fakeTest("a.spec.ts", 1);
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [first, second] });
+    r.onTestEnd(first, { status: "passed", retry: 0 });
+    r.onTestEnd(second, { status: "failed", retry: 0 });
+    r.onTestEnd(first, { status: "passed", retry: 1 });
+    r.onTestEnd(second, { status: "passed", retry: 1 });
+  });
+  assert.deepEqual(events.slice(2), [{ ev: "test", f: "a.spec.ts", o: "pass" }, { ev: "test", f: "a.spec.ts", o: "pass" }]);
+});
+
+test("an empty suite begins with no files", () => {
+  const events = run((r) => r.onBegin({ rootDir: ROOT }, { allTests: () => [] }));
+  assert.deepEqual(events, [{ ev: "hello", v: 1 }, { ev: "begin", f: {} }]);
+});
+
+test("a test with no location has an empty file name, which juneau_run.py draws no line for", () => {
+  const t = { id: "x", retries: 0, expectedStatus: "passed" };
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [t] });
+    r.onTestBegin(t, { retry: 0 });
+    r.onTestEnd(t, { status: "passed", retry: 0 });
+  });
+  assert.deepEqual(events.slice(1), [{ ev: "begin", f: { "": 1 } }, { ev: "start", f: "" }, { ev: "test", f: "", o: "pass" }]);
+});
+
+test("a failed attempt with a retry to come sends nothing, so a flaky test draws one pass", () => {
+  const t = fakeTest("a.spec.ts", 2);
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [t] });
+    r.onTestEnd(t, { status: "failed", retry: 0 });
+    r.onTestEnd(t, { status: "timedOut", retry: 1 });
+    r.onTestEnd(t, { status: "passed", retry: 2 });
+  });
+  assert.deepEqual(events.slice(2), [{ ev: "test", f: "a.spec.ts", o: "pass" }]);
+});
+
+test("the last retry's failure is final", () => {
+  const t = fakeTest("a.spec.ts", 1);
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [t] });
+    r.onTestEnd(t, { status: "failed", retry: 0 });
+    r.onTestEnd(t, { status: "failed", retry: 1 });
+  });
+  assert.deepEqual(events.slice(2), [{ ev: "test", f: "a.spec.ts", o: "fail" }]);
+});
+
+test("at the end, a test still waiting for its retry sends its outcome, so its file closes with it", () => {
+  // --max-failures counts only last attempts, so the run can stop with a retry queued; Ctrl-C and the global timeout too
+  const outcomeOf = (t, outcome, statuses) => Object.assign(t, { outcome: () => outcome,
+    results: statuses.map((status, retry) => ({ status, retry })) });
+  const queued = outcomeOf(fakeTest("a.spec.ts", 1), "unexpected", ["failed"]);
+  const last = outcomeOf(fakeTest("b.spec.ts", 0), "unexpected", ["failed"]);
+  const massSkipped = outcomeOf(fakeTest("c.spec.ts", 1), "skipped", ["skipped"]);
+  const neverRan = outcomeOf(fakeTest("d.spec.ts", 1), "skipped", []);
+  const events = run((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [queued, last, massSkipped, neverRan] });
+    r.onTestEnd(queued, queued.results[0]);
+    r.onTestEnd(last, last.results[0]);
+    r.onTestEnd(massSkipped, massSkipped.results[0]);
+    r.onEnd({ status: "failed", duration: 1 });
+  });
+  assert.deepEqual(events.slice(2), [{ ev: "test", f: "b.spec.ts", o: "fail" }, { ev: "test", f: "a.spec.ts", o: "fail" },
+    { ev: "test", f: "c.spec.ts", o: "skip" }]);
+});
+
+test("the reporter says it doesn't print, so Playwright still adds its own line reporter to an html-only config", () => {
+  assert.equal(new Reporter({ write: () => {} }).printsToStdio(), false);
+});
+
+// The Java report reader's fixture is this reporter's report of tree(), so the two can't drift apart.
+const FIXTURE = path.join(__dirname, "..", "..", "..", "..", "juneau-rest", "juneau-run-report", "src", "test", "resources",
+  "org", "apache", "juneau", "rest", "server", "runreport", "playwright-juneau-run.json");
+const BOOM = "\u001b[31mboom\u001b[39m\nat a.spec.ts:11"; // Playwright colours its error messages
+
+// A suite tree as Playwright's reporter API gives it: root, project, file and describe suites, and finished tests.
+function tree() {
+  const project = (name) => ({ type: "project", title: name, project: () => ({ name }) });
+  const chromium = project("chromium");
+  const firefox = project("firefox");
+  const suite = (type, title, parent) => ({ type, title, parent, project: parent.project });
+  const done = (parent, title, line, outcome, results, expectedStatus = "passed") => ({
+    id: `t${++ids}`, title, parent, retries: 1, expectedStatus, outcome: () => outcome, results,
+    location: { file: path.join(ROOT, "tests", "a.spec.ts"), line, column: 5 },
+  });
+  const fileC = suite("file", "tests/a.spec.ts", chromium);
+  const loginC = suite("describe", "Login", fileC);
+  const fileF = suite("file", "tests/a.spec.ts", firefox);
+  const tests = [
+    done(loginC, "works", 3, "expected", [{ retry: 0, status: "passed", duration: 40 }]),
+    done(loginC, "flaky one", 7, "flaky", [{ retry: 0, status: "failed", duration: 30, error: { message: "first try" } },
+      { retry: 1, status: "passed", duration: 50 }]),
+    done(fileF, "top", 11, "unexpected", [{ retry: 0, status: "failed", duration: 5, error: { message: BOOM } },
+      { retry: 1, status: "timedOut", duration: 9, error: { message: "Test timeout of 9ms exceeded." } }]),
+    done(fileC, "top", 11, "skipped", [{ retry: 0, status: "skipped", duration: 0 }], "skipped"),
+  ];
+  return { allTests: () => tests };
+}
+
+function reportOf(fn) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "juneau-run-pw-"));
+  try {
+    const file = path.join(dir, "out", "pw-playwright.json");
+    const errors = [];
+    const r = new Reporter({ write: () => {}, stderr: (text) => errors.push(text), env: { JUNEAU_RUN_PLAYWRIGHT_JSON: file } });
+    fn(r);
+    return { report: fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null, errors, file };
+  } finally {
+    fs.rmSync(dir, { recursive: true, force: true });
+  }
+}
+
+test("onEnd writes a JSON report in the json reporter's shape: file and describe suites, specs, tests and results", () => {
+  const { report, errors } = reportOf((r) => {
+    r.onBegin({ rootDir: ROOT, version: "1.62.1" }, tree());
+    r.onEnd({ status: "failed", duration: 1234 });
+  });
+  assert.deepEqual(errors, []);
+  // the fixture holds the full shape: two projects, a describe block, and expected, flaky, unexpected and skipped tests
+  assert.deepEqual({ ...report, config: { ...report.config, rootDir: "/repo" } }, JSON.parse(fs.readFileSync(FIXTURE, "utf8")));
+});
+
+test("a global error goes in the report's errors", () => {
+  const { report } = reportOf((r) => {
+    r.onBegin({ rootDir: ROOT }, { allTests: () => [] });
+    r.onError({ message: "global setup failed" });
+    r.onEnd({ status: "failed", duration: 2 });
+  });
+  assert.deepEqual(report.errors, [{ message: "global setup failed" }]);
+});
+
+test("no report is written without a path", () => {
+  const r = new Reporter({ write: () => {}, env: {} });
+  r.onBegin({ rootDir: ROOT }, tree());
+  assert.equal(r.onEnd({ status: "passed", duration: 1 }), undefined);
+});
+
+test("a report that can't be written is said once on stderr and doesn't throw", () => {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "juneau-run-pw-"));
+  try {
+    const blocker = path.join(dir, "file");
+    fs.writeFileSync(blocker, "");
+    const errors = [];
+    const r = new Reporter({ write: () => {}, stderr: (text) => errors.push(text),
+      env: { JUNEAU_RUN_PLAYWRIGHT_JSON: path.join(blocker, "pw.json") } });
+    r.onBegin({ rootDir: ROOT }, tree());
+    r.onEnd({ status: "passed", duration: 1 });
+    assert.equal(errors.length, 1);
+    assert.match(errors[0], /^juneau-run: could not write the Playwright report .*pw\.json: /);
+  } finally {
+    fs.rmSync(dir, { recursive: true, force: true });
+  }
+});
diff --git a/juneau-run/src/test/python/scripts_support.py b/juneau-run/src/test/python/scripts_support.py
index 5538b44568..9eea4baa43 100644
--- a/juneau-run/src/test/python/scripts_support.py
+++ b/juneau-run/src/test/python/scripts_support.py
@@ -44,8 +44,9 @@ def package_files():
 
 
 def build_zip(path, *, init=True):
-    """The assembly's layout, built with zipfile: juneau_scripts/** plus bootstrap.py, LICENSE and NOTICE at the root, and
-    pytest_plugin/juneau_run_pytest.py.  init=False leaves out juneau_scripts/__init__.py."""
+    """The assembly's layout, built with zipfile: juneau_scripts/** plus bootstrap.py, LICENSE and NOTICE at the root,
+    pytest_plugin/juneau_run_pytest.py and playwright_reporter/juneau_run_playwright.cjs.  init=False leaves out
+    juneau_scripts/__init__.py."""
     with zipfile.ZipFile(path, "w") as z:
         for rel in package_files():
             if not init and rel == "juneau_scripts/__init__.py":
@@ -53,6 +54,8 @@ def build_zip(path, *, init=True):
             z.write(PYTHON_SRC / rel, rel)
         z.write(BOOTSTRAP, "bootstrap.py")
         z.write(PYTHON_SRC / "pytest_plugin" / "juneau_run_pytest.py", "pytest_plugin/juneau_run_pytest.py")
+        z.write(MODULE_ROOT / "src" / "main" / "js" / "playwright_reporter" / "juneau_run_playwright.cjs",
+                "playwright_reporter/juneau_run_playwright.cjs")
         for name in ("LICENSE", "NOTICE"):
             z.write(MODULE_ROOT.parent / name, name)
     return Path(path)
diff --git a/juneau-run/src/test/python/test_bus.py b/juneau-run/src/test/python/test_bus.py
index 6e3df16ba8..4806d7b7a9 100644
--- a/juneau-run/src/test/python/test_bus.py
+++ b/juneau-run/src/test/python/test_bus.py
@@ -150,14 +150,16 @@ class DispatcherBusTest(unittest.TestCase):
             ("failure", "boom", "tests"),
         ])
         self.assertEqual(self.rec.kinds(), [
-            "module_start", "reactor", "class_done", "module_compiled", "module_tested", "tests", "module_end",
+            "unit_start", "reactor", "unit_dot", "module_compiled", "module_tested", "tests", "unit_end",
             "failure"])
-        self.assertEqual(self.rec.of("module_start")[0], {"step": "tests", "module": "tests/a", "title": "Mod A"})
+        self.assertEqual(self.rec.of("unit_start")[0], {"step": "tests", "id": "tests/a", "title": "Mod A", "src": "sub"})
         self.assertEqual(self.rec.of("reactor")[0], {"step": "tests", "size": 3, "poms": ["tests/parent"], "tests": True})
-        self.assertEqual(self.rec.of("class_done")[0], {"step": "tests", "module": "tests/a", "outcome": "pass"})
+        self.assertEqual(self.rec.of("unit_dot")[0], {"step": "tests", "id": "tests/a", "outcome": "pass",
+                                                         "src": "sub"})
         self.assertEqual(self.rec.of("module_compiled")[0], {"step": "tests", "module": "tests/a", "ok": True})
-        self.assertEqual(self.rec.of("module_end")[0],
-                         {"step": "tests", "module": "tests/a", "status": "ok", "ms": 1200, "totals": (5, 0, 0, 1)})
+        self.assertEqual(self.rec.of("unit_end")[0],
+                         {"step": "tests", "id": "tests/a", "status": "ok", "ms": 1200, "totals": (5, 0, 0, 1),
+                          "src": "sub"})
         self.assertEqual(self.rec.of("failure")[0], {"text": "boom", "step": "tests"})
 
     def test_progress_publishes_step_progress_and_emits_no_marker(self):
@@ -183,12 +185,24 @@ class DispatcherBusTest(unittest.TestCase):
                          {"step": "s1", "name": "surefire_s", "sub": "core", "value": 4.5})
         self.assertEqual(out.getvalue(), "")      # never a ##run marker
 
-    def test_close_children_publishes_module_end_for_each_open_module(self):
+    def test_unit_tuples_publish_bus_unit_events_under_the_parsers_step_and_no_marker(self):
+        os.environ["RUN_MARKERS"] = "1"
+        out = io.StringIO()
+        with contextlib.redirect_stdout(out):
+            self.d.apply([("unit_start", "a.py", "a.py", "file"), ("unit_dot", "a.py", "fail"),
+                          ("unit_end", "a.py", "fail", "file")])
+        self.assertEqual(self.rec.kinds(), ["unit_start", "unit_dot", "unit_end"])
+        self.assertEqual(self.rec.of("unit_start")[0], {"step": "tests", "id": "a.py", "title": "a.py", "src": "file"})
+        self.assertEqual(self.rec.of("unit_dot")[0], {"step": "tests", "id": "a.py", "outcome": "fail"})
+        self.assertEqual(self.rec.of("unit_end")[0], {"step": "tests", "id": "a.py", "status": "fail", "src": "file"})
+        self.assertEqual(out.getvalue(), "")      # a file is a line, never a child step
+
+    def test_close_children_publishes_unit_end_for_each_open_module(self):
         self.d.apply([("sub_start", "tests/a", 1, "Mod A"), ("sub_start", "tests/b", 2, "Mod B"),
                       ("tests", "tests/b", 3, 1, 0, 0)])
         self.d.close_children("fail")
-        self.assertEqual([(f["module"], f["status"], f["totals"]) for f in self.rec.of("module_end")],
-                         [("tests/b", "fail", (3, 1, 0, 0)), ("tests/a", "fail", None)])
+        self.assertEqual([(f["id"], f["status"], f["totals"], f["src"]) for f in self.rec.of("unit_end")],
+                         [("tests/b", "fail", (3, 1, 0, 0), "sub"), ("tests/a", "fail", None, "sub")])
 
 
 if __name__ == "__main__":
diff --git a/juneau-run/src/test/python/test_history.py b/juneau-run/src/test/python/test_history.py
index 0ef2118104..33def4bf47 100644
--- a/juneau-run/src/test/python/test_history.py
+++ b/juneau-run/src/test/python/test_history.py
@@ -267,13 +267,13 @@ class RecordTest(HistoryEnv, unittest.TestCase):
         self.assertRegex(r["ts"], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$")
         self.assertAlmostEqual(r["wall_s"], 101.4)
 
-    def test_maven_modules_counted_from_reactor_and_module_end(self):
+    def test_maven_modules_counted_from_reactor_and_unit_end(self):
         self.start()
         self.pub("reactor", step="mvn", size=4, poms=["mvn/parent"], tests=True)
-        self.pub("module_end", step="mvn", module="mvn/parent", status="ok", ms=100, totals=None)
-        self.pub("module_end", step="mvn", module="mvn/a", status="ok", ms=2500, totals=None)
-        self.pub("module_end", step="mvn", module="mvn/b", status="ok", ms=1500, totals=None)
-        self.pub("module_end", step="mvn", module="mvn/c", status="ok", ms=500, totals=None)
+        self.pub("unit_end", src="sub", step="mvn", id="mvn/parent", status="ok", ms=100, totals=None)
+        self.pub("unit_end", src="sub", step="mvn", id="mvn/a", status="ok", ms=2500, totals=None)
+        self.pub("unit_end", src="sub", step="mvn", id="mvn/b", status="ok", ms=1500, totals=None)
+        self.pub("unit_end", src="sub", step="mvn", id="mvn/c", status="ok", ms=500, totals=None)
         self.pub("step_end", id="mvn", status="ok", ms=5000)
         r = self.read()[-1]
         self.assertEqual(r["units"], {"kind": "modules", "total": 3, "done": 3})
@@ -282,17 +282,25 @@ class RecordTest(HistoryEnv, unittest.TestCase):
         self.assertEqual(len(r["subs"]), 4)
         self.assertTrue(all("/" not in k for k in r["subs"]))
 
-    def test_gradle_tasks_units_from_module_end(self):
+    def test_gradle_tasks_units_from_unit_end(self):
         self.start(id="g", tool="gradle")
-        self.pub("module_end", step="g", module="g/a", status="ok", ms=1000, totals=None)
-        self.pub("module_end", step="g", module="g/b", status="ok", ms=1000, totals=None)
+        self.pub("unit_end", src="sub", step="g", id="g/a", status="ok", ms=1000, totals=None)
+        self.pub("unit_end", src="sub", step="g", id="g/b", status="ok", ms=1000, totals=None)
         self.pub("step_end", id="g", status="ok", ms=3000)
         self.assertEqual(self.read()[-1]["units"], {"kind": "tasks", "total": 2, "done": 2})
 
+    def test_file_and_script_units_are_not_recorded(self):
+        self.start()
+        self.pub("unit_end", src="file", step="mvn", id="tests/test_a.py", status="ok", ms=1000)
+        self.pub("unit_end", src="script", step="mvn", id="identity", status="ok", ms=1000)
+        self.pub("unit_end", src="row", step="mvn", id="mvn/sub", status="ok", ms=1000)
+        self.pub("step_end", id="mvn", status="ok", ms=3000)
+        self.assertEqual(self.read()[-1].get("subs", {}), {})
+
     def test_subs_capped_at_400(self):
         self.start()
         for i in range(450):
-            self.pub("module_end", step="mvn", module=f"mvn/m{i}", status="ok", ms=1000, totals=None)
+            self.pub("unit_end", src="sub", step="mvn", id=f"mvn/m{i}", status="ok", ms=1000, totals=None)
         self.pub("step_end", id="mvn", status="ok", ms=1000)
         self.assertEqual(len(self.read()[-1].get("subs", {})), 400)
 
diff --git a/juneau-run/src/test/python/test_injection.py b/juneau-run/src/test/python/test_injection.py
index dfaf74f0a9..6faf22d433 100644
--- a/juneau-run/src/test/python/test_injection.py
+++ b/juneau-run/src/test/python/test_injection.py
@@ -169,6 +169,30 @@ class InjectionTest(unittest.TestCase):
         self.assertEqual(where, f"{zpath}{os.sep}pytest_plugin")
         self._assert_plugin_only(where)
 
+    def test_finds_the_playwright_reporter_in_the_source_tree_and_extracts_it_from_a_zip_once(self):
+        source = support.MODULE_ROOT / "src" / "main" / "js" / "playwright_reporter" / "juneau_run_playwright.cjs"
+        self.assertEqual(jr.find_playwright_reporter(), str(source))
+        from scripts_support import build_zip
+        tmp = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp, ignore_errors=True)
+        zpath = build_zip(Path(tmp) / "t.zip")
+        registered = []
+        with mock.patch.object(jr, "_engine_dir", return_value=Path("/nowhere")), \
+             mock.patch.object(jr, "_playwright_extracted", None), \
+             mock.patch.object(jr.atexit, "register", side_effect=lambda *a, **k: registered.append((a, k))), \
+             mock.patch.dict(sys.modules, {"juneau_scripts": types.SimpleNamespace(__file__=f"{zpath}/juneau_scripts/__init__.py")}):
+            first = jr.find_playwright_reporter()
+            self.assertEqual(jr.find_playwright_reporter(), first)
+        self.addCleanup(shutil.rmtree, str(Path(first).parent), ignore_errors=True)
+        self.assertEqual(Path(first).read_bytes(), source.read_bytes())
+        self.assertTrue(Path(first).parent.name.startswith("juneau-run-playwright-"))
+        self.assertEqual(registered, [((shutil.rmtree, str(Path(first).parent)), {"ignore_errors": True})])
+
+    def test_no_playwright_reporter_standalone(self):
+        with mock.patch.object(jr, "_engine_dir", return_value=Path("/nowhere")), \
+             mock.patch.dict(sys.modules, {"juneau_scripts": types.SimpleNamespace(__file__="/x/juneau_scripts/__init__.py")}):
+            self.assertIsNone(jr.find_playwright_reporter())
+
     @unittest.skipUnless(shutil.which("uv"), "uv not installed")
     def test_real_default_launcher_accepts_the_plugin(self):
         tmp = tempfile.mkdtemp()
diff --git a/juneau-run/src/test/python/test_markers.py b/juneau-run/src/test/python/test_markers.py
index 02449e7c40..aa76bd4da5 100644
--- a/juneau-run/src/test/python/test_markers.py
+++ b/juneau-run/src/test/python/test_markers.py
@@ -108,6 +108,102 @@ class MarkersTest(unittest.TestCase):
                     raise RuntimeError("boom")
         self.assertEqual(parse(capture(body))[-1], {"ev": "end", "id": "commit", "status": "fail", "ms": 0})
 
+    def test_unit_is_a_child_step_marker_and_one_bus_pair(self):
+        seen = []
+        jr.subscribe(lambda kind, fields: seen.append((kind, dict(fields))))
+        def body():
+            with jr.step("prechecks", 1, "Prechecks") as s:
+                with s.unit("Whitespace check", "Whitespace check") as u:
+                    u.dot("pass")
+        self.assertEqual(parse(capture(body)), [
+            {"ev": "step", "id": "prechecks", "n": 1, "title": "Prechecks"},
+            {"ev": "step", "id": "prechecks/whitespace-check", "n": 1, "title": "Whitespace check",
+             "parent": "prechecks"},
+            {"ev": "end", "id": "prechecks/whitespace-check", "status": "ok", "ms": 0},
+            {"ev": "end", "id": "prechecks", "status": "ok", "ms": 0}])
+        units = [(k, f) for k, f in seen if k.startswith("unit_")]
+        self.assertEqual([k for k, _ in units], ["unit_start", "unit_dot", "unit_end"])
+        self.assertEqual(units[0][1], {"step": "prechecks", "id": "Whitespace check", "title": "Whitespace check",
+                                       "src": "script"})
+        self.assertEqual({k: v for k, v in units[2][1].items() if k != "ms"},
+                         {"step": "prechecks", "id": "Whitespace check", "status": "ok", "src": "script"})
+        self.assertEqual([k for k, _ in seen].count("step_start"), 1)     # not a sub-step
+
+    def test_unit_fail_dot_and_fail_end_it_fail(self):
+        def body():
+            with jr.step("s", 1, "S") as s:
+                with s.unit("a", "A") as u:
+                    u.dot("fail")
+                with s.unit("b", "B") as u:
+                    u.fail()
+        ends = [m for m in parse(capture(body)) if m["ev"] == "end"]
+        self.assertEqual([(m["id"], m["status"]) for m in ends], [("s/a", "fail"), ("s/b", "fail"), ("s", "ok")])
+
+    def test_unit_exception_ends_it_fail_and_reraises(self):
+        def body():
+            with self.assertRaises(RuntimeError):
+                with jr.step("s", 1, "S") as s:
+                    with s.unit("a", "A"):
+                        raise RuntimeError("boom")
+        ends = [m for m in parse(capture(body)) if m["ev"] == "end"]
+        self.assertEqual([(m["id"], m["status"]) for m in ends], [("s/a", "fail"), ("s", "fail")])
+
+    def test_unit_ids_stay_within_64_characters_and_a_cut_collision_gets_a_number(self):
+        row = "r" * 40
+        def body():
+            with jr.step(row, 1, "R") as s:
+                for i in range(3):
+                    with s.unit("x" * 30 + str(i), "X"):
+                        pass
+        ids = [m["id"] for m in parse(capture(body)) if m["ev"] == "step"][1:]
+        self.assertEqual(ids, [f"{row}/{'x' * 23}", f"{row}/{'x' * 21}-2", f"{row}/{'x' * 21}-3"])
+        self.assertTrue(all(len(i) <= 64 for i in ids))
+
+    def test_unit_ids_stay_within_64_characters_when_the_row_leaves_one_to_three(self):
+        for size in (60, 61, 62):      # room 3, 2, 1
+            with self.subTest(size=size):
+                row = "r" * size
+                def body():
+                    with jr.step(row, 1, "R") as s:
+                        for i in range(3):
+                            with s.unit("abc" + str(i), "X"):
+                                pass
+                ids = [m["id"] for m in parse(capture(body)) if m["ev"] == "step"][1:]
+                self.assertTrue(all(len(i) <= 64 for i in ids), ids)
+                self.assertEqual(len(set(ids)), 3)
+
+    def test_a_row_with_room_for_one_character_raises_once_its_ids_run_out(self):
+        def body():
+            with jr.step("r" * 62, 1, "R") as s:
+                for i in range(9):
+                    with s.unit(f"a{i}", "A"):
+                        pass
+                with self.assertRaises(ValueError):
+                    with s.unit("a9", "A"):
+                        pass
+        ids = [m["id"] for m in parse(capture(body)) if m["ev"] == "step"][1:]
+        self.assertEqual([i.split("/")[1] for i in ids], ["a", "2", "3", "4", "5", "6", "7", "8", "9"])
+
+    def test_a_duplicate_open_unit_id_raises(self):
+        def body():
+            with jr.step("s", 1, "S") as s:
+                with s.unit("a", "A"):
+                    with self.assertRaises(ValueError):
+                        with s.unit("a", "A again"):
+                            pass
+                with s.unit("a", "A again"):       # closed: it may open again, as a new child step
+                    pass
+        steps = [m["id"] for m in parse(capture(body)) if m["ev"] == "step"]
+        self.assertEqual(steps, ["s", "s/a", "s/a-2"])
+
+    def test_a_row_id_of_63_characters_leaves_no_room_for_a_unit(self):
+        def body():
+            with jr.step("r" * 63, 1, "R") as s:
+                with self.assertRaises(ValueError):
+                    with s.unit("a", "A"):
+                        pass
+        capture(body)
+
     def test_step_works_when_disabled(self):
         with mock.patch.dict(os.environ):
             os.environ.pop("RUN_MARKERS")
diff --git a/juneau-run/src/test/python/test_maven_setup_row.py b/juneau-run/src/test/python/test_maven_setup_row.py
index 8858cc77c4..a600496943 100644
--- a/juneau-run/src/test/python/test_maven_setup_row.py
+++ b/juneau-run/src/test/python/test_maven_setup_row.py
@@ -29,8 +29,8 @@ def setup_events(poms=3, total=5, step="mvn"):
     events = [("step_start", dict(id=step, n=1, title="mvn", parent=None)),
               ("reactor", dict(step=step, size=total, poms=ids, tests=False))]
     for pid in ids:
-        events += [("module_start", dict(step=step, module=pid, title=pid)),
-                   ("module_end", dict(step=step, module=pid, status="ok", ms=1))]
+        events += [("unit_start", dict(step=step, id=pid, title=pid)),
+                   ("unit_end", dict(step=step, id=pid, status="ok", ms=1))]
     events.append(("step_end", dict(id=step, status="ok", ms=2000, exit=0, summary=None, totals=None)))
     return events
 
@@ -62,17 +62,17 @@ class SetupRowTest(unittest.TestCase):
     def test_k_over_n_counts_real_modules_only(self):
         b, ids = self.board(poms=2, total=5)
         for i, pid in enumerate(ids):                       # poms end first
-            b.apply("module_start", dict(step="mvn", module=pid, title=pid))
-            b.apply("module_end", dict(step="mvn", module=pid, status="ok", ms=1))
+            b.apply("unit_start", dict(step="mvn", id=pid, title=pid))
+            b.apply("unit_end", dict(step="mvn", id=pid, status="ok", ms=1))
         self.assertEqual(b.rows["mvn"].units_done, 0)
-        b.apply("module_start", dict(step="mvn", module="core", title="Core"))
-        b.apply("module_end", dict(step="mvn", module="core", status="ok", ms=1))
+        b.apply("unit_start", dict(step="mvn", id="core", title="Core"))
+        b.apply("unit_end", dict(step="mvn", id="core", status="ok", ms=1))
         self.assertEqual(b.rows["mvn"].units_done, 1)
 
     def test_setup_row_boxes_turn_green(self):
         b, ids = self.board(poms=3, total=5)
-        b.apply("module_start", dict(step="mvn", module=ids[0], title="p"))
-        b.apply("module_end", dict(step="mvn", module=ids[0], status="ok", ms=1))
+        b.apply("unit_start", dict(step="mvn", id=ids[0], title="p"))
+        b.apply("unit_end", dict(step="mvn", id=ids[0], status="ok", ms=1))
         s = b.setup
         self.assertEqual((s.total, s.done), (3, 1))
         self.assertEqual(jr.setup_text(s), "3 pom modules")
@@ -109,8 +109,8 @@ class SetupRowTest(unittest.TestCase):
 
     def test_gray_cell_for_real_module_without_tests_stays(self):
         b, _ = self.board(poms=0, total=2)
-        b.apply("module_start", dict(step="mvn", module="jar1", title="J"))
-        b.apply("module_end", dict(step="mvn", module="jar1", status="skip", ms=1))
+        b.apply("unit_start", dict(step="mvn", id="jar1", title="J"))
+        b.apply("unit_end", dict(step="mvn", id="jar1", status="skip", ms=1))
         self.assertEqual(b.rows["mvn"].units_done, 1)
         self.assertEqual(b.rows["mvn"].unit_cells[0], jr.UNIT_SKIP)
 
@@ -119,21 +119,21 @@ class SetupRowTest(unittest.TestCase):
         self.assertEqual(b.rows["mvn"].units_total, 0)
         self.assertEqual((b.setup.total, b.rows["mvn"].unit_kind), (3, "modules"))      # no fall back to the old bar
 
-    def test_compile_mode_unit_advances_on_module_compiled_tests_mode_on_module_end(self):
+    def test_compile_mode_unit_advances_on_module_compiled_tests_mode_on_unit_end(self):
         b, _ = self.board(poms=0, total=2)                    # the board() helper sends tests=False: compile mode
-        b.apply("module_start", dict(step="mvn", module="mvn/a", title="A"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/a", title="A"))
         b.apply("module_compiled", dict(step="mvn", module="mvn/a", ok=True))
         self.assertEqual(b.rows["mvn"].units_done, 1)         # today's bar fills on compile in compile mode
-        b.apply("module_end", dict(step="mvn", module="mvn/a", status="ok", ms=1, totals=None))
+        b.apply("unit_end", dict(step="mvn", id="mvn/a", status="ok", ms=1, totals=None))
         self.assertEqual(b.rows["mvn"].units_done, 1)         # and the end does not count it twice
 
-    def test_tests_mode_unit_advances_on_module_end_only(self):
+    def test_tests_mode_unit_advances_on_unit_end_only(self):
         b, _ = self.board(poms=0, total=2)
         b.apply("reactor", dict(step="mvn", size=2, poms=[], tests=True))
-        b.apply("module_start", dict(step="mvn", module="mvn/a", title="A"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/a", title="A"))
         b.apply("module_compiled", dict(step="mvn", module="mvn/a", ok=True))
         self.assertEqual(b.rows["mvn"].units_done, 0)
-        b.apply("module_end", dict(step="mvn", module="mvn/a", status="fail", ms=1, totals=None))
+        b.apply("unit_end", dict(step="mvn", id="mvn/a", status="fail", ms=1, totals=None))
         self.assertEqual(b.rows["mvn"].units_done, 1)
         self.assertEqual(b.rows["mvn"].unit_cells[0], jr.UNIT_FAIL)
 
@@ -147,7 +147,7 @@ class SetupRowTest(unittest.TestCase):
     def test_region_budget_takes_the_setup_line_off_the_sub_lines(self):
         b, _ = self.board(poms=2, total=40)
         for i in range(30):
-            b.apply("module_start", dict(step="mvn", module=f"m{i}", title=f"M{i}"))
+            b.apply("unit_start", dict(step="mvn", id=f"m{i}", title=f"M{i}"))
         lines = live_region_lines(b, rows=12, cols=100)
         self.assertLessEqual(len(lines), 10)
 
@@ -158,20 +158,20 @@ class SetupRowTest(unittest.TestCase):
 
 
     def test_tests_mode_counts_a_unit_when_its_tests_finish_not_only_at_the_summary(self):
-        b, _ = self.board(poms=0, total=2)                    # -T: module_end can wait for the Reactor Summary
+        b, _ = self.board(poms=0, total=2)                    # -T: unit_end can wait for the Reactor Summary
         b.apply("reactor", dict(step="mvn", size=2, poms=[], tests=True))
-        b.apply("module_start", dict(step="mvn", module="mvn/a", title="A"))
-        b.apply("class_done", dict(step="mvn", module="mvn/a", outcome="pass"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/a", title="A"))
+        b.apply("unit_dot", dict(step="mvn", id="mvn/a", outcome="pass"))
         b.apply("module_tested", dict(step="mvn", module="mvn/a"))
         self.assertEqual(b.rows["mvn"].units_done, 1)
         self.assertEqual(b.rows["mvn"].unit_cells[0], jr.UNIT_PASS)
-        b.apply("module_end", dict(step="mvn", module="mvn/a", status="ok", ms=1, totals=None))
+        b.apply("unit_end", dict(step="mvn", id="mvn/a", status="ok", ms=1, totals=None))
         self.assertEqual(b.rows["mvn"].units_done, 1)         # counted once
 
     def test_tested_failure_and_a_module_without_tests(self):
         b, _ = self.board(poms=0, total=3)
         b.apply("reactor", dict(step="mvn", size=3, poms=[], tests=True))
-        b.apply("class_done", dict(step="mvn", module="mvn/a", outcome="fail"))
+        b.apply("unit_dot", dict(step="mvn", id="mvn/a", outcome="fail", src="sub"))     # the class_done translation
         b.apply("module_tested", dict(step="mvn", module="mvn/a"))
         b.apply("module_tested", dict(step="mvn", module="mvn/b"))
         r = b.rows["mvn"]
@@ -191,15 +191,15 @@ class SetupRowTest(unittest.TestCase):
         b, r = self.mk_tests_board(4)
         self.assertEqual(self.bar(r), jr.PENDING * 4)
         for m in ("a", "b"):                    # upstream modules from --also-make: compile only, no tests
-            b.apply("module_start", dict(step="mvn", module=f"mvn/{m}", title=m))
+            b.apply("unit_start", dict(step="mvn", id=f"mvn/{m}", title=m))
             b.apply("module_tested", dict(step="mvn", module=f"mvn/{m}"))
-            b.apply("module_end", dict(step="mvn", module=f"mvn/{m}", status="ok", ms=1, totals=None))
+            b.apply("unit_end", dict(step="mvn", id=f"mvn/{m}", status="ok", ms=1, totals=None))
         self.assertEqual((r.units_done, r.units_total, r.units_dropped), (0, 2, 2))    # N shrinks as they drop out
         self.assertEqual(self.bar(r), jr.PENDING * 2)
-        b.apply("module_start", dict(step="mvn", module="mvn/c", title="c"))
-        b.apply("class_done", dict(step="mvn", module="mvn/c", outcome="pass"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/c", title="c"))
+        b.apply("unit_dot", dict(step="mvn", id="mvn/c", outcome="pass"))
         b.apply("module_tested", dict(step="mvn", module="mvn/c"))
-        b.apply("module_end", dict(step="mvn", module="mvn/c", status="ok", ms=1, totals=(1, 0, 0, 0)))
+        b.apply("unit_end", dict(step="mvn", id="mvn/c", status="ok", ms=1, totals=(1, 0, 0, 0)))
         self.assertEqual((r.units_done, r.units_total), (1, 2))
         self.assertEqual(self.bar(r), "#" + jr.PENDING)
         _, rest, _ = jr.row_cells(r, jr.PLAIN_GLYPHS, 100.0, False)
@@ -207,52 +207,52 @@ class SetupRowTest(unittest.TestCase):
 
     def test_a_dropped_module_survives_a_reactor_resend(self):
         b, r = self.mk_tests_board(4)
-        b.apply("module_start", dict(step="mvn", module="mvn/a", title="a"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/a", title="a"))
         b.apply("module_tested", dict(step="mvn", module="mvn/a"))
         b.apply("reactor", dict(step="mvn", size=4, poms=[], tests=True))
         self.assertEqual(r.units_total, 3)
 
     def test_a_module_whose_tests_were_all_skipped_still_shows_skip(self):
         b, r = self.mk_tests_board(2)
-        b.apply("module_start", dict(step="mvn", module="mvn/a", title="a"))
-        b.apply("class_done", dict(step="mvn", module="mvn/a", outcome="skip"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/a", title="a"))
+        b.apply("unit_dot", dict(step="mvn", id="mvn/a", outcome="skip"))
         b.apply("module_tested", dict(step="mvn", module="mvn/a"))
-        b.apply("module_end", dict(step="mvn", module="mvn/a", status="ok", ms=1, totals=(2, 0, 0, 2)))
+        b.apply("unit_end", dict(step="mvn", id="mvn/a", status="ok", ms=1, totals=(2, 0, 0, 2)))
         self.assertEqual((r.units_done, r.units_total), (1, 2))
         self.assertEqual(self.bar(r), "." + jr.PENDING)         # skip, not dropped
 
     def test_a_dropped_module_that_fails_counts_after_all(self):
         b, r = self.mk_tests_board(2)
-        b.apply("module_start", dict(step="mvn", module="mvn/a", title="a"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/a", title="a"))
         b.apply("module_tested", dict(step="mvn", module="mvn/a"))
-        b.apply("module_end", dict(step="mvn", module="mvn/a", status="fail", ms=1, totals=None))
+        b.apply("unit_end", dict(step="mvn", id="mvn/a", status="fail", ms=1, totals=None))
         self.assertEqual((r.units_done, r.units_total, r.units_dropped), (1, 2, 0))
         self.assertEqual(self.bar(r), "F" + jr.PENDING)
 
     def test_compile_rows_still_count_every_module(self):
         b, _ = self.board(poms=0, total=3)
         r = b.rows["mvn"]
-        b.apply("module_start", dict(step="mvn", module="mvn/a", title="a"))
-        b.apply("module_end", dict(step="mvn", module="mvn/a", status="ok", ms=1, totals=None))
+        b.apply("unit_start", dict(step="mvn", id="mvn/a", title="a"))
+        b.apply("unit_end", dict(step="mvn", id="mvn/a", status="ok", ms=1, totals=None))
         self.assertEqual((r.units_done, r.units_total, r.units_dropped), (1, 3, 0))
 
     def test_started_modules_are_in_flight_until_they_end(self):
         b, r = self.mk_tests_board(5)
         for m in ("a", "b"):
-            b.apply("module_start", dict(step="mvn", module=f"mvn/{m}", title=m))
+            b.apply("unit_start", dict(step="mvn", id=f"mvn/{m}", title=m))
         self.assertEqual(self.bar(r), "~~" + jr.PENDING * 3)
         self.assertEqual(self.bar(r, jr.NO_COLOR_GLYPHS), "▒▒" + jr.PENDING * 3)
         self.assertEqual(self.bar(r, jr.COLOR_GLYPHS), "\x1b[33m█\x1b[0m" * 2 + jr.PENDING * 3)
-        b.apply("class_done", dict(step="mvn", module="mvn/a", outcome="pass"))
+        b.apply("unit_dot", dict(step="mvn", id="mvn/a", outcome="pass"))
         b.apply("module_tested", dict(step="mvn", module="mvn/a"))
         self.assertEqual(self.bar(r), "#~" + jr.PENDING * 3)        # a finished (counted), b still running
-        b.apply("module_end", dict(step="mvn", module="mvn/a", status="ok", ms=1, totals=(1, 0, 0, 0)))
+        b.apply("unit_end", dict(step="mvn", id="mvn/a", status="ok", ms=1, totals=(1, 0, 0, 0)))
         b.apply("step_end", dict(id="mvn", status="ok", ms=2000, exit=0, summary=None, totals=None))
         self.assertNotIn("~", self.bar(r))                           # a finished row shows no in-flight cells
 
-    def test_a_modules_bar_without_module_start_never_shows_in_flight(self):
+    def test_a_modules_bar_without_unit_start_never_shows_in_flight(self):
         b, r = self.mk_tests_board(3)
-        b.apply("class_done", dict(step="mvn", module="mvn/a", outcome="pass"))    # no module_start: no signal
+        b.apply("unit_dot", dict(step="mvn", id="mvn/a", outcome="pass", src="sub"))    # no unit_start: no signal
         self.assertEqual(self.bar(r), jr.PENDING * 3)
 
     def test_each_unit_keeps_its_own_cell_failed_or_in_flight(self):
@@ -263,15 +263,15 @@ class SetupRowTest(unittest.TestCase):
 
     def test_a_pom_box_turns_green_when_it_starts(self):
         b, ids = self.board(poms=3, total=5)
-        b.apply("module_start", dict(step="mvn", module=ids[1], title="p"))     # -T: its end waits for the summary
+        b.apply("unit_start", dict(step="mvn", id=ids[1], title="p"))     # -T: its end waits for the summary
         self.assertEqual((b.setup.done, b.setup.boxes[ids[1]]), (1, "pass"))
-        b.apply("module_end", dict(step="mvn", module=ids[1], status="fail", ms=1))
+        b.apply("unit_end", dict(step="mvn", id=ids[1], status="fail", ms=1))
         self.assertEqual(b.setup.boxes[ids[1]], "fail")
 
     def test_pom_modules_never_enter_the_row_order(self):
         b, ids = self.board(poms=1, total=3)
-        b.apply("module_start", dict(step="mvn", module=ids[0], title="p"))
-        b.apply("module_end", dict(step="mvn", module=ids[0], status="ok", ms=1))
+        b.apply("unit_start", dict(step="mvn", id=ids[0], title="p"))
+        b.apply("unit_end", dict(step="mvn", id=ids[0], status="ok", ms=1))
         b.apply("step_end", dict(id="mvn", status="ok", ms=2000, exit=0, summary=None, totals=None))
         self.assertEqual(b.rows["mvn"].order, [])
 
@@ -279,18 +279,18 @@ class SetupRowTest(unittest.TestCase):
         b, _ = self.board(poms=0, total=4)
         b.apply("reactor", dict(step="mvn", size=4, poms=["p", "p", "q"], tests=False))
         for pid in ("p", "q"):
-            b.apply("module_end", dict(step="mvn", module=pid, status="ok", ms=1))
+            b.apply("unit_end", dict(step="mvn", id=pid, status="ok", ms=1))
         self.assertEqual((b.setup.total, b.setup.done), (2, 2))
 
     def test_close_of_a_failed_module_by_finish_counts_a_fail_unit(self):
         b, _ = self.board(poms=0, total=2)
         b.apply("reactor", dict(step="mvn", size=2, poms=[], tests=False))
-        b.apply("module_start", dict(step="mvn", module="mvn/a", title="A"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/a", title="A"))
         b.apply("module_compiled", dict(step="mvn", module="mvn/a", ok=False))      # box fail; compile mode counts it
-        b.apply("module_start", dict(step="mvn", module="mvn/b", title="B"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/b", title="B"))
         b.apply("reactor", dict(step="mvn", size=2, poms=[], tests=True))
         b.apply("module_compiled", dict(step="mvn", module="mvn/b", ok=False))      # fail box, not counted in tests mode
-        b.apply("module_end", dict(step="mvn", module="mvn/b", status="skip", ms=1, totals=None))   # finish() closes it
+        b.apply("unit_end", dict(step="mvn", id="mvn/b", status="skip", ms=1, totals=None))   # finish() closes it
         r = b.rows["mvn"]
         self.assertEqual((r.units_done, r.unit_cells[1]), (2, jr.UNIT_FAIL))
 
@@ -306,8 +306,8 @@ class SetupRowTest(unittest.TestCase):
 
     def test_finished_row_with_some_units_keeps_its_bar(self):
         b, _ = self.board(poms=0, total=3)
-        b.apply("module_start", dict(step="mvn", module="mvn/a", title="A"))
-        b.apply("module_end", dict(step="mvn", module="mvn/a", status="ok", ms=1))
+        b.apply("unit_start", dict(step="mvn", id="mvn/a", title="A"))
+        b.apply("unit_end", dict(step="mvn", id="mvn/a", status="ok", ms=1))
         b.apply("step_end", dict(id="mvn", status="fail", ms=5000, exit=1, summary=None, totals=None))
         cells, rest, _ = jr.row_cells(b.rows["mvn"], jr.NO_COLOR_GLYPHS, 100.0, False)
         self.assertIsNotNone(cells)
@@ -326,8 +326,8 @@ class TrailingPomTest(unittest.TestCase):
         return b
 
     def run_module(self, b, module, status="ok"):
-        b.apply("module_start", dict(step="mvn", module=module, title=module))
-        return b.apply("module_end", dict(step="mvn", module=module, status=status, ms=1, totals=None))
+        b.apply("unit_start", dict(step="mvn", id=module, title=module))
+        return b.apply("unit_end", dict(step="mvn", id=module, status=status, ms=1, totals=None))
 
     def test_jar_war_then_a_trailing_pom_has_no_setup_row_and_a_bar_of_two(self):
         b = self.board([], ["mvn/app"], 3)
@@ -343,7 +343,7 @@ class TrailingPomTest(unittest.TestCase):
 
     def test_a_trailing_pom_never_shows_as_in_flight_in_the_bar(self):
         b = self.board([], ["mvn/app"], 3)
-        b.apply("module_start", dict(step="mvn", module="mvn/app", title="app"))
+        b.apply("unit_start", dict(step="mvn", id="mvn/app", title="app"))
         self.assertEqual(b.rows["mvn"].unit_view(), bytearray(2))
 
     def test_leading_parent_and_trailing_aggregator_give_a_setup_row_of_one(self):
@@ -374,9 +374,9 @@ class TrailingPomTest(unittest.TestCase):
         b.apply("step_end", dict(id="mvn", status="fail", ms=2000, exit=1, summary=None, totals=None))
         r = b.rows["mvn"]
         self.assertIsNone(b.setup)
-        self.assertEqual(r.failed_mods, ["mvn/app"])
+        self.assertEqual(r.failed_units, ["mvn/app"])
         self.assertEqual((r.units_total, r.units_done), (2, 2))
-        self.assertEqual(r.modules["mvn/app"].box, "fail")
+        self.assertEqual(r.units["mvn/app"].box, "fail")
 
 
 class TrailingPomGoldenTest(unittest.TestCase):
@@ -427,7 +427,7 @@ class TrailingPomGoldenTest(unittest.TestCase):
         board, r = self.run_log(text)
         board.apply("step_end", dict(id="mvn", status="fail", ms=3000, exit=1, summary=None, totals=None))
         self.assertIsNone(board.setup)
-        self.assertEqual(r.failed_mods, ["app"])
+        self.assertEqual(r.failed_units, ["app"])
         self.assertEqual((r.units_total, r.units_done), (2, 2))
 
 
@@ -440,19 +440,19 @@ def pump(board, events, step="mvn"):
             board.apply("reactor", dict(step=step, size=ev[1], poms=list(ev[2]), tests=ev[3],
                                         tail=list(ev[4]) if len(ev) > 4 else []))
         elif kind == "sub_start":
-            board.apply("module_start", dict(step=step, module=ev[1], title=ev[3]))
+            board.apply("unit_start", dict(step=step, id=ev[1], title=ev[3]))
         elif kind == "tests":
             totals[ev[1]] = ev[2:]
         elif kind == "sub_end":
             t = totals.get(ev[1])
-            board.apply("module_end", dict(step=step, module=ev[1], status=ev[2], ms=ev[3],
+            board.apply("unit_end", dict(step=step, id=ev[1], status=ev[2], ms=ev[3],
                                            totals=dict(total=t[0], fail=t[1], err=t[2], skip=t[3]) if t else None))
         elif kind == "compiled":
             board.apply("module_compiled", dict(step=step, module=ev[1], ok=ev[2]))
         elif kind == "tested":
             board.apply("module_tested", dict(step=step, module=ev[1]))
         elif kind == "class_done":
-            board.apply("class_done", dict(step=step, module=ev[1], outcome=ev[2]))
+            board.apply("unit_dot", dict(step=step, id=ev[1], outcome=ev[2], src="sub"))   # as _Dispatcher does
         seen.append((kind, board.rows[step].units_done, board.setup.done if board.setup else 0, ev[1]))
     return seen
 
diff --git a/juneau-run/src/test/python/test_module_line.py b/juneau-run/src/test/python/test_module_line.py
index fd288d3b09..80e91677b7 100644
--- a/juneau-run/src/test/python/test_module_line.py
+++ b/juneau-run/src/test/python/test_module_line.py
@@ -75,22 +75,23 @@ class CollapseTest(unittest.TestCase):
                 self.assertLessEqual(jr._vlen(line), max(0, cols - 2), (cols, line))
 
 
-class RegionCollapsedWidthTest(unittest.TestCase):
-    def test_collapsed_line_fills_but_never_exceeds_columns_minus_one(self):
+class RegionFoldWidthTest(unittest.TestCase):
+    def test_the_fold_line_fills_but_never_exceeds_columns_minus_one(self):
         reset_run_state()
         self.addCleanup(reset_run_state)
         b = jr.Board("actionable", nested=False, full_log=None, clock=lambda: 100.0)
         b.apply("step_start", dict(id="mvn", n=1, title="mvn", parent=None))
         for i in range(30):
-            b.apply("module_start", dict(step="mvn", module=f"m{i}", title=f"Module number {i:02d}"))
+            b.apply("unit_start", dict(step="mvn", id=f"m{i}", title=f"Module number {i:02d}"))
         live = jr.LiveRenderer(lambda t: None, size=lambda: os.terminal_size((60, 24)), clock=lambda: 100.0, color=False)
         size = live.size()
         lines = live.region(b, size.columns - 1, size.lines - 2)
-        collapsed = [l for l in lines if l.startswith("    ")][0]
-        self.assertLessEqual(jr._vlen(collapsed), 59)
-        names = [f"{i:02d}" for i in range(30)]
+        self.assertEqual(len(lines), 22)                    # the row, 20 unit lines, then the fold line
+        folded = lines[-1]
+        self.assertLessEqual(jr._vlen(folded), 59)
+        names = [f"{i:02d}" for i in range(20, 30)]         # the 10 newest fold, listed oldest first
         # region's cols already is columns - 1: no second -1; the 4-space indent takes two more than the default 2
-        self.assertEqual(collapsed, "    " + jr.collapse_names(names, 57))
+        self.assertEqual(folded, "    " + jr.collapse_names(names, 57))
 
 
 if __name__ == "__main__":
diff --git a/juneau-run/src/test/python/test_parsers.py b/juneau-run/src/test/python/test_parsers.py
index 973ac11a68..750c867a51 100644
--- a/juneau-run/src/test/python/test_parsers.py
+++ b/juneau-run/src/test/python/test_parsers.py
@@ -12,6 +12,7 @@
 # ***************************************************************************************************************************
 """Parsers: pure line-to-event tests against recorded fixtures (see fixtures/ and the module README)."""
 
+import json
 import os
 import re
 import tempfile
@@ -824,25 +825,273 @@ class PlaywrightParserTest(unittest.TestCase):
         self.assertTrue(any("Expected: 2" in c for c in cond))
         self.assertIn("  2 passed (1.2s)", cond)
 
-    def test_prepare_adds_reporters_and_json_path_only_when_markers_are_on(self):
-        with mock.patch.dict(os.environ, {"RUN_MARKERS": "1", "RUN_ARTIFACTS": "/art"}):
-            os.environ.pop("PLAYWRIGHT_JSON_OUTPUT_NAME", None)
+    def prep(self, cmd, env=None, where="/r/juneau_run_playwright.cjs", console=True, interactive=False):
+        parser = self.parser()
+        parser.interactive = interactive
+        with mock.patch.object(jr, "find_playwright_reporter", return_value=where), \
+                mock.patch.object(jr, "_console", object() if console else None):
+            return parser.prepare(cmd, env, None)
+
+    def clean_env(self, **values):
+        """os.environ with none of the variables prepare() reads, plus values."""
+        patch = mock.patch.dict(os.environ, values)
+        patch.start()
+        self.addCleanup(patch.stop)
+        for name in ("RUN_MARKERS", "RUN_ARTIFACTS", "PLAYWRIGHT_JSON_OUTPUT_NAME", "JUNEAU_RUN_PLAYWRIGHT_REPORTER",
+                     "PW_TEST_REPORTER", "JUNEAU_RUN_PLAYWRIGHT_JSON", "JUNEAU_RUN_PLAYWRIGHT_MARKERS",
+                     "PLAYWRIGHT_HTML_OPEN", "PW_TEST_HTML_REPORT_OPEN"):
+            if name not in values:
+                os.environ.pop(name, None)
+
+    def test_prepare_appends_the_reporter_through_the_environment_and_asks_it_for_json_under_markers(self):
+        self.clean_env(RUN_MARKERS="1", RUN_ARTIFACTS="/art")
+        cmd, env = self.prep(["npx", "playwright", "test"], {"A": "b"})
+        self.assertEqual(cmd, ["npx", "playwright", "test"])        # no --reporter: the config's reporters still run
+        self.assertEqual(env["PW_TEST_REPORTER"], "/r/juneau_run_playwright.cjs")
+        self.assertEqual(env["JUNEAU_RUN_PLAYWRIGHT_JSON"], str(Path("/art") / "pw-playwright.json"))
+        self.assertNotIn("JUNEAU_RUN_PLAYWRIGHT_MARKERS", env)
+        self.assertNotIn("PLAYWRIGHT_JSON_OUTPUT_NAME", env)      # would redirect a json reporter in the config
+        self.assertEqual(env["A"], "b")
+
+    def test_a_console_run_gets_the_reporter_and_no_json(self):
+        self.clean_env(RUN_ARTIFACTS="/art")
+        cmd, env = self.prep(["npx", "playwright", "test"], {})
+        self.assertEqual(cmd, ["npx", "playwright", "test"])
+        self.assertEqual(env["PW_TEST_REPORTER"], "/r/juneau_run_playwright.cjs")
+        self.assertNotIn("JUNEAU_RUN_PLAYWRIGHT_JSON", env)
+
+    def test_a_plain_run_with_no_session_or_markers_is_left_alone(self):
+        self.clean_env()
+        cmd, env = self.prep(["npx", "playwright", "test"], {}, console=False)
+        self.assertEqual((cmd, env), (["npx", "playwright", "test"], {}))
+
+    def test_an_interactive_run_keeps_playwrights_output_and_gets_only_the_json_under_markers(self):
+        self.clean_env(RUN_ARTIFACTS="/art")
+        self.assertEqual(self.prep(["playwright", "test"], {}, interactive=True), (["playwright", "test"], {}))
+        os.environ["RUN_MARKERS"] = "1"
+        cmd, env = self.prep(["playwright", "test"], {}, interactive=True)
+        self.assertEqual(cmd, ["playwright", "test"])
+        self.assertEqual((env["PW_TEST_REPORTER"], env["JUNEAU_RUN_PLAYWRIGHT_MARKERS"]),
+                         ("/r/juneau_run_playwright.cjs", "0"))      # prints nothing: only the report file is added
+        self.assertEqual(env["JUNEAU_RUN_PLAYWRIGHT_JSON"], str(Path("/art") / "pw-playwright.json"))
+        os.environ.pop("RUN_ARTIFACTS")
+        self.assertEqual(self.prep(["playwright", "test"], {}, interactive=True), (["playwright", "test"], {}))
+
+    def test_a_reporter_the_caller_names_is_respected(self):
+        self.clean_env(RUN_MARKERS="1", RUN_ARTIFACTS="/art")
+        for cmd in (["playwright", "test", "--reporter=list"], ["playwright", "test", "--reporter", "list"]):
+            self.assertEqual(self.prep(cmd, {}), (cmd, {}))
+        self.assertEqual(self.prep(["playwright", "test"], {"PW_TEST_REPORTER": "mine.js"})[1], {"PW_TEST_REPORTER": "mine.js"})
+
+    def test_a_json_path_the_caller_set_is_kept_and_reported(self):
+        self.clean_env(RUN_MARKERS="1", RUN_ARTIFACTS="/art")
+        parser = self.parser()
+        with mock.patch.object(jr, "find_playwright_reporter", return_value="/r/x.cjs"), mock.patch.object(jr, "_console", None):
+            env = parser.prepare(["playwright", "test"], {"JUNEAU_RUN_PLAYWRIGHT_JSON": "/mine.json"}, None)[1]
+        self.assertEqual((env["JUNEAU_RUN_PLAYWRIGHT_JSON"], parser.report_path), ("/mine.json", "/mine.json"))
+
+    def test_when_the_reporter_is_not_added_a_json_report_the_caller_asked_playwright_for_is_announced(self):
+        self.clean_env(RUN_MARKERS="1", RUN_ARTIFACTS="/art")
+        parser = self.parser()
+        with mock.patch.object(jr, "_console", None):
+            parser.prepare(["playwright", "test", "--reporter=json"], {"PLAYWRIGHT_JSON_OUTPUT_NAME": "/pw.json"}, None)
+        self.assertEqual(parser.report_path, "/pw.json")
+
+    def test_a_flag_that_only_starts_with_reporter_is_not_a_reporter(self):
+        self.clean_env()
+        self.assertEqual(self.prep(["playwright", "test", "--reporters-dir=x"], {})[1]["PW_TEST_REPORTER"],
+                         "/r/juneau_run_playwright.cjs")
+
+    def test_the_opt_out_or_a_missing_reporter_leaves_the_run_alone(self):
+        self.clean_env(JUNEAU_RUN_PLAYWRIGHT_REPORTER="0", RUN_MARKERS="1", RUN_ARTIFACTS="/art")
+        self.assertEqual(self.prep(["playwright", "test"], {}), (["playwright", "test"], {}))
+        os.environ.pop("JUNEAU_RUN_PLAYWRIGHT_REPORTER")
+        self.assertEqual(self.prep(["playwright", "test"], {}, where=None), (["playwright", "test"], {}))
+
+    def test_a_reporter_path_with_a_comma_is_fine(self):
+        self.clean_env()
+        self.assertEqual(self.prep(["playwright", "test"], {}, where="/a,b/r.cjs")[1]["PW_TEST_REPORTER"], "/a,b/r.cjs")
+
+    def test_an_injected_run_outside_a_terminal_never_opens_the_html_report(self):
+        # html's open-on-failure serves the report and waits whenever stdin is a terminal, which a console run's is
+        self.clean_env()
+        self.assertEqual(self.prep(["playwright", "test"], {})[1]["PLAYWRIGHT_HTML_OPEN"], "never")
+        for name in ("PLAYWRIGHT_HTML_OPEN", "PW_TEST_HTML_REPORT_OPEN"):
+            env = self.prep(["playwright", "test"], {name: "always"})[1]
+            self.assertEqual((env[name], env.get("PLAYWRIGHT_HTML_OPEN", "always")), ("always", "always"), name)
+        os.environ.update(RUN_MARKERS="1", RUN_ARTIFACTS="/art")
+        self.assertNotIn("PLAYWRIGHT_HTML_OPEN", self.prep(["playwright", "test"], {}, interactive=True)[1])
+
+    def test_an_injected_run_outside_a_terminal_drops_an_inherited_markers_off(self):
+        self.clean_env()
+        self.assertNotIn("JUNEAU_RUN_PLAYWRIGHT_MARKERS",
+                         self.prep(["playwright", "test"], {"JUNEAU_RUN_PLAYWRIGHT_MARKERS": "0"})[1])
+
+    def test_the_opt_out_is_read_from_the_commands_environment(self):
+        self.clean_env(RUN_MARKERS="1")
+        env = {"JUNEAU_RUN_PLAYWRIGHT_REPORTER": "0"}
+        self.assertEqual(self.prep(["playwright", "test"], env), (["playwright", "test"], env))
+        self.assertIn("PW_TEST_REPORTER", self.prep(["playwright", "test"], {"JUNEAU_RUN_PLAYWRIGHT_REPORTER": "1"})[1])
+
+    def test_a_leftover_report_is_removed_so_a_cancelled_run_announces_none(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            self.clean_env(RUN_MARKERS="1", RUN_ARTIFACTS=tmp)
+            old = Path(tmp) / "pw-playwright.json"
+            old.write_text("{}", encoding="utf-8")
+            parser = self.parser()
+            with mock.patch.object(jr, "find_playwright_reporter", return_value="/r/x.cjs"), \
+                    mock.patch.object(jr, "_console", None):
+                parser.prepare(["playwright", "test"], {}, None)
+            self.assertFalse(old.exists())
+            self.assertEqual(kinds(parser.finish(-15), "report"), [])
+
+    def test_interrupted_tests_in_the_summary_count_as_skipped(self):
+        p = self.parser()
+        events = [e for line in ("  1 failed", "  2 interrupted", "  3 passed") for e in p.on_line(line)]
+        self.assertEqual(kinds(events, "tests")[-1], ("tests", "pw", 6, 1, 0, 2))
+
+    def marker(self, ev, **fields):
+        return "##playwright " + json.dumps({"ev": ev, **fields})
+
+    def test_reporter_markers_open_dot_and_close_each_file(self):
+        p = self.parser()
+        lines = [self.marker("hello", v=1), self.marker("begin", f={"a.spec.ts": 2, "b.spec.ts": 1}),
+                 self.marker("start", f="a.spec.ts"), self.marker("test", f="a.spec.ts", o="pass"),
+                 self.marker("start", f="b.spec.ts"), self.marker("start", f="a.spec.ts"),
+                 self.marker("test", f="a.spec.ts", o="fail"), self.marker("test", f="b.spec.ts", o="skip")]
+        events = [e for line in lines for e in p.on_line(line) if e[0].startswith("unit_")]
+        self.assertEqual(events, [("unit_start", "a.spec.ts", "a.spec.ts", "file"), ("unit_dot", "a.spec.ts", "pass"),
+                                  ("unit_start", "b.spec.ts", "b.spec.ts", "file"), ("unit_dot", "a.spec.ts", "fail"),
+                                  ("unit_end", "a.spec.ts", "fail", "file"), ("unit_dot", "b.spec.ts", "skip"),
+                                  ("unit_end", "b.spec.ts", "ok", "file")])
+
+    def test_the_bar_counts_from_markers_once_the_reporter_says_hello(self):
+        p = self.parser()
+        self.assertEqual(p.on_line(self.marker("hello", v=1)), [])
+        self.assertEqual(p.on_line(self.marker("begin", f={"a.ts": 2, "b.ts": 1})), [("units", "pw", 3, "tests")])
+        self.assertEqual(p.on_line("[1/3] a.ts:3:1 › passes"), [])      # the line reporter's count is ignored
+        self.assertEqual(p.on_line(self.marker("test", f="a.ts", o="fail"))[:2],
+                         [("unit", "pw", 1, "fail"), ("tests", "pw", 1, 1, 0, 0)])
+        self.assertEqual(p.on_line(self.marker("test", f="b.ts", o="skip"))[:2],
+                         [("unit", "pw", 2, "skip"), ("tests", "pw", 2, 1, 0, 1)])
+        self.assertEqual(kinds(p.on_line("  1 failed"), "tests"), [("tests", "pw", 1, 1, 0, 0)])  # the summary still wins
+
+    def test_a_test_line_after_the_summary_moves_the_bar_but_leaves_the_summary_counts(self):
+        # the reporter's onEnd sends a test whose retry never ran after Playwright printed its summary
+        p = self.parser()
+        for line in (self.marker("hello", v=1), self.marker("begin", f={"a.ts": 1, "b.ts": 1}),
+                     self.marker("test", f="b.ts", o="fail"), "  1 failed", "  1 flaky"):
+            p.on_line(line)
+        events = p.on_line(self.marker("test", f="a.ts", o="pass"))
+        self.assertEqual((kinds(events, "unit"), kinds(events, "tests")), ([("unit", "pw", 2, "pass")], []))
+
+    def test_a_retried_test_counts_once(self):
+        p = self.parser()
+        lines = [self.marker("hello", v=1), self.marker("begin", f={"a.ts": 1}), self.marker("start", f="a.ts"),
+                 "[1/2] a.ts:3:1 › flaky", self.marker("start", f="a.ts"), "[2/2] a.ts:3:1 › flaky (retry #1)",
+                 self.marker("test", f="a.ts", o="pass")]
+        events = [e for line in lines for e in p.on_line(line)]
+        self.assertEqual(kinds(events, "unit"), [("unit", "pw", 1, "pass")])
+        self.assertEqual(kinds(events, "units"), [("units", "pw", 1, "tests")])
+
+    def test_a_test_with_no_file_still_counts(self):
+        p = self.parser()
+        for line in (self.marker("hello", v=1), self.marker("begin", f={"": 1})):
+            p.on_line(line)
+        self.assertEqual(p.on_line(self.marker("test", f="", o="pass")), [("unit", "pw", 1, "pass"), ("tests", "pw", 1, 0, 0, 0)])
+
+    def test_a_run_whose_reporter_never_said_hello_says_so_once(self):
+        with mock.patch.dict(os.environ, {"RUN_MARKERS": "1"}):
+            os.environ.pop("PW_TEST_REPORTER", None)
+            os.environ.pop("JUNEAU_RUN_PLAYWRIGHT_REPORTER", None)
+            for exit_code, text in ((0, "  2 passed"), (1, "  1 failed"), (1, "[1/2] a.ts:3:1 › passes")):
+                p = self.parser()
+                with mock.patch.object(jr, "find_playwright_reporter", return_value="/r/x.cjs"):
+                    p.prepare(["playwright", "test"], {}, None)
+                if text:
+                    p.on_line(text)
+                self.assertEqual(kinds(p.finish(exit_code), "note"),
+                                 [("note", "warn", "Playwright reporter not loaded: no per-file lines", "pw")])
+
+    def test_no_note_without_the_reporter_or_when_nothing_ran(self):
+        p = self.parser()                                   # not injected
+        self.assertEqual(kinds(p.finish(0), "note"), [])
+        with mock.patch.dict(os.environ, {"RUN_MARKERS": "1"}):
+            os.environ.pop("PW_TEST_REPORTER", None)
+            os.environ.pop("JUNEAU_RUN_PLAYWRIGHT_REPORTER", None)
             p = self.parser()
-            cmd, env = p.prepare(["npx", "playwright", "test"], {"A": "b"}, None)
-            self.assertEqual(cmd, ["npx", "playwright", "test", "--reporter=line,json"])
-            self.assertEqual(env["PLAYWRIGHT_JSON_OUTPUT_NAME"], "/art/pw-playwright.json")
-            self.assertEqual(env["A"], "b")
-        with mock.patch.dict(os.environ, {"RUN_ARTIFACTS": "/art"}):
-            os.environ.pop("RUN_MARKERS", None)
-            os.environ.pop("PLAYWRIGHT_JSON_OUTPUT_NAME", None)
-            cmd, env = self.parser().prepare(["npx", "playwright", "test"], {}, None)
-            self.assertEqual(cmd, ["npx", "playwright", "test"])
-            self.assertNotIn("PLAYWRIGHT_JSON_OUTPUT_NAME", env)
+            with mock.patch.object(jr, "find_playwright_reporter", return_value="/r/x.cjs"):
+                p.prepare(["playwright", "test"], {}, None)
+            self.assertEqual(kinds(p.finish(1), "note"), [])  # a config error: Playwright stopped before any test
+            p = self.parser()
+            with mock.patch.object(jr, "find_playwright_reporter", return_value="/r/x.cjs"):
+                p.prepare(["playwright", "test", "--list"], {}, None)
+            p.on_line("Total: 3 tests in 1 file")
+            self.assertEqual(kinds(p.finish(0), "note"), [])  # --list, --help: no test ran, so no summary
+            p = self.parser()
+            with mock.patch.object(jr, "find_playwright_reporter", return_value="/r/x.cjs"):
+                p.prepare(["playwright", "test"], {}, None)
+            p.on_line(self.marker("hello", v=1))
+            self.assertEqual(kinds(p.finish(0), "note"), [])
 
-    def test_existing_reporter_is_respected(self):
-        with mock.patch.dict(os.environ, {"RUN_MARKERS": "1", "RUN_ARTIFACTS": "/art"}):
-            cmd, _ = self.parser().prepare(["playwright", "test", "--reporter=list"], {}, None)
-        self.assertEqual(cmd, ["playwright", "test", "--reporter=list"])
+    def test_a_marker_after_test_output_with_no_newline_is_found(self):
+        p = self.parser()
+        p.on_line(self.marker("begin", f={"a.spec.ts": 3}))
+        events = p.on_line("[1/3] a.spec.ts:3:1 › passes" + self.marker("start", f="a.spec.ts"))
+        self.assertEqual(events, [("units", "pw", 3, "tests"), ("unit", "pw", 1, None), ("tests", "pw", 1, 0, 0, 0),
+                                  ("unit_start", "a.spec.ts", "a.spec.ts", "file")])
+        self.assertEqual(p.on_line("console.log text" + self.marker("test", f="a.spec.ts", o="pass")),
+                         [("unit_dot", "a.spec.ts", "pass")])
+
+    def test_a_test_line_before_its_start_opens_the_file(self):
+        p = self.parser()
+        p.on_line(self.marker("begin", f={"a.ts": 2}))
+        self.assertEqual(p.on_line(self.marker("test", f="a.ts", o="pass")),
+                         [("unit_start", "a.ts", "a.ts", "file"), ("unit_dot", "a.ts", "pass")])
+
+    def test_a_dot_on_a_closed_file_is_passed_on_without_reopening_it(self):
+        p = self.parser()
+        for line in (self.marker("begin", f={"a.ts": 1}), self.marker("test", f="a.ts", o="pass")):
+            p.on_line(line)
+        self.assertEqual(p.on_line(self.marker("start", f="a.ts")), [])
+        self.assertEqual(p.on_line(self.marker("test", f="a.ts", o="fail")), [("unit_dot", "a.ts", "fail")])
+        self.assertEqual(p.finish(1), [])
+
+    def test_a_file_missing_from_begin_closes_only_at_finish(self):
+        p = self.parser()
+        for line in (self.marker("begin", f={"a.ts": 1}), self.marker("start", f="b.ts"),
+                     self.marker("test", f="b.ts", o="pass"), self.marker("test", f="b.ts", o="pass")):
+            self.assertNotIn("unit_end", [e[0] for e in p.on_line(line)], line)
+        self.assertEqual(p.finish(0), [("unit_end", "b.ts", "ok", "file")])
+
+    def test_marker_lines_stay_out_of_the_condensed_log(self):
+        text = PLAYWRIGHT_FAIL.replace("    Error: expect(received).toBe(expected)",
+                                       "    Error: expect(received).toBe(expected)\n" + self.marker("start", f="x.ts"))
+        cond = [e[1] for e in kinds(feed(self.parser(), text, 1), "cond")]
+        self.assertFalse(any("##playwright" in c for c in cond))
+
+    def test_finish_closes_open_files_fail_only_with_a_fail_dot_whatever_the_exit_code(self):
+        for exit_code in (0, 1):
+            p = self.parser()
+            for line in (self.marker("begin", f={"a.ts": 5, "b.ts": 5}), self.marker("test", f="a.ts", o="fail"),
+                         self.marker("test", f="b.ts", o="pass")):
+                p.on_line(line)
+            self.assertEqual(p.finish(exit_code), [("unit_end", "a.ts", "fail", "file"), ("unit_end", "b.ts", "ok", "file")])
+
+    def test_an_interrupted_run_names_only_the_file_with_a_failure(self):
+        p = self.parser()
+        lines = [self.marker("hello", v=1), self.marker("begin", f={f"{c}.ts": 3 for c in "abcd"})]
+        lines += [self.marker("start", f=f"{c}.ts") for c in "abcd"]
+        lines += [self.marker("test", f="a.ts", o="fail")] + [self.marker("test", f=f"{c}.ts", o="skip") for c in "bcd"]
+        for line in lines:
+            p.on_line(line)
+        self.assertEqual(p.finish(1), [("unit_end", f"{c}.ts", "fail" if c == "a" else "ok", "file") for c in "abcd"])
+
+    def test_bad_markers_are_ignored(self):
+        p = self.parser()
+        for line in ("##playwright {", "##playwright []", self.marker("start"), self.marker("test", f="a.ts", o="odd"),
+                     self.marker("begin", f="a.ts")):
+            self.assertEqual(p.on_line(line), [], line)
 
     def test_report_is_emitted_when_the_json_file_exists(self):
         with tempfile.TemporaryDirectory() as tmp:
diff --git a/juneau-run/src/test/python/test_playwright_reporter.py b/juneau-run/src/test/python/test_playwright_reporter.py
new file mode 100644
index 0000000000..c06417a013
--- /dev/null
+++ b/juneau-run/src/test/python/test_playwright_reporter.py
@@ -0,0 +1,28 @@
+# ***************************************************************************************************************************
+# * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.  See the NOTICE file
+# * distributed with this work for additional information regarding copyright ownership.  The ASF licenses this file
+# * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance
+# * with the License.  You may obtain a copy of the License at
+# *
+# *  http://www.apache.org/licenses/LICENSE-2.0
+# *
+# * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
+# * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the
+# * specific language governing permissions and limitations under the License.
+# ***************************************************************************************************************************
+"""The Playwright reporter's node tests (src/test/js), run in the Maven gate."""
+
+import shutil
+import subprocess
+import unittest
+
+import support
+
+TESTS = support.MODULE_ROOT / "src" / "test" / "js" / "juneau_run_playwright.test.cjs"
+
+
+@unittest.skipUnless(shutil.which("node"), "node not installed")
+class PlaywrightReporterTest(unittest.TestCase):
+    def test_node_tests_pass(self):
+        p = subprocess.run(["node", "--test", str(TESTS)], capture_output=True, text=True, timeout=120)
+        self.assertEqual(p.returncode, 0, p.stdout + p.stderr)
diff --git a/juneau-run/src/test/python/test_progress_parser.py b/juneau-run/src/test/python/test_progress_parser.py
index bb38222fa6..029c8a7521 100644
--- a/juneau-run/src/test/python/test_progress_parser.py
+++ b/juneau-run/src/test/python/test_progress_parser.py
@@ -33,11 +33,69 @@ def units(events):
     return [e for e in events if e[0] in ("units", "unit")]
 
 
+def unit_events(events):
+    return [e for e in events if e[0] in ("unit_start", "unit_dot", "unit_end")]
+
+
 class PluginMarkersTest(unittest.TestCase):
-    def test_run_marker_becomes_progress_naming_the_file(self):
-        events = ev(new_parser(), ['##pytest {"ev":"hello","v":1}', '##pytest {"ev":"run","f":"test_a.py"}',
-                                   '##pytest {"ev":"run"}'])
-        self.assertEqual(events, [("progress", "p", "test_a.py")])
+    def test_file_marker_opens_the_files_unit_once(self):
+        events = ev(new_parser(), ['##pytest {"ev":"hello","v":1}', '##pytest {"ev":"file","f":"test_a.py"}',
+                                   '##pytest {"ev":"file","f":"test_a.py"}', '##pytest {"ev":"file"}',
+                                   '##pytest {"ev":"run","f":"test_b.py"}'])     # run is gone: nothing
+        # feed() ends with finish(), which closes the file left open
+        self.assertEqual(events, [("unit_start", "test_a.py", "test_a.py", "file"), ("unit_end", "test_a.py", "ok", "file")])
+
+    def test_one_dot_per_test_and_error_is_fail(self):
+        events = ev(new_parser(), ['##pytest {"ev":"hello","v":1}', '##pytest {"ev":"file","f":"a.py"}',
+                                   test_mark("passed", "a.py::t1"), test_mark("error", "a.py::t2"),
+                                   test_mark("skipped", "a.py::t3"), test_mark("xfailed", "a.py::t4")])
+        self.assertEqual(unit_events(events)[1:-1], [("unit_dot", "a.py", "pass"), ("unit_dot", "a.py", "fail"),
+                                                     ("unit_dot", "a.py", "skip"), ("unit_dot", "a.py", "skip")])
+
+    def test_a_file_closes_with_its_last_test(self):
+        events = ev(new_parser(), ['##pytest {"ev":"hello","v":1}', total_mark({"a.py": 2, "b.py": 1}),
+                                   '##pytest {"ev":"file","f":"a.py"}', test_mark("passed", "a.py::t1"),
+                                   test_mark("failed", "a.py::t2"), '##pytest {"ev":"file","f":"b.py"}',
+                                   test_mark("passed", "b.py::t1")])
+        self.assertEqual(unit_events(events), [
+            ("unit_start", "a.py", "a.py", "file"), ("unit_dot", "a.py", "pass"), ("unit_dot", "a.py", "fail"),
+            ("unit_end", "a.py", "fail", "file"),
+            ("unit_start", "b.py", "b.py", "file"), ("unit_dot", "b.py", "pass"), ("unit_end", "b.py", "ok", "file")])
+
+    def test_a_late_error_after_close_is_a_fail_dot_and_before_close_fails_the_file(self):
+        events = ev(new_parser(), ['##pytest {"ev":"hello","v":1}', total_mark({"a.py": 1, "b.py": 2}),
+                                   '##pytest {"ev":"file","f":"a.py"}', test_mark("passed", "a.py::t1"),
+                                   test_mark("error", "a.py::t1", late=True),
+                                   '##pytest {"ev":"file","f":"b.py"}', test_mark("passed", "b.py::t1"),
+                                   test_mark("error", "b.py::t1", late=True), test_mark("passed", "b.py::t2")])
+        self.assertEqual(unit_events(events), [
+            ("unit_start", "a.py", "a.py", "file"), ("unit_dot", "a.py", "pass"), ("unit_end", "a.py", "ok", "file"),
+            ("unit_dot", "a.py", "fail"),
+            ("unit_start", "b.py", "b.py", "file"), ("unit_dot", "b.py", "pass"), ("unit_dot", "b.py", "pass"),
+            ("unit_end", "b.py", "fail", "file")])
+
+    def test_without_per_file_counts_units_close_in_finish(self):
+        p = new_parser()
+        events = []
+        for line in ['##pytest {"ev":"hello","v":1}', '##pytest {"ev":"total","n":2}',
+                     '##pytest {"ev":"file","f":"a.py"}', test_mark("failed", "a.py::t1"),
+                     '##pytest {"ev":"file","f":"b.py"}', test_mark("passed", "b.py::t1")]:
+            events += p.on_line(line)
+        self.assertNotIn("unit_end", [e[0] for e in events])
+        self.assertEqual(unit_events(p.finish(1)), [("unit_end", "a.py", "fail", "file"), ("unit_end", "b.py", "ok", "file")])
+        self.assertEqual(unit_events(p.finish(1)), [])          # closed once
+
+    def test_xdist_interleaved_files_and_an_orphan_test(self):
+        events = ev(new_parser(), ['##pytest {"ev":"hello","v":1}', total_mark({"a.py": 2, "b.py": 2}),
+                                   '##pytest {"ev":"file","f":"a.py"}', test_mark("passed", "b.py::t1"),
+                                   '##pytest {"ev":"file","f":"b.py"}', test_mark("passed", "a.py::t1"),
+                                   '##pytest {"ev":"file","f":"a.py"}', test_mark("passed", "a.py::t2"),
+                                   test_mark("passed", "b.py::t2")])
+        self.assertEqual(unit_events(events), [
+            ("unit_start", "a.py", "a.py", "file"),
+            ("unit_start", "b.py", "b.py", "file"), ("unit_dot", "b.py", "pass"),     # the orphan test opens b.py
+            ("unit_dot", "a.py", "pass"), ("unit_dot", "a.py", "pass"), ("unit_end", "a.py", "ok", "file"),
+            ("unit_dot", "b.py", "pass"), ("unit_end", "b.py", "ok", "file")])
 
     def test_total_then_tests(self):
         events = ev(new_parser(), [
diff --git a/juneau-run/src/test/python/test_progress_plugin.py b/juneau-run/src/test/python/test_progress_plugin.py
index bbaf298da8..f8ffab2206 100644
--- a/juneau-run/src/test/python/test_progress_plugin.py
+++ b/juneau-run/src/test/python/test_progress_plugin.py
@@ -58,7 +58,7 @@ class PluginTest(unittest.TestCase):
     def test_markers_for_every_outcome(self):
         _, marks = run_pytest(self.tmp)
         self.assertEqual(marks[0], {"ev": "hello", "v": 1})
-        self.assertEqual(marks[1], {"ev": "total", "n": 7})
+        self.assertEqual(marks[1], {"ev": "total", "n": 7, "f": {"test_s.py": 7}})
         tests = [m for m in marks if m["ev"] == "test" and not m.get("late")]
         self.assertEqual(sorted(m["o"] for m in tests),
                          sorted(["passed", "failed", "skipped", "xfailed", "xpassed", "error", "passed"]))
@@ -67,10 +67,11 @@ class PluginTest(unittest.TestCase):
         self.assertEqual(late[0]["o"], "error")
         self.assertTrue(all(m["id"].startswith("test_s.py::") for m in tests))
 
-    def test_run_marker_names_each_test_file_once_as_it_starts(self):
+    def test_file_marker_names_each_test_file_once_as_it_starts(self):
         Path(self.tmp, "test_t.py").write_text("def test_a(): pass\ndef test_b(): pass\n")
         _, marks = run_pytest(self.tmp, suite="def test_a(): pass\ndef test_b(): pass\n")
-        self.assertEqual([m["f"] for m in marks if m["ev"] == "run"], ["test_s.py", "test_t.py"])
+        self.assertEqual([m["f"] for m in marks if m["ev"] == "file"], ["test_s.py", "test_t.py"])
+        self.assertEqual([m for m in marks if m["ev"] == "run"], [])
 
     def test_total_equals_collected_and_each_test_once(self):
         _, marks = run_pytest(self.tmp)
@@ -110,7 +111,7 @@ class PluginTest(unittest.TestCase):
     def test_without_xdist_the_plugin_loads_and_reports_the_total(self):
         p, marks = run_pytest(self.tmp, "-p", "no:xdist", suite="def test_a(): pass\n")
         self.assertEqual(p.returncode, 0, p.stdout + p.stderr)
-        self.assertIn({"ev": "total", "n": 1}, marks)
+        self.assertIn({"ev": "total", "n": 1, "f": {"test_s.py": 1}}, marks)
 
     def test_zero_collected_still_emits_total(self):
         _, marks = run_pytest(self.tmp, suite="x = 1\n")
diff --git a/juneau-run/src/test/python/test_render.py b/juneau-run/src/test/python/test_render.py
index d1a4b500dc..5548a4c512 100644
--- a/juneau-run/src/test/python/test_render.py
+++ b/juneau-run/src/test/python/test_render.py
@@ -62,7 +62,7 @@ class DetailTest(unittest.TestCase):
                 with self.subTest(detail=detail, level=level):
                     self.assertEqual(jr.shows_note(detail, level), level in shown)
 
-    def test_module_rows_by_detail(self):
+    def test_finished_module_lines_by_detail(self):
         # (detail, failed, has_tests) -> shown
         table = {
             ("summary", True, True): False, ("summary", False, True): False,
@@ -107,19 +107,19 @@ def maven_events(step="tests", tests_mode=True):
     return [
         ("step_start", {"id": step, "n": 3, "title": "Tests", "parent": None}),
         ("reactor", {"step": step, "size": 3, "poms": [f"{step}/p"], "tests": tests_mode}),
-        ("module_start", {"step": step, "module": f"{step}/p", "title": "Parent"}),
+        ("unit_start", {"step": step, "id": f"{step}/p", "title": "Parent"}),
         ("module_compiled", {"step": step, "module": f"{step}/p", "ok": True}),
-        ("module_end", {"step": step, "module": f"{step}/p", "status": "ok", "ms": 100, "totals": None}),
-        ("module_start", {"step": step, "module": f"{step}/a", "title": "Mod A"}),
-        ("class_done", {"step": step, "module": f"{step}/a", "outcome": "pass"}),
+        ("unit_end", {"step": step, "id": f"{step}/p", "status": "ok", "ms": 100, "totals": None}),
+        ("unit_start", {"step": step, "id": f"{step}/a", "title": "Mod A"}),
+        ("unit_dot", {"step": step, "id": f"{step}/a", "outcome": "pass"}),
         ("module_compiled", {"step": step, "module": f"{step}/a", "ok": True}),
         ("module_tested", {"step": step, "module": f"{step}/a"}),
-        ("module_end", {"step": step, "module": f"{step}/a", "status": "ok", "ms": 41000, "totals": (7139, 0, 0, 0)}),
-        ("module_start", {"step": step, "module": f"{step}/b", "title": "Mod B"}),
-        ("class_done", {"step": step, "module": f"{step}/b", "outcome": "fail"}),
+        ("unit_end", {"step": step, "id": f"{step}/a", "status": "ok", "ms": 41000, "totals": (7139, 0, 0, 0)}),
+        ("unit_start", {"step": step, "id": f"{step}/b", "title": "Mod B"}),
+        ("unit_dot", {"step": step, "id": f"{step}/b", "outcome": "fail"}),
         ("module_compiled", {"step": step, "module": f"{step}/b", "ok": True}),
         ("module_tested", {"step": step, "module": f"{step}/b"}),
-        ("module_end", {"step": step, "module": f"{step}/b", "status": "fail", "ms": 2000, "totals": (10, 2, 0, 0)}),
+        ("unit_end", {"step": step, "id": f"{step}/b", "status": "fail", "ms": 2000, "totals": (10, 2, 0, 0)}),
     ]
 
 
@@ -206,7 +206,7 @@ class BoardTest(unittest.TestCase):
         b = self.board()
         feed(b, ("step_start", {"id": "build", "n": 3, "title": "Build", "parent": None}),
              ("reactor", {"step": "build", "size": 1, "poms": [], "tests": False}),
-             ("module_start", {"step": "build", "module": "build/a", "title": "Mod A"}),
+             ("unit_start", {"step": "build", "id": "build/a", "title": "Mod A"}),
              ("module_compiled", {"step": "build", "module": "build/a", "ok": True}),
              ("module_compiled", {"step": "build", "module": "build/a", "ok": False}))
         self.assertEqual(b.rows["build"].boxes(), ["fail"])
@@ -322,9 +322,9 @@ class BoardTest(unittest.TestCase):
         b = self.board()
         feed(b, ("step_start", {"id": "tests", "n": 3, "title": "Tests", "parent": None}),
              ("reactor", {"step": "tests", "size": 2, "poms": [], "tests": True}),
-             ("module_start", {"step": "tests", "module": "tests/a", "title": "Mod A"}),
-             ("class_done", {"step": "tests", "module": "tests/a", "outcome": "pass"}),
-             ("module_start", {"step": "tests", "module": "tests/b", "title": "Mod B"}),
+             ("unit_start", {"step": "tests", "id": "tests/a", "title": "Mod A"}),
+             ("unit_dot", {"step": "tests", "id": "tests/a", "outcome": "pass"}),
+             ("unit_start", {"step": "tests", "id": "tests/b", "title": "Mod B"}),
              ("step_end", {"id": "tests", "status": "fail", "ms": 1000, "exit": 1, "summary": None, "totals": None}))
         self.assertEqual(b.rows["tests"].boxes(), ["pass", "skip"])
 
@@ -383,7 +383,7 @@ class ConsoleTest(unittest.TestCase):
         out = io.StringIO()
         c = self.console(out, console="condensed")
         c("step_start", {"id": "tests", "n": 3, "title": "Tests", "parent": None})
-        c("module_start", {"step": "tests", "module": "tests/a", "title": "Mod A"})
+        c("unit_start", {"step": "tests", "id": "tests/a", "title": "Mod A"})
         c("tests", {"step": "tests", "total": 10, "fail": 1, "err": 0, "skip": 0})
         self.clock.advance(14)
         c.tick()
@@ -595,11 +595,11 @@ def start(step="tests", size=3, title="Tests"):
 
 
 def mod_start(name, step="tests"):
-    return ("module_start", {"step": step, "module": f"{step}/{name}", "title": f"Mod {name.upper()}"})
+    return ("unit_start", {"step": step, "id": f"{step}/{name}", "title": f"Mod {name.upper()}"})
 
 
 def cls(name, outcome="pass", step="tests"):
-    return ("class_done", {"step": step, "module": f"{step}/{name}", "outcome": outcome})
+    return ("unit_dot", {"step": step, "id": f"{step}/{name}", "outcome": outcome})
 
 
 class RowEstimateTest(unittest.TestCase):
@@ -733,9 +733,9 @@ class LiveTest(unittest.TestCase):
         self.push(cls("a"), cls("a", "fail"))
         self.assertEqual(self.screen.text(), "\n".join([
             (self.spin() + "Tests".ljust(10) + "  ▒░░  0/3").ljust(59 - 4) + "0:01",     # Mod A is in flight
-            "    Mod A  •F",
+            "    Mod A  •F".ljust(59 - 4) + "0:01",
         ]))
-        self.push(("module_end", {"step": "tests", "module": "tests/a", "status": "fail", "ms": 1000,
+        self.push(("unit_end", {"step": "tests", "id": "tests/a", "status": "fail", "ms": 1000,
                                   "totals": (2, 1, 0, 0)}))
         self.push(("step_end", {"id": "tests", "status": "fail", "ms": 4000, "exit": 1, "summary": None,
                                 "totals": None}))
@@ -864,8 +864,8 @@ class LiveTest(unittest.TestCase):
         self.push(*start(size=5), *[mod_start(n) for n in "abcde"], *[cls(n) for n in "abcde"])
         region = self.live.region(self.board, 59, 4)
         self.assertEqual(len(region), 4)
-        self.assertEqual(region[1], "    +3 more running")
-        self.assertTrue(region[2].startswith("    Mod D  ") and region[3].startswith("    Mod E  "), region)
+        self.assertTrue(region[1].startswith("    Mod A  ") and region[2].startswith("    Mod B  "), region)
+        self.assertEqual(region[3], "    C, D, E")              # the newest fold first, listed oldest first
         self.assertTrue(all(len(line) <= 59 for line in region), region)
         self.assertEqual(len(self.screen.text().splitlines()), 4)
         self.assertTrue(all(len(line) <= 59 for line in self.screen.lines), self.screen.lines)
@@ -874,31 +874,32 @@ class LiveTest(unittest.TestCase):
     def test_boxes_cap_at_one_line(self):
         self.dims = (40, 10)
         self.push(*start(), mod_start("a"), *[cls("a") for _ in range(200)])
-        self.assertEqual(self.live.region(self.board, 39, 8)[1:], ["    Mod A  " + "•" * 24 + "+176"])
+        self.assertEqual(self.live.region(self.board, 39, 8)[1:], ["    Mod A  " + "•" * 18 + "+182" + "  0:00"])
 
     def test_a_module_that_exactly_fits_has_no_counter(self):
         self.dims = (40, 10)
-        self.push(*start(), mod_start("a"), *[cls("a") for _ in range(28)])
-        self.assertEqual(self.live.region(self.board, 39, 8)[1:], ["    Mod A  " + "•" * 28])
+        self.push(*start(), mod_start("a"), *[cls("a") for _ in range(22)])
+        self.assertEqual(self.live.region(self.board, 39, 8)[1:], ["    Mod A  " + "•" * 22 + "  0:00"])
 
     def test_a_module_one_over_shows_the_counter_in_the_last_cells(self):
         self.dims = (40, 10)
-        self.push(*start(), mod_start("a"), *[cls("a") for _ in range(29)])
-        self.assertEqual(self.live.region(self.board, 39, 8)[1:], ["    Mod A  " + "•" * 26 + "+3"])
+        self.push(*start(), mod_start("a"), *[cls("a") for _ in range(23)])
+        self.assertEqual(self.live.region(self.board, 39, 8)[1:], ["    Mod A  " + "•" * 20 + "+3" + "  0:00"])
 
     def test_the_counter_keeps_the_strip_width_as_digits_grow(self):
         self.dims = (40, 10)
-        for total in (29, 40, 126, 127, 128, 1000, 5000):
+        for total in (23, 40, 126, 127, 128, 1000, 5000):
             with self.subTest(total=total):
                 board = jr.Board(clock=lambda: 100.0)
                 feed(board, *start(), mod_start("a"), *[cls("a") for _ in range(total)])
                 live = jr.LiveRenderer(lambda text: None, size=lambda: os.terminal_size(self.dims), clock=lambda: 100.0,
                                        color=False)
-                strip = live._sub_lines(next(iter(board.rows.values())).modules["tests/a"], 39)[0]
-                self.assertEqual(jr._vlen(strip), jr._vlen("    Mod A  ") + 28)
-                dots = strip[len("    Mod A  "):].lstrip("•")
+                strip = live._sub_lines(next(iter(board.rows.values())).units["tests/a"], 39)[0]
+                self.assertEqual(jr._vlen(strip), 39)                  # elapsed never moves
+                self.assertTrue(strip.endswith("  0:00"), strip)
+                dots = strip[len("    Mod A  "):-len("  0:00")].lstrip("•")
                 self.assertRegex(dots, r"^\+\d+$")
-                self.assertEqual(int(dots[1:]), total - (28 - len(dots)))
+                self.assertEqual(int(dots[1:]), total - (22 - len(dots)))
 
     def test_the_counter_is_green_without_a_hidden_failure_and_red_with_one(self):
         self.dims = (40, 10)
@@ -907,33 +908,57 @@ class LiveTest(unittest.TestCase):
             feed(board, *start(), mod_start("a"), cls("a", first), *[cls("a") for _ in range(40)])
             live = jr.LiveRenderer(lambda text: None, size=lambda: os.terminal_size(self.dims), clock=lambda: 100.0,
                                    color=True)
-            return live._sub_lines(next(iter(board.rows.values())).modules["tests/a"], 39)[0]
-        self.assertTrue(strip("pass").endswith("\x1b[32m+16\x1b[0m"), repr(strip("pass")))
-        self.assertTrue(strip("skip").endswith("\x1b[32m+16\x1b[0m"), repr(strip("skip")))
-        self.assertTrue(strip("fail").endswith("\x1b[31m+16\x1b[0m"), repr(strip("fail")))
+            return live._sub_lines(next(iter(board.rows.values())).units["tests/a"], 39)[0]
+        self.assertTrue(strip("pass").endswith("\x1b[32m+22\x1b[0m  0:00"), repr(strip("pass")))
+        self.assertTrue(strip("skip").endswith("\x1b[32m+22\x1b[0m  0:00"), repr(strip("skip")))
+        self.assertTrue(strip("fail").endswith("\x1b[31m+22\x1b[0m  0:00"), repr(strip("fail")))
 
     def test_a_failure_among_the_kept_dots_does_not_redden_the_counter(self):
         board = jr.Board(clock=lambda: 100.0)
         feed(board, *start(), mod_start("a"), *[cls("a") for _ in range(40)], cls("a", "fail"))
         live = jr.LiveRenderer(lambda text: None, size=lambda: os.terminal_size((40, 10)), clock=lambda: 100.0, color=True)
-        strip = live._sub_lines(next(iter(board.rows.values())).modules["tests/a"], 39)[0]
-        self.assertIn("\x1b[32m+16\x1b[0m", strip)
+        strip = live._sub_lines(next(iter(board.rows.values())).units["tests/a"], 39)[0]
+        self.assertIn("\x1b[32m+22\x1b[0m", strip)
 
     def test_a_tiny_strip_still_fits(self):
         board = jr.Board(clock=lambda: 100.0)
         feed(board, *start(), mod_start("a"), *[cls("a") for _ in range(50)])
         live = jr.LiveRenderer(lambda text: None, size=lambda: os.terminal_size((40, 10)), clock=lambda: 100.0, color=False)
-        module = next(iter(board.rows.values())).modules["tests/a"]
-        for cols in range(1, 20):
+        module = next(iter(board.rows.values())).units["tests/a"]
+        for cols in range(1, 41):
             with self.subTest(cols=cols):
-                strip = live._sub_lines(module, cols)[0]
-                prefix = jr._vlen(f"    {jr._vcut(module.title, max(1, cols // 2))}  ")
-                self.assertEqual(jr._vlen(strip) - prefix, max(1, cols - prefix))
+                line = live._sub_lines(module, cols)[0]
+                self.assertLessEqual(jr._vlen(line), cols)      # a wider line would wrap and leave a ghost line
+                if cols >= 4 + 1 + 2 + 1 + 2 + 4:       # room for a title column, one dot and elapsed: it fills cols
+                    self.assertEqual(jr._vlen(line), cols)
+                    self.assertTrue(line.endswith("  0:00"), line)
+
+    def test_an_elapsed_of_ten_minutes_keeps_the_line_at_cols(self):
+        clock = [100.0]
+        board = jr.Board(clock=lambda: clock[0])
+        feed(board, *start(), mod_start("a"), cls("a"))
+        live = jr.LiveRenderer(lambda text: None, size=lambda: os.terminal_size((40, 10)), clock=lambda: clock[0],
+                               color=False)
+        module = next(iter(board.rows.values())).units["tests/a"]
+        for cols in (15, 20, 39):     # 15: the narrowest line with a title column, a dot and a 5-column elapsed
+            with self.subTest(cols=cols):
+                clock[0] = 700.0
+                line = live._sub_lines(module, cols)[0]
+                self.assertEqual(jr._vlen(line), cols)
+                self.assertTrue(line.endswith("  10:00"), line)
+                self.assertIn("•", line)
+
+    def test_a_unit_with_no_dots_in_colour_is_its_title_then_elapsed(self):
+        board = jr.Board(clock=lambda: 100.0)
+        feed(board, *start(), mod_start("a"))
+        live = jr.LiveRenderer(lambda text: None, size=lambda: os.terminal_size((40, 10)), clock=lambda: 100.0, color=True)
+        line = live._sub_lines(next(iter(board.rows.values())).units["tests/a"], 39)[0]
+        self.assertEqual(line, "    Mod A".ljust(39 - 4) + "0:00")
 
     def test_shrinking_the_region_clears_the_old_lines(self):
         self.push(*start(), mod_start("a"), mod_start("b"))
-        self.push(("module_end", {"step": "tests", "module": "tests/a", "status": "ok", "ms": 1, "totals": None}),
-                  ("module_end", {"step": "tests", "module": "tests/b", "status": "ok", "ms": 1, "totals": None}))
+        self.push(("unit_end", {"step": "tests", "id": "tests/a", "status": "ok", "ms": 1, "totals": None}),
+                  ("unit_end", {"step": "tests", "id": "tests/b", "status": "ok", "ms": 1, "totals": None}))
         self.assertEqual(len(self.screen.text().splitlines()), 1)
 
     def test_colour_and_no_colour_glyphs(self):
@@ -1014,7 +1039,7 @@ class LiveTest(unittest.TestCase):
         self.push(*start(size=80))
         for i in range(80):
             name = f"m{i}"
-            self.push(mod_start(name), ("module_end", {"step": "tests", "module": f"tests/{name}", "status": "ok",
+            self.push(mod_start(name), ("unit_end", {"step": "tests", "id": f"tests/{name}", "status": "ok",
                                                        "ms": 1, "totals": (1, 0, 0, 0)}))
         self.push(("tests", {"step": "tests", "total": 7139, "fail": 2, "err": 0, "skip": 0}))
         lines = self.screen.text().splitlines()
@@ -1039,7 +1064,7 @@ class LiveTest(unittest.TestCase):
         self.assertEqual(jr._vcut("测试测试", 5), "测试")
         self.assertEqual(jr._vlen(jr._fit("测" * 40, "0:01", 30)), 30)
         self.dims = (40, 10)
-        self.push(*start(), ("module_start", {"step": "tests", "module": "tests/a", "title": "测试" * 20}),
+        self.push(*start(), ("unit_start", {"step": "tests", "id": "tests/a", "title": "测试" * 20}),
                   *[cls("a") for _ in range(100)])
         subs = self.live.region(self.board, 39, 8)[1:]
         self.assertEqual(len(subs), 1)
@@ -1056,7 +1081,7 @@ class LiveTest(unittest.TestCase):
         self.clock.advance(1)
         live.tick(self.board)
         self.assertEqual(len(writes), closed)
-        live.render(self.board, feed(self.board, ("module_end", {"step": "tests", "module": "tests/a",
+        live.render(self.board, feed(self.board, ("unit_end", {"step": "tests", "id": "tests/a",
                                                                  "status": "ok", "ms": 1, "totals": None}),
                                      ("step_end", {"id": "tests", "status": "ok", "ms": 0, "exit": None,
                                                    "summary": "late", "totals": None})))
@@ -1080,7 +1105,7 @@ class LiveTest(unittest.TestCase):
                      ("step_start", {"id": "verify.1", "n": 1, "title": "mvn", "parent": "verify"}))
                 if takes_over:
                     feed(b, ("reactor", {"step": "verify.1", "size": 1, "poms": [], "tests": True}),
-                         ("module_start", {"step": "verify.1", "module": "verify.1/a", "title": "Mod A"}))
+                         ("unit_start", {"step": "verify.1", "id": "verify.1/a", "title": "Mod A"}))
                 feed(b, ("quiet", {"step": "verify.1", "seconds": 25}))
                 self.assertEqual(b.rows["verify"].quiet, 25)
                 feed(b, ("step_end", {"id": "verify.1", "status": "ok", "ms": 30000, "exit": None, "summary": None,
@@ -1092,55 +1117,142 @@ class LiveTest(unittest.TestCase):
         self.assertIn(" · quiet 25s", self.screen.text())
 
 
-class CollapsedModuleLineTest(unittest.TestCase):
-    """Name-only in-flight modules share one comma-joined line under their row; modules with results keep sub-lines."""
+class UnitLinesTest(unittest.TestCase):
+    """Every running unit has a line under its row: its title padded to the row's longest, its dots, then its elapsed
+    time at the right edge.  When the region is short the newest fold onto one packed names line per row."""
 
     def setUp(self):
         reset_run_state()
         self.addCleanup(reset_run_state)
         self.board = jr.Board(clock=lambda: 100.0)
 
-    def region(self, *events, cols=60):
+    def region(self, *events, cols=60, rows=18):
         feed(self.board, *events)
-        live = jr.LiveRenderer(lambda text: None, size=lambda: os.terminal_size((cols + 1, 20)), clock=lambda: 100.0,
-                               color=False)
-        return live.region(self.board, cols, 18)
+        live = jr.LiveRenderer(lambda text: None, size=lambda: os.terminal_size((cols + 1, rows + 2)),
+                               clock=lambda: 100.0, color=False)
+        return live.region(self.board, cols, rows)
 
-    def test_name_only_modules_collapse_to_two_lines(self):
+    @staticmethod
+    def unit(id, title, step="tests"):
+        return ("unit_start", {"step": step, "id": f"{step}/{id}", "title": title})
+
+    def test_every_running_unit_has_its_own_line(self):
         for step in ("compile", "x-tool"):                      # data-driven: the step id does not matter
             with self.subTest(step=step):
                 reset_run_state()
                 self.board = jr.Board(clock=lambda: 100.0)
                 lines = self.region(*start(step, 4), mod_start("a", step), mod_start("b", step), mod_start("c", step))
-                self.assertEqual(len(lines), 2)
-                self.assertEqual(lines[1], "    A, B, C")
-
-    def test_modules_with_results_keep_their_own_sub_lines(self):
-        lines = self.region(*start(), mod_start("a"), mod_start("b"), cls("a"), cls("a", "fail"))
-        self.assertEqual(len(lines), 3)
-        self.assertEqual(lines[1], "    B")                       # name-only first, then the detail sub-lines
-        self.assertTrue(lines[2].startswith("    Mod A  "))
-
-    def test_a_module_with_a_first_result_leaves_the_collapsed_line_on_the_next_redraw(self):
-        lines = self.region(*start(), mod_start("a"), mod_start("b"))
-        self.assertEqual(lines[1], "    A, B")
-        lines = self.region(cls("b"))
-        self.assertEqual(lines[1], "    A")
-        self.assertTrue(lines[2].startswith("    Mod B  "))
-
-    def test_pom_modules_are_never_on_the_collapsed_line(self):
+                self.assertEqual(lines[1:], [f"    Mod {n}  ".ljust(60 - 4) + "0:00" for n in "ABC"])
+
+    def test_a_unit_with_results_keeps_its_start_order(self):
+        lines = self.region(*start(), mod_start("a"), mod_start("b"), cls("b"), cls("b", "fail"))
+        self.assertEqual(lines[1:], ["    Mod A  ".ljust(56) + "0:00", "    Mod B  •F".ljust(56) + "0:00"])
+
+    def test_titles_pad_to_the_longest_in_the_row(self):
+        lines = self.region(*start(), self.unit("x", "X"), self.unit("y", "Longer Y"),
+                            ("unit_dot", {"step": "tests", "id": "tests/x", "outcome": "pass"}))
+        self.assertEqual(lines[1:], ["    " + "X".ljust(8) + "  •".ljust(44) + "0:00",
+                                     "    Longer Y  ".ljust(56) + "0:00"])
+
+    def test_a_long_title_is_cut_at_half_the_line(self):
+        lines = self.region(*start(), self.unit("x", "x" * 50))
+        self.assertEqual(lines[1], "    " + "x" * 30 + "  ".ljust(22) + "0:00")
+
+    def test_pom_units_never_get_a_line(self):
         feed(self.board, ("step_start", {"id": "mvn", "n": 1, "title": "mvn", "parent": None}),
              ("reactor", {"step": "mvn", "size": 3, "poms": ["mvn/p"], "tests": False}),
-             ("module_start", {"step": "mvn", "module": "mvn/p", "title": "Mod P"}))
-        lines = self.region(("module_start", {"step": "mvn", "module": "mvn/a", "title": "Mod A"}),
-                            ("module_start", {"step": "mvn", "module": "mvn/b", "title": "Mod B"}))
-        self.assertEqual([line for line in lines if line.startswith("    ")], ["    A, B"])
+             ("unit_start", {"step": "mvn", "id": "mvn/p", "title": "Mod P"}))
+        lines = self.region(("unit_start", {"step": "mvn", "id": "mvn/a", "title": "Mod A"}),
+                            ("unit_start", {"step": "mvn", "id": "mvn/b", "title": "Mod B"}))
+        self.assertEqual([line for line in lines if line.startswith("    ")],
+                         ["    Mod A  ".ljust(56) + "0:00", "    Mod B  ".ljust(56) + "0:00"])
 
-    def test_the_prefix_uses_every_module_seen_not_only_those_in_flight(self):
-        lines = self.region(*start(), mod_start("a"), mod_start("b"), mod_start("c"),
-                            ("module_end", {"step": "tests", "module": "tests/b", "status": "ok", "ms": 1}),
-                            ("module_end", {"step": "tests", "module": "tests/c", "status": "ok", "ms": 1}))
-        self.assertEqual(lines[1], "    A")                      # still "Mod " stripped though only one is in flight
+    def test_the_newest_fold_onto_one_names_line(self):
+        lines = self.region(*start(), mod_start("a"), mod_start("b"), mod_start("c"), rows=3)
+        self.assertEqual(lines[1:], ["    Mod A  ".ljust(56) + "0:00", "    B, C"])
+
+    def test_the_slowest_unit_keeps_its_line(self):
+        clock = Clock()
+        self.board = jr.Board(clock=clock)
+        feed(self.board, *start(), mod_start("a"))
+        clock.advance(5)
+        feed(self.board, mod_start("b"))
+        clock.advance(5)
+        feed(self.board, mod_start("c"))
+        live = jr.LiveRenderer(lambda text: None, size=lambda: os.terminal_size((61, 5)), clock=clock, color=False)
+        self.assertEqual(live.region(self.board, 60, 3)[1:], ["    Mod A  ".ljust(56) + "0:10", "    B, C"])
+
+    def test_the_fold_prefix_uses_every_unit_seen_not_only_those_in_flight(self):
+        lines = self.region(*start(), mod_start("a"), mod_start("b"), mod_start("c"), mod_start("d"),
+                            ("unit_end", {"step": "tests", "id": "tests/b", "status": "ok", "ms": 1}), rows=3)
+        self.assertEqual(lines[2], "    C, D")                    # "Mod " stripped by all four, not only those folded
+
+    def test_a_tests_row_with_units_has_no_current_file_line(self):
+        lines = self.region(*start(), ("step_progress", {"step": "tests", "text": "test_x.py"}), mod_start("a"))
+        self.assertEqual(lines[1:], ["    Mod A  ".ljust(56) + "0:00"])
+
+    def test_a_tests_row_without_units_keeps_its_current_file_line(self):
+        lines = self.region(("step_start", {"id": "py", "n": 1, "title": "Py", "parent": None}),
+                            ("units", {"step": "py", "total": 3, "kind": "tests"}),
+                            ("step_progress", {"step": "py", "text": "test_x.py"}))
+        self.assertEqual(lines[1:], ["    test_x.py"])
+
+    def test_a_tests_row_whose_units_have_all_closed_shows_its_current_file_line_again(self):
+        lines = self.region(("step_start", {"id": "py", "n": 1, "title": "Py", "parent": None}),
+                            ("units", {"step": "py", "total": 3, "kind": "tests"}),
+                            ("unit_start", {"step": "py", "id": "a.py", "title": "a.py", "src": "file"}),
+                            ("unit_end", {"step": "py", "id": "a.py", "status": "ok", "src": "file"}),
+                            ("step_progress", {"step": "py", "text": "test_x.py"}))
+        self.assertEqual(lines[1:], ["    test_x.py"])     # "while it has a running unit" (D17)
+
+
+class StepEndClosesUnitsTest(unittest.TestCase):
+    """A unit still open when its step ends is closed there, so no unit line outlives its row."""
+
+    def setUp(self):
+        reset_run_state()
+        self.addCleanup(reset_run_state)
+        self.clock = Clock()
+        self.board = jr.Board(clock=self.clock)
+
+    def end(self, status, *events):
+        feed(self.board, *start(), mod_start("a"), *events)
+        self.clock.advance(2)
+        feed(self.board, ("step_end", {"id": "tests", "status": status, "ms": 2000, "exit": 0, "summary": None,
+                                       "totals": None}))
+        return self.board.rows["tests"].units["tests/a"]
+
+    def test_an_open_unit_closes_ok_with_its_elapsed(self):
+        unit = self.end("ok", cls("a"))
+        self.assertEqual((unit.status, unit.ms), ("ok", 2000))
+
+    def test_an_open_unit_with_a_fail_dot_closes_fail(self):
+        self.assertEqual(self.end("ok", cls("a", "fail")).status, "fail")
+
+    def test_an_open_unit_of_a_failed_row_closes_fail(self):
+        self.assertEqual(self.end("fail").status, "fail")
+
+    def test_an_ended_unit_is_left_alone(self):
+        unit = self.end("fail", ("unit_end", {"step": "tests", "id": "tests/a", "status": "ok", "ms": 7}))
+        self.assertEqual((unit.status, unit.ms), ("ok", 7))
+
+    def test_open_file_script_and_row_units_close_with_no_finished_module_line(self):
+        for status, dot in (("ok", "pass"), ("ok", "fail"), ("fail", "pass")):
+            with self.subTest(status=status, dot=dot):
+                board = jr.Board(detail="modules", clock=self.clock)
+                feed(board, ("step_start", {"id": "s", "n": 1, "title": "Script", "parent": None}),
+                     *[("unit_start", {"step": "s", "id": src, "title": src, "src": src}) for src in ("file", "script")],
+                     ("step_start", {"id": "s.1", "n": 1, "title": "row", "parent": "s"}),
+                     *[("unit_dot", {"step": "s", "id": i, "outcome": dot}) for i in ("file", "script", "s.1")])
+                self.clock.advance(2)
+                blocks = feed(board, ("step_end", {"id": "s", "status": status, "ms": 2000, "exit": 0,
+                                                   "summary": None, "totals": None}))
+                units = board.rows["s"].units
+                want = "fail" if "fail" in (status, dot) else "ok"
+                self.assertEqual({i: (u.src, u.status, u.in_progress) for i, u in units.items()},
+                                 {"file": ("file", want, False), "script": ("script", want, False),
+                                  "s.1": ("row", want, False)})
+                self.assertEqual([b for b in blocks if b[0] == "module"], [])
 
 
 class LiveConsoleTest(unittest.TestCase):
@@ -1725,8 +1837,8 @@ class RowStatusTest(RowCase):
         b = self.board()
         events = [step_start("a", "Build"), ("reactor", {"step": "a", "size": 3, "poms": [], "tests": False})]
         for name, status in (("x", "fail"), ("y", "ok"), ("z", "fail")):
-            events += [("module_start", {"step": "a", "module": name, "title": f"Mod {name.upper()}"}),
-                       ("module_end", {"step": "a", "module": name, "status": status, "ms": 1, "totals": None})]
+            events += [("unit_start", {"step": "a", "id": name, "title": f"Mod {name.upper()}"}),
+                       ("unit_end", {"step": "a", "id": name, "status": status, "ms": 1, "totals": None})]
         blocks = feed(b, *events, step_end("a", "fail", exit=1))
         self.assertIn("2 failed: Mod X +1", plain(b, blocks, self.clock))
 
@@ -1859,9 +1971,22 @@ class FoldTest(PendingCase):
     def test_then_module_lines_fold(self):
         region = self.live().region(self.busy(), 79, 5)
         self.assertEqual(len(region), 5)
-        self.assertIn("    +2 more running", region)
+        self.assertIn("    C, D", region)
         self.assertEqual(region[-1], "○ +6 more pending")
 
+    def test_folding_across_two_rows_takes_the_newest_first_wherever_it_runs(self):
+        b = self.planned([("one", "One"), ("two", "Two")])
+        feed(b, *start("one", size=2, title="One"), *start("two", size=2, title="Two"))
+        for step, name in (("one", "a"), ("two", "c"), ("one", "b"), ("two", "d")):     # started in this order
+            self.clock.advance(1)
+            feed(b, mod_start(name, step))
+        self.assertEqual(sum(line.startswith("    Mod ") for line in self.live().region(b, 79, 6)), 4)
+        region = self.live().region(b, 79, 5)
+        # three lines for units: D, B, then C fold (newest first, across both rows), each under its own row
+        self.assertEqual([line.split()[0] for line in region], ["⠋", "Mod", "B", "⠋", "C,"])
+        self.assertTrue(region[1].startswith("    Mod A "), region)
+        self.assertEqual((region[2], region[4]), ("    B", "    C, D"))
+
 
 class ModuleLinesTest(RowCase):
     def test_no_finished_module_lines_at_actionable(self):
@@ -1875,21 +2000,21 @@ class ModuleLinesTest(RowCase):
         blocks = feed(b, *maven_events())
         self.assertIn("    F Mod B  0:02  10 tests, 2 failed\n", plain(b, blocks, self.clock))
 
-    def test_inflight_sub_line_unchanged(self):
+    def test_inflight_sub_line_ends_with_its_elapsed_time(self):
         b = self.board()
         feed(b, *start(), mod_start("a"), cls("a"), cls("a", "fail"))
         live = jr.LiveRenderer(lambda t: None, size=lambda: os.terminal_size((60, 10)), clock=self.clock, color=False)
-        self.assertEqual(live.region(b, 59, 8)[1:], ["    Mod A  •F"])
+        self.assertEqual(live.region(b, 59, 8)[1:], ["    Mod A  •F".ljust(59 - 4) + "0:00"])
 
 
 class SetupRowTest(RowCase):
     def events(self, pom_status="ok"):
         return [step_start("tests", "Tests"),
                 ("reactor", {"step": "tests", "size": 3, "poms": ["p0", "p1"], "tests": True}),
-                ("module_start", {"step": "tests", "module": "p0", "title": "Parent"}),
-                ("module_end", {"step": "tests", "module": "p0", "status": "ok", "ms": 1, "totals": None}),
-                ("module_start", {"step": "tests", "module": "p1", "title": "Bom"}),
-                ("module_end", {"step": "tests", "module": "p1", "status": pom_status, "ms": 1, "totals": None})]
+                ("unit_start", {"step": "tests", "id": "p0", "title": "Parent"}),
+                ("unit_end", {"step": "tests", "id": "p0", "status": "ok", "ms": 1, "totals": None}),
+                ("unit_start", {"step": "tests", "id": "p1", "title": "Bom"}),
+                ("unit_end", {"step": "tests", "id": "p1", "status": pom_status, "ms": 1, "totals": None})]
 
     def test_setup_finishes_when_poms_done(self):
         b = self.board()
diff --git a/juneau-run/src/test/python/test_scripts_checks.py b/juneau-run/src/test/python/test_scripts_checks.py
index 5115859b3c..54a0b77345 100644
--- a/juneau-run/src/test/python/test_scripts_checks.py
+++ b/juneau-run/src/test/python/test_scripts_checks.py
@@ -76,9 +76,29 @@ class ChecksTest(ConsoleCase):
         self.assertEqual([u["outcome"] for u in self.of("unit")], ["fail", "pass", "fail"])
         self.assertIn("F#F  3/3", self.screen())
 
-    def test_progress_text_is_check_name(self):
+    def test_one_script_unit_per_check_and_no_progress_text(self):
         run.checks("pre", "Prechecks", [IDENTITY, UPSTREAM, UNREVIEWED])
-        self.assertEqual([p["text"] for p in self.of("step_progress")], ["identity", "upstream", "unreviewed"])
+        self.assertEqual([(u["step"], u["id"], u["title"], u["src"]) for u in self.of("unit_start")],
+                         [("pre", "identity", "identity", "script"), ("pre", "upstream", "upstream", "script"),
+                          ("pre", "unreviewed", "unreviewed", "script")])
+        self.assertEqual([(u["id"], u["status"]) for u in self.of("unit_end")],
+                         [("identity", "fail"), ("upstream", "ok"), ("unreviewed", "fail")])
+        self.assertEqual(self.of("step_progress"), [])
+        row = jr._console.board.rows["pre"]
+        self.assertEqual((row.failed_units, row.reason), ([], "git user.email is not set +1"))   # keeps its reason
+
+    def test_a_step_from_an_older_juneau_run_with_no_unit_still_runs_the_checks(self):
+        class OldStep:
+            failed = False
+
+            def fail(self):
+                self.failed = True
+
+        st = OldStep()
+        result = run.drive_checks("pre", st, [UPSTREAM, IDENTITY])
+        self.assertEqual((result.ok, st.failed), (False, True))
+        self.assertEqual([u["outcome"] for u in self.of("unit")], ["pass", "fail"])
+        self.assertEqual(self.of("unit_start"), [])
 
     def test_fail_sets_reason_and_hint(self):
         result = run.checks("pre", "Prechecks", [UPSTREAM, IDENTITY])
@@ -145,7 +165,13 @@ class MarkersTest(unittest.TestCase):
         with redirect_stdout(out):
             run.checks("pre", "Prechecks", [IDENTITY, UPSTREAM, UNREVIEWED])
         found = markers(out.getvalue())
-        self.assertEqual([m["ev"] for m in found], ["step", "note", "note", "end"])
+        # each check is a v1 child step, <row>/<slug>; its error note follows its end
+        self.assertEqual([m["ev"] for m in found],
+                         ["step", "step", "end", "note", "step", "end", "step", "end", "note", "end"])
+        self.assertEqual([(m["id"], m["n"], m.get("parent")) for m in found if m["ev"] == "step"][1:],
+                         [("pre/identity", 1, "pre"), ("pre/upstream", 2, "pre"), ("pre/unreviewed", 3, "pre")])
+        self.assertEqual([(m["id"], m["status"]) for m in found if m["ev"] == "end"],
+                         [("pre/identity", "fail"), ("pre/upstream", "ok"), ("pre/unreviewed", "fail"), ("pre", "fail")])
         self.assertEqual([m["text"] for m in found if m["ev"] == "note"],
                          ["identity: git user.email is not set", "unreviewed: 2 unreviewed paths"])
         self.assertEqual({m.get("level") for m in found if m["ev"] == "note"}, {"error"})
diff --git a/juneau-run/src/test/python/test_scripts_packaging.py b/juneau-run/src/test/python/test_scripts_packaging.py
index d196811093..830be36f22 100644
--- a/juneau-run/src/test/python/test_scripts_packaging.py
+++ b/juneau-run/src/test/python/test_scripts_packaging.py
@@ -69,6 +69,7 @@ class DescriptorTest(unittest.TestCase):
         sources = [f.findtext(f"{ASM}source") for f in self.root.findall(f"{ASM}files/{ASM}file")]
         self.assertEqual(sources, ["${project.basedir}/src/main/python/bootstrap.py",
                                    "${project.basedir}/src/main/python/pytest_plugin/juneau_run_pytest.py",
+                                   "${project.basedir}/src/main/js/playwright_reporter/juneau_run_playwright.cjs",
                                    "${project.basedir}/../LICENSE", "${project.basedir}/../NOTICE"])
         for s in sources:
             self.assertTrue(Path(s.replace("${project.basedir}", str(MODULE_ROOT))).is_file(), s)
@@ -77,7 +78,8 @@ class DescriptorTest(unittest.TestCase):
         with tempfile.TemporaryDirectory() as tmp:
             with zipfile.ZipFile(build_zip(Path(tmp) / "t.zip")) as z:
                 names = sorted(z.namelist())
-        self.assertEqual(names, sorted(package_files() + ["LICENSE", "NOTICE", "bootstrap.py", "pytest_plugin/juneau_run_pytest.py"]))
+        self.assertEqual(names, sorted(package_files() + ["LICENSE", "NOTICE", "bootstrap.py", "pytest_plugin/juneau_run_pytest.py",
+                                                      "playwright_reporter/juneau_run_playwright.cjs"]))
 
 
 @unittest.skipUnless(os.environ.get("JUNEAU_SCRIPTS_CHECK_ZIP"), "set JUNEAU_SCRIPTS_CHECK_ZIP to a mvn package zip")
@@ -88,7 +90,8 @@ class RealZipTest(unittest.TestCase):
     def test_real_zip_holds_exactly_the_toolkit(self):
         with zipfile.ZipFile(os.environ["JUNEAU_SCRIPTS_CHECK_ZIP"]) as z:
             names = sorted(n for n in z.namelist() if not n.endswith("/"))
-        self.assertEqual(names, sorted(package_files() + ["LICENSE", "NOTICE", "bootstrap.py", "pytest_plugin/juneau_run_pytest.py"]))
+        self.assertEqual(names, sorted(package_files() + ["LICENSE", "NOTICE", "bootstrap.py", "pytest_plugin/juneau_run_pytest.py",
+                                                      "playwright_reporter/juneau_run_playwright.cjs"]))
 
 
 class PomTest(unittest.TestCase):
diff --git a/juneau-run/src/test/python/test_scripts_run.py b/juneau-run/src/test/python/test_scripts_run.py
index cdb5ad09f1..66692cd4a0 100644
--- a/juneau-run/src/test/python/test_scripts_run.py
+++ b/juneau-run/src/test/python/test_scripts_run.py
@@ -70,6 +70,14 @@ class SayAndStepTest(PlainCase):
             st.skip()
         self.assertEqual(out.getvalue(), "")
 
+    def test_a_null_steps_unit_yields_a_handle_that_does_nothing(self):
+        out = io.StringIO()
+        with redirect_stdout(out), run.step("s", "Step") as st:
+            with st.unit("a", "A") as u:
+                u.dot("fail")
+                u.fail()
+        self.assertEqual((out.getvalue(), st.status), ("", "ok"))
+
     def test_routed_and_console_requested_are_false(self):
         self.assertFalse(run.routed())
         self.assertFalse(run.console_requested())
diff --git a/juneau-run/src/test/python/test_unit_model.py b/juneau-run/src/test/python/test_unit_model.py
new file mode 100644
index 0000000000..7a5349e872
--- /dev/null
+++ b/juneau-run/src/test/python/test_unit_model.py
@@ -0,0 +1,321 @@
+# ***************************************************************************************************************************
+# * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.  See the NOTICE file
+# * distributed with this work for additional information regarding copyright ownership.  The ASF licenses this file
+# * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance
+# * with the License.  You may obtain a copy of the License at
+# *
+# *  http://www.apache.org/licenses/LICENSE-2.0
+# *
+# * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
+# * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the
+# * specific language governing permissions and limitations under the License.
+# ***************************************************************************************************************************
+"""The board's Unit model: unit_start / unit_dot / unit_end for each src, and the gates that keep file and script units
+out of the Maven module bar, the Setup row, the heartbeat and History."""
+
+import unittest
+
+from support import jr, reset_run_state
+
+
+class Clock:
+    def __init__(self):
+        self.t = 100.0
+
+    def __call__(self):
+        return self.t
+
+
+class UnitModelTest(unittest.TestCase):
+    def setUp(self):
+        reset_run_state()
+        self.addCleanup(reset_run_state)
+        self.clock = Clock()
+        self.board = jr.Board(clock=self.clock)
+        self.board.apply("step_start", {"id": "s", "n": 1, "title": "Script tests", "parent": None})
+        self.row = self.board.rows["s"]
+
+    def apply(self, kind, **f):
+        return self.board.apply(kind, dict(step="s", **f))
+
+    def test_each_src_opens_a_unit_with_its_src(self):
+        for src in ("sub", "row", "file", "script"):
+            self.apply("unit_start", id=f"u-{src}", title=f"U {src}", src=src)
+        self.assertEqual([(u.id, u.title, u.src) for u in self.row.units.values()],
+                         [("u-sub", "U sub", "sub"), ("u-row", "U row", "row"), ("u-file", "U file", "file"),
+                          ("u-script", "U script", "script")])
+        self.assertTrue(all(u.in_progress for u in self.row.units.values()))
+
+    def test_a_missing_src_is_sub(self):
+        self.apply("unit_start", id="m", title="M")
+        self.assertEqual(self.row.units["m"].src, "sub")
+
+    def test_dots_append_outcomes(self):
+        self.apply("unit_start", id="f", title="F", src="file")
+        self.apply("unit_dot", id="f", outcome="pass")
+        self.apply("unit_dot", id="f", outcome="fail")
+        self.assertEqual(self.row.units["f"].outcomes, ["pass", "fail"])
+
+    def test_an_orphan_dot_opens_its_unit_titled_by_its_id_with_no_bar(self):
+        self.clock.t = 105.0
+        self.apply("unit_dot", id="o", outcome="pass")
+        u = self.row.units["o"]
+        self.assertEqual((u.title, u.t0, u.outcomes, u.src), ("o", 105.0, ["pass"], "script"))
+        self.assertNotIn("o", self.board.unit_rows)
+        self.assertFalse(self.row.bar_units)
+
+    def test_an_orphan_maven_dot_opens_a_module(self):
+        self.apply("unit_dot", id="m", outcome="pass", src="sub")      # _Dispatcher's class_done translation
+        self.assertEqual(self.row.units["m"].src, "sub")
+        self.assertIs(self.board.unit_rows["m"], self.row)
+
+    def test_a_repeat_start_resets_t0_for_sub_and_row(self):
+        for src in ("sub", "row"):
+            with self.subTest(src=src):
+                self.clock.t = 100.0
+                self.apply("unit_start", id=src, title="old", src=src)
+                self.clock.t = 110.0
+                self.apply("unit_start", id=src, title="new", src=src)
+                self.assertEqual((self.row.units[src].title, self.row.units[src].t0), ("new", 110.0))
+
+    def test_a_repeat_start_keeps_an_open_file_or_script_unit(self):
+        for src in ("file", "script"):
+            with self.subTest(src=src):
+                self.clock.t = 100.0
+                self.apply("unit_start", id=src, title="old", src=src)
+                self.apply("unit_dot", id=src, outcome="pass")
+                self.clock.t = 110.0
+                self.apply("unit_start", id=src, title="new", src=src)
+                u = self.row.units[src]
+                self.assertEqual((u.title, u.t0, u.outcomes), ("old", 100.0, ["pass"]))
+
+    def test_a_start_after_a_file_or_script_unit_closed_opens_it_fresh(self):
+        self.apply("unit_start", id="f", title="F", src="file")
+        self.apply("unit_dot", id="f", outcome="fail")
+        self.apply("unit_end", id="f", status="fail", src="file")
+        self.clock.t = 120.0
+        self.apply("unit_start", id="f", title="F", src="file")
+        u = self.row.units["f"]
+        self.assertEqual((u.status, u.t0, u.outcomes), (None, 120.0, []))
+
+    def test_file_and_script_units_end_without_a_box_a_cell_or_a_finished_line(self):
+        self.board.detail = "all"
+        for src in ("file", "script"):
+            with self.subTest(src=src):
+                self.apply("unit_start", id=src, title=src, src=src)
+                self.clock.t += 2
+                blocks = self.apply("unit_end", id=src, status="fail", src=src)
+                u = self.row.units[src]
+                self.assertEqual((u.status, u.ms, u.box, u.unit_index), ("fail", 2000, None, None))
+                self.assertEqual(blocks, [])
+        # a failed file names its file in the row's text; a failed script unit never does
+        self.assertEqual((self.row.order, self.row.units_done, self.row.failed_units), ([], 0, ["file"]))
+
+    def test_a_file_unit_with_a_fail_dot_is_named_once(self):
+        self.apply("unit_start", id="f", title="test_f.py", src="file")
+        self.apply("unit_dot", id="f", outcome="fail")
+        self.apply("unit_dot", id="f", outcome="fail")
+        self.apply("unit_end", id="f", status="ok", src="file")
+        self.assertEqual(self.row.failed_units, ["test_f.py"])
+
+    def test_a_fail_dot_on_a_closed_file_unit_names_it_without_reopening_it(self):
+        self.apply("unit_start", id="f", title="test_f.py", src="file")
+        self.apply("unit_end", id="f", status="ok", src="file")
+        self.apply("unit_dot", id="f", outcome="fail")       # a late teardown error
+        u = self.row.units["f"]
+        self.assertEqual((u.status, u.in_progress, self.row.failed_units), ("ok", False, ["test_f.py"]))
+
+    def test_a_failed_script_unit_is_never_named(self):
+        self.apply("unit_start", id="c", title="identity", src="script")
+        self.apply("unit_dot", id="c", outcome="fail")
+        self.apply("unit_end", id="c", status="fail", src="script")
+        self.assertEqual(self.row.failed_units, [])
+
+    def test_the_close_pass_closes_open_files_fail_but_names_only_those_with_a_fail_dot(self):
+        self.apply("unit_start", id="f", title="test_f.py", src="file")
+        self.apply("unit_start", id="g", title="test_g.py", src="file")
+        self.apply("unit_dot", id="f", outcome="fail")
+        self.board.apply("step_end", {"id": "s", "status": "fail", "ms": 1, "exit": 1, "summary": None,
+                                      "totals": None})
+        self.assertEqual([self.row.units[i].status for i in ("f", "g")], ["fail", "fail"])  # g ends fail with its row
+        self.assertEqual(self.row.failed_units, ["test_f.py"])     # an interrupted run doesn't name every open file
+
+    def test_the_close_pass_does_not_name_an_open_file_of_a_passing_row(self):
+        self.apply("unit_start", id="f", title="test_f.py", src="file")
+        self.board.apply("step_end", {"id": "s", "status": "ok", "ms": 1, "exit": 0, "summary": None,
+                                      "totals": None})
+        self.assertEqual((self.row.units["f"].status, self.row.failed_units), ("ok", []))
+
+    def feed_playwright(self, lines):
+        """The unit events a PlaywrightParser makes of lines, then of its finish(1), applied to the board."""
+        parser = jr.PlaywrightParser()
+        parser.step_id = "s"
+        for ev in [e for line in lines for e in parser.on_line(line)] + parser.finish(1):
+            if ev[0] == "unit_start":
+                self.apply("unit_start", id=ev[1], title=ev[2], src=ev[3])
+            elif ev[0] == "unit_dot":
+                self.apply("unit_dot", id=ev[1], outcome=ev[2])
+            elif ev[0] == "unit_end":
+                self.apply("unit_end", id=ev[1], status=ev[2], src=ev[3])
+
+    def test_an_interrupted_playwright_run_names_only_its_failed_file_and_still_fails(self):
+        lines = ['##playwright {"ev":"hello","v":1}', '##playwright {"ev":"begin","f":{"a.ts":3,"b.ts":3,"c.ts":3,"d.ts":3}}']
+        lines += [f'##playwright {{"ev":"start","f":"{c}.ts"}}' for c in "abcd"]
+        lines += ['##playwright {"ev":"test","f":"a.ts","o":"fail"}']
+        lines += [f'##playwright {{"ev":"test","f":"{c}.ts","o":"skip"}}' for c in "bcd"]     # interrupted
+        self.feed_playwright(lines)
+        self.apply("tests", total=4, fail=1, err=0, skip=0)
+        self.board.apply("step_end", {"id": "s", "status": "fail", "ms": 1, "exit": 1, "summary": None,
+                                      "totals": None})
+        self.assertEqual(self.row.failed_units, ["a.ts"])
+        self.assertEqual(self.row.status, "fail")
+        self.assertEqual(jr.row_text(self.row, jr.PLAIN_GLYPHS, self.clock())[2], "4 tests, 1 failed: a.ts")
+
+    def test_a_playwright_test_whose_retry_never_ran_names_its_file(self):
+        # --max-failures stopped the run with a.ts's retry queued: the reporter's onEnd sends its outcome so far
+        lines = ['##playwright {"ev":"hello","v":1}', '##playwright {"ev":"begin","f":{"a.ts":1,"b.ts":1}}',
+                 '##playwright {"ev":"start","f":"a.ts"}', '##playwright {"ev":"start","f":"b.ts"}',
+                 '##playwright {"ev":"test","f":"b.ts","o":"fail"}', '##playwright {"ev":"test","f":"a.ts","o":"fail"}']
+        self.feed_playwright(lines)
+        self.board.apply("step_end", {"id": "s", "status": "fail", "ms": 1, "exit": 1, "summary": None,
+                                      "totals": None})
+        self.assertEqual(([self.row.units[f].status for f in ("a.ts", "b.ts")], self.row.failed_units),
+                         (["fail", "fail"], ["b.ts", "a.ts"]))
+
+    def test_a_failing_script_tests_row_reads_totals_then_its_files(self):
+        self.board.apply("units", {"step": "s", "total": 2, "kind": "files"})
+        for f in ("scripts/tests/test_push.py", "scripts/tests/test_release.py"):
+            self.apply("unit_start", id=f, title=f, src="file")
+            self.apply("unit_end", id=f, status="fail", src="file")
+        self.apply("tests", total=107, fail=2, err=0, skip=0)
+        self.board.apply("step_end", {"id": "s", "status": "fail", "ms": 1, "exit": 1, "summary": None,
+                                      "totals": None})
+        text = jr.row_text(self.row, jr.PLAIN_GLYPHS, self.clock())[2]
+        self.assertEqual(text, "107 tests, 2 failed: scripts/tests/test_push.py +1")
+
+    def test_a_failed_maven_module_outside_tests_mode_keeps_its_count_text(self):
+        self.board.apply("reactor", {"step": "s", "size": 2, "poms": [], "tests": False})
+        self.apply("unit_start", id="s/a", title="Mod A", src="sub")
+        self.apply("unit_end", id="s/a", status="fail", ms=5, src="sub")
+        self.apply("tests", total=12, fail=1, err=0, skip=0)
+        self.board.apply("step_end", {"id": "s", "status": "fail", "ms": 1, "exit": 1, "summary": None,
+                                      "totals": None})
+        self.assertEqual(jr.row_text(self.row, jr.PLAIN_GLYPHS, self.clock())[2], "1 failed: Mod A")
+
+    def test_unit_end_keeps_a_given_ms(self):
+        self.apply("unit_start", id="f", title="F", src="script")
+        self.apply("unit_end", id="f", status="ok", ms=1234, src="script")
+        self.assertEqual(self.row.units["f"].ms, 1234)
+
+    def test_file_and_script_units_make_no_box_bar(self):
+        self.apply("unit_start", id="f", title="F", src="file")
+        cells, count, *_ = jr.row_text(self.row, jr.PLAIN_GLYPHS, self.clock())
+        self.assertEqual((cells, count), (None, ""))
+
+    def test_sub_and_row_units_still_box(self):
+        self.apply("unit_start", id="r", title="R", src="row")
+        self.apply("unit_end", id="r", status="ok", ms=5, src="row")
+        self.assertEqual((self.row.order, self.row.units["r"].box), (["r"], "pass"))
+
+    def test_step_end_grants_only_bar_units(self):
+        for src in ("sub", "file", "script"):
+            self.apply("unit_start", id=src, title=src, src=src)
+        self.board.apply("step_end", {"id": "s", "status": "ok", "ms": 1, "exit": 0, "summary": None, "totals": None})
+        self.assertEqual(self.row.order, ["sub"])
+        self.assertEqual([self.row.units[i].box for i in ("file", "script")], [None, None])
+
+    def test_file_and_script_ids_are_not_registered_board_wide(self):
+        self.apply("unit_start", id="identity", title="identity", src="script")
+        self.apply("unit_start", id="a.py", title="a.py", src="file")
+        self.apply("unit_start", id="s/m", title="M", src="sub")
+        self.assertEqual(set(self.board.unit_rows), {"s/m"})
+
+    def test_compiled_and_tested_still_read_module(self):
+        self.board.apply("reactor", {"step": "s", "size": 2, "poms": [], "tests": False})
+        self.apply("unit_start", id="s/a", title="A", src="sub")
+        self.apply("module_compiled", module="s/a", ok=True)
+        self.assertEqual(self.row.units["s/a"].box, "pass")
+        self.board.apply("reactor", {"step": "s", "size": 2, "poms": [], "tests": True})
+        self.apply("unit_start", id="s/b", title="B", src="sub")
+        self.apply("unit_dot", id="s/b", outcome="fail", src="sub")
+        self.apply("module_tested", module="s/b")
+        self.assertEqual(self.row.units["s/b"].box, "fail")
+
+    def test_module_events_leave_file_and_script_units_alone(self):
+        self.board.apply("reactor", {"step": "s", "size": 2, "poms": [], "tests": True})
+        for src in ("file", "script"):
+            self.apply("unit_start", id=src, title=src, src=src)
+            self.apply("unit_dot", id=src, outcome="pass")
+            self.apply("module_compiled", module=src, ok=False)
+            self.apply("module_tested", module=src)
+        self.assertEqual(([u.box for u in self.row.units.values()], self.row.order, self.row.units_done),
+                         ([None, None], [], 0))
+        self.assertEqual("".join(jr.unit_bar(self.row.unit_view(), self.row.units_total, jr.PLAIN_GLYPHS)),
+                         jr.PENDING * 2)        # nor are they in flight on the module bar
+
+    def test_an_orphan_dot_then_a_sub_start_makes_a_module_that_fails_into_the_bar(self):
+        self.board.apply("reactor", {"step": "s", "size": 2, "poms": [], "tests": False})
+        self.apply("unit_dot", id="s/a", outcome="fail")        # no src: a script unit, for now
+        self.apply("unit_start", id="s/a", title="Mod A", src="sub")
+        self.apply("unit_end", id="s/a", status="fail", ms=5, src="sub")
+        u = self.row.units["s/a"]
+        self.assertEqual((u.src, u.box, self.row.failed_units, self.row.units_done), ("sub", "fail", ["Mod A"], 1))
+        self.assertIs(self.board.unit_rows["s/a"], self.row)
+
+    def test_a_sub_start_after_an_orphan_end_puts_the_unit_back_in_progress(self):
+        self.apply("unit_dot", id="s/a", outcome="fail")
+        self.apply("unit_end", id="s/a", status="fail", ms=7)      # no src: a closed script unit, for now
+        self.apply("unit_start", id="s/a", title="Mod A", src="sub")
+        u = self.row.units["s/a"]
+        self.assertEqual((u.src, u.status, u.ms, u.outcomes, u.in_progress), ("sub", None, None, [], True))
+
+    def test_an_orphan_end_with_no_src_is_a_script_unit_and_a_bar_end_with_no_ms_is_timed(self):
+        self.apply("unit_end", id="o", status="fail")
+        self.assertEqual((self.row.units["o"].src, self.row.order, self.row.failed_units), ("script", [], []))
+        self.apply("unit_start", id="s/a", title="A", src="sub")
+        self.clock.t += 3
+        self.apply("unit_end", id="s/a", status="ok", src="sub")
+        self.assertEqual(self.row.units["s/a"].ms, 3000)
+
+
+class SubRowUnitTest(unittest.TestCase):
+    def setUp(self):
+        reset_run_state()
+        self.addCleanup(reset_run_state)
+        self.board = jr.Board(clock=Clock())
+        self.board.apply("step_start", {"id": "verify", "n": 1, "title": "Verify", "parent": None})
+        self.board.apply("step_start", {"id": "verify.1", "n": 1, "title": "mvn verify", "parent": "verify"})
+        self.row = self.board.rows["verify"]
+
+    def test_a_sub_row_is_a_row_unit(self):
+        u = self.row.units["verify.1"]
+        self.assertEqual((u.src, u.title, u.in_progress), ("row", "mvn verify", True))
+
+    def test_a_maven_sub_row_hands_the_bar_and_the_lines_to_its_modules(self):
+        self.board.apply("reactor", {"step": "verify.1", "size": 2, "poms": [], "tests": False})
+        self.board.apply("unit_start", {"step": "verify.1", "id": "verify.1/a", "title": "Mod A", "src": "sub"})
+        self.assertEqual([(u.id, u.src) for u in self.row.units.values()], [("verify.1/a", "sub")])
+
+    def test_a_generic_sub_row_keeps_its_unit_until_its_end(self):
+        self.board.apply("step_end", {"id": "verify.1", "status": "ok", "ms": 10, "exit": 0, "summary": None,
+                                      "totals": None})
+        u = self.row.units["verify.1"]
+        self.assertEqual((u.status, u.box, u.in_progress), ("ok", "pass", False))
+
+
+class HeartbeatTest(unittest.TestCase):
+    def test_the_heartbeat_counts_only_sub_and_row_units(self):
+        clock = Clock()
+        board = jr.Board(clock=clock)
+        board.apply("step_start", {"id": "s", "n": 1, "title": "Tests", "parent": None})
+        for src in ("sub", "row", "file", "script"):
+            board.apply("unit_start", {"step": "s", "id": src, "title": src, "src": src})
+        out = []
+        plain = jr.PlainRenderer(out.append, heartbeat=True, clock=clock)
+        clock.t += jr.HEARTBEAT_SECONDS
+        plain.tick(board)
+        self.assertIn("· 2 modules running", "".join(out))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/scripts/tests/test_push.py b/scripts/tests/test_push.py
index 7d01401b84..1ca101aecd 100644
--- a/scripts/tests/test_push.py
+++ b/scripts/tests/test_push.py
@@ -45,9 +45,11 @@ import hashlib
 import importlib.util
 import inspect
 import io
+import json
 import os
 import pathlib
 import subprocess
+import sys
 from pathlib import Path
 
 import pytest
@@ -1474,3 +1476,38 @@ class TestConsoleDefault:
         with pytest.raises(SystemExit):
             push_module._main()
         assert "condensed (default)" in " ".join(capsys.readouterr().out.split())
+
+
+def test_prechecks_opens_a_unit_and_a_child_step_per_check(
+    push_module, docs_repo, juneau_root, monkeypatch, capsys
+):
+    """
+    The Prechecks row draws one line per check while it runs: on the bus each check is a script unit, and
+    under RUN_MARKERS=1 a prechecks/<check> child step.  A failed check leaves the row its reason text, not a unit name.
+    """
+    _local_only_commit(docs_repo)
+    _run(docs_repo, "git", "config", "--unset", "user.email")
+    listed = push_module.prechecks(juneau_root)
+
+    jr = sys.modules["juneau_run"]
+    seen = []
+    board = jr.Board()
+    monkeypatch.setattr(jr, "_subscribers", list(jr._subscribers))     # the subscribers below go with the test
+    jr.subscribe(lambda kind, fields: seen.append((kind, dict(fields))))
+    jr.subscribe(board.apply)
+    monkeypatch.setenv("RUN_MARKERS", "1")
+
+    push_module.toolkit("run").checks("prechecks", "Prechecks", listed)
+
+    starts = [f for k, f in seen if k == "unit_start"]
+    assert [(f["step"], f["title"], f["src"]) for f in starts] == [("prechecks", c.name, "script") for c in listed]
+    found = [json.loads(line[len("##run "):]) for line in capsys.readouterr().out.splitlines()
+             if line.startswith("##run ")]
+    children = [m["id"] for m in found if m["ev"] == "step" and m.get("parent") == "prechecks"]
+    assert children == ["prechecks/" + push_module.toolkit("run")._slug(c.name) for c in listed]
+    ends = {m["id"]: m["status"] for m in found if m["ev"] == "end"}
+    assert ends["prechecks/docs-identity"] == "fail"
+    row = board.rows["prechecks"]
+    text = jr.row_text(row, jr.PLAIN_GLYPHS, board.clock())[2]
+    assert (row.status, row.failed_units, text) == ("fail", [], row.reason)     # the check's text, not a unit's name
+    assert "user.email" in text
diff --git a/scripts/tests/test_release_run.py b/scripts/tests/test_release_run.py
index 517beb7a3b..56154e4eb8 100644
--- a/scripts/tests/test_release_run.py
+++ b/scripts/tests/test_release_run.py
@@ -923,3 +923,20 @@ def test_the_release_session_never_rings_the_bell_or_plays_a_sound(release, monk
     monkeypatch.setattr(notify, "bell", lambda **k: pytest.fail("bell"))
     monkeypatch.setattr(notify, "sound", lambda *a, **k: pytest.fail("sound"))
     script.run()
+
+
+def test_each_phase_command_is_a_sub_row_of_its_phase(release, monkeypatch, tmp_path):
+    """
+    A phase command runs as a sub-row (parent=) of its phase, so the phase row draws a unit line for it; a Maven
+    command (parser "maven") hands that line to its reactor's modules instead.  What each draws is pinned in
+    juneau-run's test_unit_model; this pins the call shape that selects it.
+    """
+    fake = FakeRun()
+    script = make(release, monkeypatch, fake)
+    script.current = "create_binary_artifacts"
+    script.run_command(["svn", "add", "source/x"], cwd=tmp_path)
+    script.current = "run_clean_verify"
+    script.run_command(["mvn", "clean", "verify"], cwd=tmp_path)
+    (svn_args, svn), (mvn_args, mvn) = fake.of("run_tool")
+    assert (svn_args[1], svn["parent"]) == ("generic", "create_binary_artifacts")
+    assert (mvn_args[1], mvn["parent"]) == ("maven", "run_clean_verify")