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/08/18 18:31:33 UTC

(juneau) 05/08: TODO-429: Add an opt-in Playwright browser-execution harness for the views-toolkit client assets

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

commit e64a9dfc13f89acb92cf752909b8fe8a864153a2
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Aug 18 13:34:50 2026 -0400

    TODO-429: Add an opt-in Playwright browser-execution harness for the views-toolkit client assets
    
    Nothing in the default build previously executed juneau-views.js /
    juneau-pages.js / etc. -- every prior assertion read the served script as text.
    PagePanelVisibility_BrowserTest drives a real headless Chromium. An emulated
    DOM/jsdom-style shim was tried and rejected: HtmlUnit reported every panel
    visible against both the fixed and the broken runtime, so it cannot see the
    ancestor-visibility cascade that was the actual defect.
    
    Gated behind @EnabledIfSystemProperty(juneau.jsTests); the new js-tests Maven
    profile (fetches a pinned Playwright + Chromium into target/js, reclaimed by
    mvn clean) is what flips that flag, and is never invoked by a plain mvn
    install -- no new default-scope dependency, no network requirement for a normal
    build. New CI job in maven.yml runs it opt-in via -Pjs-tests.
    
    Lands after the juneau-pages.js fix it asserts, since the harness asserts the
    fixed behavior and would fail against the prior runtime whenever someone
    actually ran -Pjs-tests.
---
 .github/workflows/maven.yml                        |  82 ++++++
 juneau-rest/juneau-rest-server-views/pom.xml       | 115 ++++++++
 .../views/PagePanelVisibility_BrowserTest.java     | 312 +++++++++++++++++++++
 .../src/test/js/panel-visibility.cjs               | 143 ++++++++++
 4 files changed, 652 insertions(+)

diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index 7a8158aa71..6cab9b15f7 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -56,3 +56,85 @@ jobs:
         cache: 'maven'
     - name: Build with Maven
       run: mvn --no-transfer-progress --batch-mode --show-version
