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 — 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 — 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} — 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);
+});