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