+
+  # Executes the juneau-rest-server-views client runtime (juneau-pages.js) in headless Chromium.  Two real defects
+  # shipped in that runtime because JavaScript had no execution coverage at all, so this job is the thing that makes
+  # the harness real: it is opt-in via the -Pjs-tests profile precisely so an offline source-tarball build never needs
+  # Node, npm or a browser, which means it only ever runs where something deliberately asks for it - here.
+  #
+  # Deliberately a SEPARATE job rather than a flag on the matrix build above, for two reasons: the result is
+  # JDK-independent (the JVM only orchestrates; the assertions are about Chromium's layout), so running it three times
+  # would buy nothing, and a Playwright CDN hiccup then fails only this job instead of red-X'ing the entire Java build
+  # on every JDK.
+  #
+  # Runs on EVERY push and pull request - it inherits this workflow's triggers rather than adding a path filter
+  # scoped to juneau-rest/juneau-rest-server-views.  A path filter would be cheaper but wrong: the fixture this
+  # harness renders is assembled from three modules, so a regression can arrive from outside the views module
+  # entirely.  The PAGE_META sidecar alone depends on StringUtils.escapeForScript in juneau-commons and on
+  # HtmlBuilder.rawText in juneau-bean-html5 - break either and JSON.parse fails in the browser and every page blanks,
+  # while a views-only filter stays green.  That cross-module coupling is the whole reason this harness exists.
+  js-tests:
+    name: JS harness (headless Chromium)
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+      with:
+        persist-credentials: false
+    - name: Set up JDK 17
+      uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
+      with:
+        distribution: 'temurin'
+        java-version: 17
+        cache: 'maven'
+    # The ~200MB browser download is the only slow part (~60s cold, versus ~2s to actually run the harness).  Cached
+    # at Playwright's own default location, which is why the profile lets -Djuneau.jsTests.browsers move it out of
+    # target/: this workflow's Maven runs use the reactor's "clean verify" defaultGoal, so a cache restored into
+    # target/ would be deleted before it was ever read.  Keyed on the pom that pins the Playwright version rather than
+    # on the literal version, so a bump cannot leave a stale key behind; the restore-keys prefix still gives a
+    # partial hit on any other pom edit, and `playwright install` then simply verifies and skips.
+    - name: Cache Playwright browsers
+      uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
+      with:
+        path: ~/.cache/ms-playwright
+        key: ${{ runner.os }}-playwright-${{ hashFiles('juneau-rest/juneau-rest-server-views/pom.xml') }}
+        restore-keys: ${{ runner.os }}-playwright-
+    # Recorded so a future failure log says which Node actually ran, rather than "whatever the runner image had".
+    - name: Show Node version
+      run: node --version && npm --version
+    # -pl/-am rather than -f: -f would resolve the module's SNAPSHOT dependencies from ~/.m2, which a cold runner does
+    # not have.  -Dtest= narrows Surefire to the harness so the upstream modules are built but not re-tested; the
+    # matrix job above is what runs their suites, including the always-on markup/CSS half of this same contract.
+    - name: Run JS harness
+      run: >
+        mvn --no-transfer-progress --batch-mode --show-version
+        -Pjs-tests
+        -pl juneau-rest/juneau-rest-server-views -am
+        test
+        -Dtest=PagePanelVisibility_BrowserTest
+        -Dsurefire.failIfNoSpecifiedTests=false
+        -Djuneau.jsTests.browsers="${HOME}/.cache/ms-playwright"
+    # Surefire already prints each failed assertion with the full rendered-state JSON attached, which is the actual
+    # diagnosis (a panel reported as "jc-subpanel jc-active" with "visible":false is the blank-tab defect, stated).
+    # This step adds what Surefire cannot know: how to reproduce it, and the prober's own stderr for the case where
+    # the browser itself failed to launch rather than an assertion failing.
+    - name: Explain failure
+      if: failure()
+      env:
+        VIEWS: juneau-rest/juneau-rest-server-views
+      run: |
+        echo '================================================================================'
+        echo 'The JavaScript-execution harness failed.  It renders real PageTable output with'
+        echo 'the real juneau-views.css and juneau-pages.js in headless Chromium and asserts on'
+        echo 'what is actually visible, so a failure here means the browser disagreed with the'
+        echo 'panel-visibility or lazy-init contract - not that a source pattern stopped matching.'
+        echo
+        echo 'Reproduce locally (needs Node and npm on the PATH; first run downloads Chromium):'
+        echo "  mvn -Pjs-tests -f ${VIEWS}/pom.xml test"
+        echo
+        echo 'Contract being asserted: PageTable.java javadoc, "Panel markup contract".'
+        echo '================================================================================'
+        echo '--- Surefire report ---'
+        cat "${VIEWS}"/target/surefire-reports/*PagePanelVisibility_BrowserTest.txt 2>/dev/null || echo '(none - the failure was before the tests ran)'
+        echo '--- Prober stderr (non-empty only if Chromium or Node itself failed) ---'
+        cat "${VIEWS}/target/js/prober-stderr.txt" 2>/dev/null || echo '(none)'
diff --git a/juneau-rest/juneau-rest-server-views/pom.xml b/juneau-rest/juneau-rest-server-views/pom.xml
index e6ac1a3e76..6bba1ff813 100644
--- a/juneau-rest/juneau-rest-server-views/pom.xml
+++ b/juneau-rest/juneau-rest-server-views/pom.xml
@@ -62,6 +62,121 @@
 		</dependency>
 	</dependencies>
 
+	<profiles>
+		<!--
+			JavaScript-execution harness.  NOT activated by default, and nothing here runs, downloads, or is
+			required unless you ask for it by name:
+
+			    mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test
+
+			(-f rather than -pl, for the same reason the microbench profile in juneau-integration-tests documents:
+			the reactor's .mvn/maven.config also-make flag otherwise drags sibling modules into the run.)
+
+			A default `mvn install` - including an offline build from a source tarball - needs no Node, no npm, no
+			browser and no network for this module: the profile is off, so PagePanelVisibility_BrowserTest sees no
+			"juneau.jsTests" system property and JUnit skips it.  It still compiles, because it drives the prober
+			through plain java.lang.ProcessBuilder rather than any new Maven dependency; the module's default
+			dependency set is unchanged by this profile.
+
+			Everything the profile fetches lands under target/js (node_modules, the pinned Playwright package, and
+			the Chromium download), which the repo already ignores via "**/target/" and Apache RAT already skips via
+			"**/target/**" - so no new .gitignore entry, no new RAT exclusion, and nothing fetched can reach the
+			source distribution (juneau-distrib's src assembly packages only the sources jars under its own
+			target/src).  Both committed files - src/test/js/panel-visibility.cjs and the Java test - carry the
+			standard ASF header.
+
+			Prerequisites when you DO opt in: Node and npm on the PATH (override the launcher with
+			-Djuneau.jsTests.node=/path/to/node), plus network access on the first run.  Both steps below are
+			idempotent and cost roughly a second once target/js is warm.  On Windows the npm launcher is npm.cmd,
+			so pass -Djuneau.jsTests.npm=npm.cmd there.
+
+			The browser download is the one thing whose location is separately overridable, via
+			-Djuneau.jsTests.browsers.  It defaults inside target/ so `mvn clean` reclaims it for a developer, but CI
+			points it at Playwright's own ~/.cache/ms-playwright so actions/cache can carry the ~200MB across runs
+			(target/ cannot be cached: the reactor's defaultGoal is "clean verify", which would delete a restored
+			cache before it was used).  See the js-tests job in .github/workflows/maven.yml.
+		-->
+		<profile>
+			<id>js-tests</id>
+			<properties>
+				<!-- Pinned deliberately: a floating browser version would make failures non-reproducible. -->
+				<playwright.version>1.49.1</playwright.version>
+				<!-- Launcher names, overridable with -D when they are not on the PATH under these names. -->
+				<juneau.jsTests.node>node</juneau.jsTests.node>
+				<juneau.jsTests.npm>npm</juneau.jsTests.npm>
+				<js.tests.dir>${project.build.directory}/js</js.tests.dir>
+				<!-- Overridden by CI to a cacheable path outside target/; see the note above. -->
+				<juneau.jsTests.browsers>${js.tests.dir}/browsers</juneau.jsTests.browsers>
+			</properties>
+			<build>
+				<plugins>
+					<plugin>
+						<groupId>org.codehaus.mojo</groupId>
+						<artifactId>exec-maven-plugin</artifactId>
+						<version>3.3.0</version>
+						<executions>
+							<execution>
+								<id>js-install-playwright</id>
+								<phase>process-test-classes</phase>
+								<goals>
+									<goal>exec</goal>
+								</goals>
+								<configuration>
+									<executable>${juneau.jsTests.npm}</executable>
+									<arguments>
+										<argument>install</argument>
+										<argument>--no-fund</argument>
+										<argument>--no-audit</argument>
+										<argument>--prefix</argument>
+										<argument>${js.tests.dir}</argument>
+										<argument>playwright@${playwright.version}</argument>
+									</arguments>
+								</configuration>
+							</execution>
+							<execution>
+								<!--
+									Separate from the npm install because the browser is not an npm package: it is a
+									binary Playwright fetches on demand.  Idempotent - it re-verifies and skips an
+									already-present browser, which is what makes a warm cache cheap.
+								-->
+								<id>js-install-chromium</id>
+								<phase>process-test-classes</phase>
+								<goals>
+									<goal>exec</goal>
+								</goals>
+								<configuration>
+									<executable>${juneau.jsTests.node}</executable>
+									<environmentVariables>
+										<PLAYWRIGHT_BROWSERS_PATH>${juneau.jsTests.browsers}</PLAYWRIGHT_BROWSERS_PATH>
+									</environmentVariables>
+									<arguments>
+										<argument>${js.tests.dir}/node_modules/playwright/cli.js</argument>
+										<argument>install</argument>
+										<argument>chromium</argument>
+									</arguments>
+								</configuration>
+							</execution>
+						</executions>
+					</plugin>
+					<plugin>
+						<groupId>org.apache.maven.plugins</groupId>
+						<artifactId>maven-surefire-plugin</artifactId>
+						<configuration>
+							<!-- Merges with the reactor-root logging properties; these keys are local to this module. -->
+							<systemPropertyVariables>
+								<juneau.jsTests>true</juneau.jsTests>
+								<juneau.jsTests.dir>${js.tests.dir}</juneau.jsTests.dir>
+								<juneau.jsTests.harness>${project.basedir}/src/test/js/panel-visibility.cjs</juneau.jsTests.harness>
+								<juneau.jsTests.node>${juneau.jsTests.node}</juneau.jsTests.node>
+								<juneau.jsTests.browsers>${juneau.jsTests.browsers}</juneau.jsTests.browsers>
+							</systemPropertyVariables>
+						</configuration>
+					</plugin>
+				</plugins>
+			</build>
+		</profile>
+	</profiles>
+
 	<build>
 		<plugins>
 			<plugin>
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagePanelVisibility_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagePanelVisibility_BrowserTest.java
new file mode 100644
index 0000000000..b1bef2f5f4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagePanelVisibility_BrowserTest.java
@@ -0,0 +1,312 @@
+/*
+ * 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.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The module's <b>JavaScript-execution harness</b>: runs {@code juneau-pages.js} in a real headless browser and
+ * asserts on what a user would actually see.
+ *
+ * <h5 class='section'>Why this exists:</h5>
+ * <p>
+ * Every other test of this runtime asserts on the served script's <i>source shape</i>, which cannot distinguish a
+ * working page from a blank one.  Two real defects reached a released module through exactly that gap: a sub-tabbed
+ * tab rendered completely blank, and a tab's outer panel eagerly initialized every one of its sub-panels' tables.
+ * Both are invisible to a substring assertion and obvious to a browser.
+ *
+ * <h5 class='section'>What makes the result trustworthy:</h5>
+ * <p>
+ * The fixture is not a hand-written page.  This class builds it from the <b>real</b> {@link PageTable} emitter
+ * output, the <b>real</b> {@code juneau-views.css} and the <b>real</b> {@code juneau-pages.js} resources
+ * {@link ViewsMixin} serves, so nothing under test is restated in the fixture.  Visibility is then measured as a
+ * non-empty layout box in Chromium &mdash; the only check that accounts for the CSS cascade through <i>ancestors</i>,
+ * which is the whole substance of the blank-tab defect (a sub-panel can carry {@code .jc-active} and still be
+ * invisible because the panel wrapping it does not).  An emulated DOM is not good enough here: HtmlUnit reports
+ * every panel visible for both the fixed and the broken runtime, so a harness built on one would pass against
+ * known-broken code.
+ * <p>
+ * {@code juneau-views.js} is deliberately <b>not</b> loaded.  The lazy-init seam is instead observed by stubbing
+ * {@code JuneauViews.init.initTable} and recording which views it is called for, which is what lets the sub-panel
+ * scoping be asserted rather than inferred.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does &mdash; a default {@code mvn install} needs no Node, no npm, no browser download and no network.  See
+ * that profile's comment in this module's {@code pom.xml} for how to run it, and
+ * {@code src/test/js/panel-visibility.cjs} for the prober this drives.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link PageTable_SubtabPanelContract_Test} &mdash; the always-on markup/CSS half of the same
+ * 		contract, which runs with no Node at all.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=PagePanelVisibility_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class PagePanelVisibility_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static final String CATALOG = "#admin/catalog";
+	private static final String CATALOG_BUNDLES = "#admin/catalog/bundles";
+	private static final String RELEASES = "#admin/releases";
+
+	/** One prober report: the rendered state of the page after visiting one hash. */
+	private record Step(Map<?,?> raw) {
+
+		private String str(String k) { return (String) raw.get(k); }
+
+		@SuppressWarnings("unchecked")
+		private List<Object> list(String k) { return (List<Object>) raw.get(k); }
+
+		String activeTab() { return str("activeTab"); }
+
+		String activeSubtab() { return str("activeSubtab"); }
+
+		int visibleSubtabBars() { return ((Number) raw.get("visibleSubtabBars")).intValue(); }
+
+		List<Object> initedViews() { return list("initedViews"); }
+
+		List<Object> jsFailures() { return list("jsFailures"); }
+
+		List<Object> errorBanners() { return list("errorBanners"); }
+
+		/** Whether the panel scoped to exactly this (tab, subtab) pair occupies a non-empty box; fails if absent. */
+		boolean visible(String tab, String subtab) {
+			var matches = list("panels").stream()
+				.map(x -> (Map<?,?>) x)
+				.filter(x -> tab.equals(x.get("tab")) && Objects.equals(subtab, x.get("subtab")))
+				.toList();
+			assertEquals(1, matches.size(),
+				() -> "expected exactly one panel for tab=" + tab + " subtab=" + subtab + " in " + raw);
+			return (Boolean) matches.get(0).get("visible");
+		}
+	}
+
+	private static List<Step> steps;
+
+	public static class Release {
+		public String name;
+	}
+
+	private static ViewDef view(String id) {
+		return ViewDef.create(id)
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("/" + id + "/data")
+			.columns(Column.of("name").title("Name"))
+			.build();
+	}
+
+	/** A leaf tab plus a sub-tabbed tab - the pairing that made the blank-tab defect invisible to a leaf-only suite. */
+	private static PageDef page() {
+		return PageDef.create("admin")
+			.tabs(
+				Tab.create("releases", "Releases").view(view("releases")),
+				Tab.create("catalog", "Catalog").subtabs(
+					Subtab.create("packages", "Packages").view(view("packages")),
+					Subtab.create("bundles", "Bundles").view(view("bundles"))))
+			.build();
+	}
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harness"));
+
+		// The fixture restates nothing: emitter output, stylesheet and runtime all come from the real artifacts.
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>\n"
+			+ resource(ViewsMixin.VIEWS_CSS_RESOURCE)
+			+ "\n</style></head><body>\n"
+			+ Html.of(PageTable.of(page()))
+			+ "\n<script>\n"
+			+ resource(ViewsMixin.PAGES_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("page.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		var out = run(dir, harness, fixtureFile, CATALOG, CATALOG_BUNDLES, RELEASES);
+		List<?> reports = Json.to(out, List.class);
+		steps = reports.stream().map(x -> new Step((Map<?,?>) x)).toList();
+		assertEquals(3, steps.size(), () -> "expected one report per hash, got:\n" + out);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	/** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+	private static String run(Path dir, Path harness, Path fixture, String...hashes) throws Exception {
+		// Every attribute name the prober needs is handed over from PageTable's constants, so the prober is not a
+		// third spelling of them; juneau-pages.js's own irreducible copy is pinned in
+		// PageTable_SubtabPanelContract_Test instead.  The active-state CLASS names are not passed, because the
+		// emitter has no constant for them to come from - see that test's c03.
+		var attrs = Json.of(Map.of(
+			"panelTab", PageTable.PANEL_TAB_ATTR,
+			"panelSubtab", PageTable.PANEL_SUBTAB_ATTR,
+			"tabId", PageTable.TAB_ID_ATTR,
+			"subtabId", PageTable.SUBTAB_ID_ATTR));
+		var cmd = new ArrayList<>(List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(),
+			fixture.toString(), attrs));
+		cmd.addAll(List.of(hashes));
+
+		// Redirected to files rather than pipes, both because a chatty failure can fill one pipe's buffer while this
+		// side is still draining the other, and because the raw output is then left in target/ to look at.
+		var stdout = dir.resolve("prober-stdout.json");
+		var stderr = dir.resolve("prober-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		// Both provisioned by the js-tests profile.  node_modules lives under target/, so it is gitignored and
+		// RAT-excluded already; the browser path is separately overridable because CI needs it somewhere cacheable
+		// that `mvn clean` will not delete.
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	/** Reads a diagnostic file without letting a second failure mask the first. */
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	private static Step catalogNoSubtab() { return steps.get(0); }
+
+	private static Step catalogBundles() { return steps.get(1); }
+
+	private static Step releases() { return steps.get(2); }
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a: a sub-tabbed tab renders at all  (behavioural replacement for the panelMatches source-shape assertion)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_subtabbedTabRendersItsOuterPanelSubtabBarAndFirstSubpanel() {
+		// The blank-tab defect in one assertion: with the tab named but no sub-tab named, the runtime resolves a
+		// non-null subtabId, and an exact-match panel rule would leave the outer panel display:none - taking the
+		// sub-tab bar and the sub-panel nested inside it down with it, however correctly they were classed.
+		var s = catalogNoSubtab();
+		assertTrue(s.visible("catalog", null), () -> "outer .jc-panel of the sub-tabbed tab is not rendered: " + s.raw());
+		assertEquals(1, s.visibleSubtabBars(), () -> "the sub-tab bar must be reachable: " + s.raw());
+		assertTrue(s.visible("catalog", "packages"), () -> "first sub-panel is not rendered: " + s.raw());
+		assertEquals("catalog", s.activeTab());
+		assertEquals("packages", s.activeSubtab(), "an unnamed sub-tab must fall back to the first");
+	}
+
+	@Test void a02_theOtherTabsPanelStaysHidden() {
+		// The relaxation that fixes a01 must not leak across tabs.
+		var s = catalogNoSubtab();
+		assertFalse(s.visible("releases", null), () -> "another tab's panel leaked into view: " + s.raw());
+	}
+
+	@Test void a03_onlyTheActiveSubpanelIsRendered() {
+		var s = catalogNoSubtab();
+		assertFalse(s.visible("catalog", "bundles"), () -> "an inactive sub-panel is rendered: " + s.raw());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b: naming a sub-tab still narrows, and does so over hashchange
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_namingASubtabSwitchesWhichSubpanelRenders() {
+		// Reached by assigning location.hash, so this also proves the hashchange listener re-applies the resolution
+		// rather than the page only being correct on first load.
+		var s = catalogBundles();
+		assertTrue(s.visible("catalog", "bundles"), () -> "named sub-panel is not rendered: " + s.raw());
+		assertFalse(s.visible("catalog", "packages"), () -> "previous sub-panel stayed visible: " + s.raw());
+		assertTrue(s.visible("catalog", null), () -> "outer panel must stay visible for EVERY sub-tab: " + s.raw());
+		assertEquals("bundles", s.activeSubtab());
+	}
+
+	@Test void b02_switchingToALeafTabHidesTheWholeSubtabbedSubtree() {
+		var s = releases();
+		assertTrue(s.visible("releases", null), () -> "leaf tab panel is not rendered: " + s.raw());
+		assertFalse(s.visible("catalog", null), () -> "the sub-tabbed tab's outer panel stayed visible: " + s.raw());
+		assertFalse(s.visible("catalog", "bundles"), () -> "a sub-panel stayed visible: " + s.raw());
+		assertEquals(0, s.visibleSubtabBars(), () -> "a hidden tab's sub-tab bar stayed visible: " + s.raw());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c: lazy init is scoped to the panel that OWNS the table
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_aPanelOnlyInitsTheViewTablesItOwns() {
+		// A sub-tabbed tab's outer panel encloses every sub-panel's table.  Without ownership scoping it would init
+		// all of them the moment the tab opens - defeating lazy init and sizing columns inside hidden sub-panels.
+		assertEquals(List.of("packages"), catalogNoSubtab().initedViews(),
+			() -> "opening the tab must init only the active sub-panel's view: " + catalogNoSubtab().raw());
+	}
+
+	@Test void c02_initIsDeferredUntilASubtabIsActuallyActivated() {
+		// initedViews accumulates across the whole browser session, so this shows `bundles` was inited only once its
+		// own sub-panel became active - not earlier, when its ancestor panel did.
+		assertEquals(List.of("packages", "bundles"), catalogBundles().initedViews(),
+			() -> "expected bundles to init on activation, and not before: " + catalogBundles().raw());
+	}
+
+	@Test void c03_eachViewIsInitedAtMostOnce() {
+		// Re-activation must go down the columns.adjust() path, not init a second time.
+		var all = releases().initedViews();
+		assertEquals(new HashSet<>(all).size(), all.size(), () -> "a view was inited twice: " + all);
+		assertEquals(List.of("packages", "bundles", "releases"), all, () -> releases().raw().toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d: the runtime ran cleanly
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_noScriptErrorsAndNoFailLoudBanner() {
+		// A green run above would be meaningless if the runtime had actually thrown, or had refused to init and
+		// rendered its contract-version banner instead.
+		for (var s : steps) {
+			assertEquals(List.of(), s.jsFailures(), () -> "the runtime logged errors: " + s.jsFailures());
+			assertEquals(List.of(), s.errorBanners(), () -> "the runtime refused to init: " + s.errorBanners());
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/panel-visibility.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/panel-visibility.cjs
new file mode 100644
index 0000000000..649b9b21f4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/panel-visibility.cjs
@@ -0,0 +1,143 @@
+/*
+ * 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.
+ */
+
+/*
+ * panel-visibility.cjs - real-browser prober for the juneau-pages.js panel-visibility + lazy-init contracts.
+ *
+ * Never runs in a default build.  It is driven by PagePanelVisibility_BrowserTest, which itself only runs under
+ * `mvn -Pjs-tests`; see that class's javadoc and the profile comment in this module's pom.xml.
+ *
+ *   Usage:  node panel-visibility.cjs <page.html> <attrsJson> <hash>...
+ *
+ * Loads <page.html> - a self-contained fixture the Java test writes from the REAL PageTable emitter output, the REAL
+ * served juneau-views.css, and the REAL served juneau-pages.js - in headless Chromium, then visits each <hash> in
+ * turn (the first via a navigation, the rest by assigning location.hash, which exercises the runtime's `hashchange`
+ * path rather than re-running its bootstrap).  Prints ONE JSON array to stdout: one report per hash, in order.
+ *
+ * <attrsJson> is {"panelTab":..,"panelSubtab":..,"tabId":..,"subtabId":..} - every attribute name this probe needs,
+ * passed in rather than hard-coded so that this file does not become a THIRD independent spelling of them: the Java
+ * caller sources them from PageTable's public constants, and juneau-pages.js's own copy - the one duplication no
+ * build can remove - is pinned against those same constants by PageTable_SubtabPanelContract_Test.
+ *
+ * The active-state class names below are the exception, and are hard-coded, because they have no Java side to be
+ * passed from: the emitter never writes them.  That correspondence is pinned instead between this runtime and the
+ * stylesheet, by the same test.
+ *
+ * DIVISION OF LABOUR: this script only OBSERVES; every assertion lives in the Java test.  That keeps the failure
+ * diagnostics in JUnit, keeps the expectations next to the emitter they constrain, and keeps this file free of the
+ * fixture-specific knowledge that would otherwise have to be maintained in two languages.  Consequently the probe
+ * is generic - it enumerates whatever panels the document happens to contain rather than a hard-coded list.
+ *
+ * WHY A REAL BROWSER: the contract under test is "is this panel actually rendered", which depends on the CSS cascade
+ * through ANCESTORS (a sub-panel can carry .jc-active and still be invisible because the panel wrapping it does
+ * not).  Emulated DOMs get this wrong - HtmlUnit reports every panel visible for both the fixed and the broken
+ * runtime - so a harness built on one would pass against known-broken code, which is worse than no harness at all.
+ * Visibility is therefore measured as a non-empty layout box, the same thing a user sees.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+
+/*
+ * Stands in for juneau-views.js, which the fixture deliberately does NOT load: the seam under test is that a panel
+ * lazy-inits the view tables it OWNS and no others, so the probe records every initTable(...) call instead of
+ * really booting DataTables.  Installed before any page script runs, and juneau-pages.js adopts a pre-existing
+ * window.JuneauViews rather than replacing it, so the stub survives.
+ */
+const INSTRUMENTATION = function () {
+	window.__juneauInited = [];
+	window.JuneauViews = {
+		init: {
+			initTable: function (t) { window.__juneauInited.push(t.getAttribute('data-juneau-view')); }
+		}
+	};
+};
+
+/* Reads the state the runtime produced: which panels are really rendered, which tab/sub-tab is marked active. */
+const PROBE = function (attrs) {
+	const rendered = el => {
+		const r = el.getBoundingClientRect();
+		return r.width > 0 && r.height > 0;
+	};
+	const panels = Array.prototype.map.call(document.querySelectorAll('[' + attrs.panelTab + ']'), el => ({
+		tab: el.getAttribute(attrs.panelTab),
+		subtab: el.getAttribute(attrs.panelSubtab),
+		classes: el.getAttribute('class'),
+		visible: rendered(el)
+	}));
+	const attr = (sel, name) => {
+		const el = document.querySelector(sel);
+		return el ? el.getAttribute(name) : null;
+	};
+	// Tracked separately from the panels because the sub-tab bar is the one part of a sub-tabbed tab that lives in
+	// the OUTER panel: if it is invisible the user has no way to reach the other sub-tabs even if one renders.
+	const bars = Array.prototype.map.call(document.querySelectorAll('.jc-subtab-bar'), rendered);
+	return {
+		panels: panels,
+		activeTab: attr('.jc-tab-active', attrs.tabId),
+		activeSubtab: attr('.jc-subtab-active', attrs.subtabId),
+		visibleSubtabBars: bars.filter(Boolean).length,
+		initedViews: window.__juneauInited.slice(),
+		errorBanners: Array.prototype.map.call(document.querySelectorAll('.jc-page-error'), el => el.textContent)
+	};
+};
+
+(async () => {
+	const [fixture, attrsJson, ...hashes] = process.argv.slice(2);
+	if (!fixture || !attrsJson || !hashes.length) {
+		process.stderr.write('usage: node panel-visibility.cjs <page.html> <attrsJson> <hash>...\n');
+		process.exit(2);
+	}
+	const attrs = JSON.parse(attrsJson);
+	for (const k of ['panelTab', 'panelSubtab', 'tabId', 'subtabId'])
+		if (!attrs[k])
+			throw new Error('missing attribute name "' + k + '" in: ' + attrsJson);
+	if (!fs.existsSync(fixture))
+		throw new Error('fixture not found: ' + fixture);
+
+	const url = 'file://' + path.resolve(fixture);
+	const browser = await chromium.launch();
+	const reports = [];
+	try {
+		const page = await browser.newPage();
+		const failures = [];
+		page.on('pageerror', e => failures.push(String(e)));
+		page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+		await page.addInitScript(INSTRUMENTATION);
+
+		for (let i = 0; i < hashes.length; i++) {
+			if (i === 0)
+				await page.goto(url + hashes[i]);
+			else
+				await page.evaluate(h => { window.location.hash = h; }, hashes[i]);
+			// The runtime reacts synchronously to load/hashchange; one frame is enough for layout to settle.
+			await page.evaluate(() => new Promise(requestAnimationFrame));
+			const report = await page.evaluate(PROBE, attrs);
+			report.hash = hashes[i];
+			report.jsFailures = failures.slice();
+			reports.push(report);
+		}
+	} finally {
+		await browser.close();
+	}
+	process.stdout.write(JSON.stringify(reports, null, 2) + '\n');
+})().catch(e => {
+	process.stderr.write(String((e && e.stack) || e) + '\n');
+	process.exit(1);
+});