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 <[email protected]>
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);
+});

Reply via email to