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")