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:32 UTC
(juneau) 04/08: TODO-429/431: Fix sub-tab panel visibility and eager DataTable init in juneau-pages.js; adopt escapeForScript
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 b7f14752c9035b40481e588ab63f2fd249f3b79b
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Aug 18 13:34:31 2026 -0400
TODO-429/431: Fix sub-tab panel visibility and eager DataTable init in juneau-pages.js; adopt escapeForScript
Two real client-runtime defects found by the browser harness that is added in
a following commit:
- panelMatches() demanded an exact data-panel-subtab match, so a sub-tabbed
tab's outer .jc-panel (which carries only data-panel-tab) never got
.jc-active, blanking every sub-tabbed tab. Fixed with an explicit "missing
attribute = tab-agnostic, always matches" branch; the contract is now
spelled out in both juneau-pages.js and PageTable's javadoc.
- activatePanelViews() claimed every table[data-juneau-view] in its subtree
including hidden descendant sub-panels, firing every sub-tab's ajax draw and
sizing columns while display:none -- the exact mis-sizing lazy init exists to
prevent.
Also (TODO-431, framework half): PageTable and ViewTable each carried their own
private escapeForScript for their JSON sidecars; both are deleted in favor of
the new, 19-assertion-covered StringUtils.escapeForScript.
HtmlBuilder_RawText_Test's contract-doc comment is repointed at the new
location. TODO-431's consumer-side sweep (release-manager's new-release.ftlh)
and its Phase 3 build-time guard are still open; not this commit.
---
.../bean/html5/HtmlBuilder_RawText_Test.java | 4 +-
.../apache/juneau/commons/utils/StringUtils.java | 78 +++++++
.../juneau/commons/utils/StringUtils_Test.java | 71 ++++++
.../apache/juneau/rest/server/views/PageTable.java | 86 +++++++-
.../apache/juneau/rest/server/views/ViewTable.java | 31 +--
.../org/apache/juneau/views/juneau-pages.js | 37 +++-
.../rest/server/views/JuneauPagesJs_Test.java | 24 +-
.../rest/server/views/PageTable_Emit_Test.java | 10 +-
.../views/PageTable_SubtabPanelContract_Test.java | 243 +++++++++++++++++++++
.../rest/server/views/ViewsJs_PageSeam_Test.java | 13 +-
.../rest/server/views/ViewsMixin_Serving_Test.java | 9 +-
11 files changed, 542 insertions(+), 64 deletions(-)
diff --git a/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java
index 06524220d0..816f0436d3 100644
--- a/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java
+++ b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java
@@ -65,8 +65,8 @@ class HtmlBuilder_RawText_Test extends TestBase {
}
// SECURITY: RAWTEXT/rawText is verbatim by contract -- it does NOT silently neutralize a '</script>'
- // end-tag sequence in the body. Safety is the caller's responsibility (escape '<' -> \u003c), exactly as
- // juneau-rest-server-views' ViewTable.escapeForScript does. This test documents that contract.
+ // end-tag sequence in the body. Safety is the caller's responsibility; callers embedding a JSON payload should
+ // use StringUtils.escapeForScript(String) rather than hand-rolling it. This test documents that contract.
@Test void c01_scriptEndTagIsCallerResponsibility() throws Exception {
var s = HtmlSerializer.DEFAULT_SQ;
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/StringUtils.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/StringUtils.java
index fc49277272..11ad9f9e4f 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/StringUtils.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/StringUtils.java
@@ -1617,10 +1617,88 @@ public class StringUtils {
return sb.toString();
}
+ /**
+ * Escapes a serialized-JSON string so it can be safely embedded as the raw-text content of an HTML
+ * {@code <script>} element.
+ *
+ * <p>
+ * This is the escaper for a JSON <i>sidecar</i> — a payload handed to the browser as the content of a
+ * {@code <script type="application/json" id="...">} element, or assigned to a variable inside a plain inline
+ * {@code <script>}. It substitutes only characters whose JSON encoding is unambiguous, so the result stays
+ * valid, round-trippable JSON that parses back to the original value:
+ *
+ * <ul class='spaced-list'>
+ * <li>{@code <} → {@code \u005Cu003c}
+ * <li>{@code U+2028} (LINE SEPARATOR) → {@code \u005Cu2028}
+ * <li>{@code U+2029} (PARAGRAPH SEPARATOR) → {@code \u005Cu2029}
+ * </ul>
+ *
+ * <h5 class='section'>Injection vectors this defends against (do not "simplify" these away):</h5>
+ * <ul class='spaced-list'>
+ * <li><b>{@code </script>} break-out.</b> A {@code <script>} element's content is <i>raw text</i>: the HTML
+ * parser scans it only for an end tag, which it matches ASCII-case-insensitively as {@code </script}
+ * followed by whitespace, {@code /}, or {@code >}. A payload containing that sequence closes the element
+ * early and everything after it becomes live markup. Escaping {@code <} neutralizes it at the source. A
+ * targeted {@code replace("</script>", ...)} does <b>not</b>: it misses {@code </SCRIPT>},
+ * {@code </script foo>}, and {@code </script/}.
+ * <li><b>{@code <!--} / {@code <script} double-escape confusion.</b> Inside script content, {@code <!--}
+ * switches the tokenizer to its "script data escaped" state, and a subsequent {@code <script} switches it
+ * to "script data double escaped" — a state in which the element's own real {@code </script>} no
+ * longer closes it, so the rest of the document is swallowed as script data and the page's structure is
+ * attacker-controlled. Escaping {@code </} alone does <b>not</b> stop this; escaping {@code <} does.
+ * <li><b>{@code U+2028}/{@code U+2029} string-literal break-out.</b> Both are legal raw characters inside a
+ * JSON string but were JavaScript <i>line terminators</i> before ES2019, so a payload embedded in a
+ * JavaScript expression context could terminate its string literal mid-value. Escaped unconditionally so
+ * one helper is correct for both the {@code type="application/json"} and the inline-expression form.
+ * </ul>
+ *
+ * <h5 class='section'>Why <c>&</c> and {@code >} are deliberately NOT escaped:</h5>
+ * <p>
+ * Raw-text content is not entity-decoded by the browser, so HTML character references buy no safety here and
+ * {@link #escapeHtml(String)} is the <b>wrong</b> tool for this context — it would rewrite <c>&</c>
+ * to <c>&amp;</c> and corrupt the payload the moment {@code JSON.parse} reads it back verbatim. For the
+ * same reason {@code >} cannot terminate anything on its own once {@code <} is gone.
+ *
+ * <h5 class='section'>Why the substitutions are safe:</h5>
+ * <p>
+ * In valid JSON, {@code <}, {@code U+2028}, and {@code U+2029} can only occur inside a string literal (JSON's
+ * inter-token whitespace is limited to space, tab, CR, and LF), and inside a string literal a backslash is
+ * always itself escaped as {@code \u005C\u005C}. The inserted backslash therefore always begins a fresh
+ * escape sequence rather than extending a preceding one.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Serialize, escape, then insert as VERBATIM raw script content (never entity-encoded).</jc>
+ * String <jv>json</jv> = <jsm>escapeForScript</jsm>(Json.<jsm>of</jsm>(<jv>meta</jv>));
+ * <jc>// {"label":"x\u005Cu003c/script>"} - parses back to the original "x</script>"</jc>
+ * </p>
+ *
+ * @param json The <b>already-serialized JSON</b> to escape. Must be JSON: on arbitrary text these
+ * substitutions are not escape sequences and would alter the value. Can be <jk>null</jk> (returns
+ * <jk>null</jk>).
+ * @return The break-out-safe JSON, or <jk>null</jk> if input is <jk>null</jk>.
+ * @see #escapeHtml(String)
+ */
+ public static String escapeForScript(String json) {
+ if (json == null)
+ return null;
+ // The search terms are the RAW characters (a Java source \\u escape is pre-processed to the single character);
+ // the replacements are their 6-character JSON escape sequences.
+ return json
+ .replace("<", "\\u003c")
+ .replace("\u2028", "\\u2028")
+ .replace("\u2029", "\\u2029");
+ }
+
/**
* Escapes HTML entities in a string.
*
* <p>
+ * Use this for text interpolated into HTML <i>element content or an attribute value</i>. It is <b>not</b> the
+ * right escaper for the raw-text content of a {@code <script>} element — see
+ * {@link #escapeForScript(String)} for that.
+ *
+ * <p>
* Escapes the following characters:
* <ul>
* <li><js>'&'</js> → <js>"&amp;"</js></li>
diff --git a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java
index 25700afafb..4fc5fb4c5b 100755
--- a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java
+++ b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java
@@ -1069,6 +1069,77 @@ class StringUtils_Test extends TestBase {
assertEquals("Test\\u0100Test", escapeForJava("Test\u0100Test")); // Latin capital A with macron
}
+ //====================================================================================================
+ // escapeForScript(String)
+ //====================================================================================================
+
+ /** The 6-character JSON escape for {@code '<'}. Not a Java unicode escape - the leading {@code \\} is literal. */
+ private static final String JSON_LT = "\\u003c";
+
+ /** U+2028 LINE SEPARATOR / U+2029 PARAGRAPH SEPARATOR, as raw single characters. */
+ private static final String RAW_LS = String.valueOf((char)0x2028);
+ private static final String RAW_PS = String.valueOf((char)0x2029);
+
+ @Test
+ void a042b_escapeForScript_basics() {
+ assertNull(escapeForScript(null));
+ assertEquals("", escapeForScript(""));
+ // Nothing dangerous -> byte-identical passthrough (never entity-encodes: '&' and '>' must survive intact,
+ // since <script> raw text is not entity-decoded by the browser).
+ assertEquals("{\"a\":\"b & c > d\"}", escapeForScript("{\"a\":\"b & c > d\"}"));
+ }
+
+ @Test
+ void a042b1_escapeForScript_neutralizesScriptEndTagBreakout() {
+ // The primary vector: an end tag inside the payload would close the <script> element early.
+ assertEquals("{\"v\":\"" + JSON_LT + "/script>\"}", escapeForScript("{\"v\":\"</script>\"}"));
+ }
+
+ @Test
+ void a042b2_escapeForScript_neutralizesEveryEndTagSpelling() {
+ // Proves this is NOT a `replace("</script>", ...)` substring guard: the HTML parser matches `</script`
+ // case-insensitively followed by whitespace, '/', or '>', so all of these terminate the element.
+ for (var s : list("</script>", "</SCRIPT>", "</ScRiPt>", "</script >", "</script\t>", "</script/", "</script"))
+ assertFalse(escapeForScript("{\"v\":\"" + s + "\"}").contains("<"), s);
+ }
+
+ @Test
+ void a042b3_escapeForScript_neutralizesDoubleEscapeConfusion() {
+ // `<!--` puts the tokenizer in "script data escaped" state and a following `<script` in "script data double
+ // escaped", where the element's own real </script> no longer closes it - so escaping only `</` is not enough.
+ var a = escapeForScript("{\"v\":\"<!-- <script>alert(1)</script>\"}");
+ assertFalse(a.contains("<"), a);
+ assertEquals("{\"v\":\"" + JSON_LT + "!-- " + JSON_LT + "script>alert(1)" + JSON_LT + "/script>\"}", a);
+ }
+
+ @Test
+ void a042b4_escapeForScript_neutralizesLineAndParagraphSeparators() {
+ // Legal raw characters in a JSON string, but JavaScript line terminators before ES2019 - they would break out
+ // of a string literal when the payload is embedded in a JS expression context.
+ assertEquals("{\"v\":\"a\\u2028b\"}", escapeForScript("{\"v\":\"a" + RAW_LS + "b\"}"));
+ assertEquals("{\"v\":\"a\\u2029b\"}", escapeForScript("{\"v\":\"a" + RAW_PS + "b\"}"));
+ var a = escapeForScript("{\"v\":\"" + RAW_LS + RAW_PS + "\"}");
+ assertFalse(a.contains(RAW_LS), a);
+ assertFalse(a.contains(RAW_PS), a);
+ }
+
+ @Test
+ void a042b5_escapeForScript_insertedBackslashNeverExtendsAPrecedingEscape() {
+ // A '<' in valid JSON can only sit inside a string literal, where a literal backslash is always already
+ // doubled - so the inserted '\' starts a fresh escape rather than turning the preceding '\\' into '\\\'.
+ // Input JSON below carries the value `a\<b` (backslash, then '<').
+ assertEquals("{\"v\":\"a\\\\" + JSON_LT + "b\"}", escapeForScript("{\"v\":\"a\\\\<b\"}"));
+ }
+
+ @Test
+ void a042b6_escapeForScript_outputCarriesNoBreakoutCharacterAtAll() {
+ // The invariant a future "simplification" must not break: no raw '<', U+2028, or U+2029 survives.
+ var a = escapeForScript("{\"v\":\"</script><!--<script>" + RAW_LS + RAW_PS + "\"}");
+ assertFalse(a.contains("<"), a);
+ assertFalse(a.contains(RAW_LS), a);
+ assertFalse(a.contains(RAW_PS), a);
+ }
+
//====================================================================================================
// escapeHtml(String)
//====================================================================================================
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
index 42cccce3ac..ff2e756442 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
@@ -17,11 +17,13 @@
package org.apache.juneau.rest.server.views;
import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.apache.juneau.commons.utils.StringUtils.escapeForScript;
import java.util.*;
import org.apache.juneau.bean.html5.*;
import org.apache.juneau.commons.bean.*;
+import org.apache.juneau.commons.utils.*;
import org.apache.juneau.marshall.*;
import org.apache.juneau.marshall.marshaller.*;
@@ -33,12 +35,31 @@ import org.apache.juneau.marshall.marshaller.*;
*
* <p>
* Mirrors the sibling {@link ViewTable} emitter pattern exactly, including its escaping contract (see that class's
- * javadoc): PAGE_META is serialized with the repo's canonical compact JSON marshaller, has every {@code <} escaped
- * to its JSON unicode escape ({@link ViewTable#escapeForScript(String)} — reused verbatim, not re-implemented,
- * so the two sidecars can never drift in how they neutralize a {@code </script>} break-out), and is inserted as
+ * javadoc): PAGE_META is serialized with the repo's canonical compact JSON marshaller, is passed through
+ * {@link StringUtils#escapeForScript(String)} (the same shared escaper {@link ViewTable} uses, so the two sidecars
+ * can never drift in how they neutralize a {@code </script>} break-out), and is inserted as
* {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String) raw content} so the returned bean stays
* re-serializable.
*
+ * <h5 class='section'>Panel markup contract (shared with {@code juneau-pages.js} — both sides MUST agree):</h5>
+ * <p>
+ * Panel visibility is a two-attribute, hierarchically-narrowing contract:
+ * <ul class='spaced-list'>
+ * <li>{@link #PANEL_TAB_ATTR} scopes a panel to one top-level tab and is emitted on <b>every</b> panel.
+ * <li>{@link #PANEL_SUBTAB_ATTR} is <b>optional</b> and only <i>narrows</i> a panel further, to one specific
+ * sub-tab. Omitting it means the panel is sub-tab-<b>agnostic</b>: it is shown whenever its tab is active,
+ * whichever sub-tab that tab resolved to.
+ * </ul>
+ * <p>
+ * A leaf tab (one declaring {@link Tab#view}) therefore emits a single sub-tab-agnostic {@link #PANEL_CLASS} panel.
+ * A tab declaring {@link Tab#subtabs} emits <b>two nested levels</b>: an outer {@link #PANEL_CLASS} panel carrying
+ * only {@link #PANEL_TAB_ATTR} (it wraps the sub-tab bar and must stay visible for <i>all</i> of its sub-tabs, so it
+ * deliberately carries no {@link #PANEL_SUBTAB_ATTR} — a static attribute could only ever name one of them),
+ * and inside it one {@link #SUBPANEL_CLASS} per sub-tab carrying <b>both</b> attributes. Because
+ * {@code juneau-views.css} hides both panel classes until the runtime adds {@code .jc-active}, the runtime's
+ * matching rule must treat a missing {@link #PANEL_SUBTAB_ATTR} as "any sub-tab"; requiring an exact match instead
+ * leaves the outer panel {@code display:none} and blanks the whole tab, sub-tab bar and active sub-panel included.
+ *
* <h5 class='section'>No module dependency, class-based chrome only (Decision 1(A)):</h5>
* <p>
* The emitted shell carries only the neutral {@code .jc-*} classes ({@link #PAGE_CLASS}, {@link #TAB_CLASS},
@@ -97,6 +118,48 @@ public class PageTable {
/** Class on a sub-tab panel (nested inside a tab's panel). */
public static final String SUBPANEL_CLASS = "jc-subpanel";
+ /**
+ * Attribute scoping a panel to one top-level tab; emitted on <b>every</b> panel.
+ *
+ * <p>
+ * Published alongside the {@code .jc-*} class constants because it is the load-bearing half of the panel markup
+ * contract documented in this class's javadoc: {@code juneau-pages.js}'s {@code panelMatches} reads this exact
+ * attribute name and cannot import a Java constant, so renaming it here without mirroring it there silently
+ * blanks every panel. {@code PageTable_SubtabPanelContract_Test} asserts the two spellings still agree.
+ */
+ public static final String PANEL_TAB_ATTR = "data-panel-tab";
+
+ /**
+ * Attribute narrowing a panel to one specific sub-tab; <b>optional</b>, and its <i>absence</i> is meaningful.
+ *
+ * <p>
+ * A panel omitting it is sub-tab-agnostic (shown for whichever sub-tab its tab resolved to) — the rule a
+ * sub-tabbed tab's outer panel depends on to render at all. Same cross-artifact caveat as
+ * {@link #PANEL_TAB_ATTR}.
+ */
+ public static final String PANEL_SUBTAB_ATTR = "data-panel-subtab";
+
+ /**
+ * Attribute carrying a top-level tab-bar link's tab id, compared by the runtime to decide which tab reads as
+ * selected. Same cross-artifact caveat as {@link #PANEL_TAB_ATTR}.
+ */
+ public static final String TAB_ID_ATTR = "data-tab-id";
+
+ /**
+ * Attribute carrying a sub-tab-bar link's sub-tab id. Same cross-artifact caveat as {@link #PANEL_TAB_ATTR}.
+ */
+ public static final String SUBTAB_ID_ATTR = "data-subtab-id";
+
+ /**
+ * Attribute carrying the id of the tab a sub-tab link belongs to.
+ *
+ * <p>
+ * Sub-tab ids are only required to be unique <i>within</i> their tab, so the runtime pairs this with
+ * {@link #SUBTAB_ID_ATTR} before marking a sub-tab selected; matching on the sub-tab id alone would light up a
+ * same-named sub-tab under a different tab. Same cross-artifact caveat as {@link #PANEL_TAB_ATTR}.
+ */
+ public static final String PARENT_TAB_ATTR = "data-parent-tab";
+
private PageTable() {}
/**
@@ -128,7 +191,7 @@ public class PageTable {
tabBarChildren.add(
a(hashHref(id, t.id, null), t.label == null ? t.id : t.label)
.class_(TAB_CLASS)
- .attr("data-tab-id", t.id));
+ .attr(TAB_ID_ATTR, t.id));
var tabBar = nav(tabBarChildren.toArray()).class_(TAB_BAR_CLASS).attr("role", "tablist");
var panelsChildren = new ArrayList<>();
@@ -136,7 +199,7 @@ public class PageTable {
panelsChildren.add(buildTabPanel(ctx, id, t));
var panels = div(panelsChildren.toArray()).class_("jc-panels");
- var json = ViewTable.escapeForScript(Json.of(buildMeta(pageDef)));
+ var json = escapeForScript(Json.of(buildMeta(pageDef)));
var sidecar = script().type("application/json").id(SIDECAR_ID_PREFIX + id).text(rawText(json));
return div(tabBar, panels, sidecar).id(id).attr(MARKER_ATTR, id).class_(PAGE_CLASS);
@@ -146,7 +209,7 @@ public class PageTable {
private static Div buildTabPanel(MarshallingContext ctx, String pageId, Tab t) {
if (t.view != null) {
var body = ViewTable.of(ctx, t.view, null);
- return div(body).class_(PANEL_CLASS).attr("data-panel-tab", t.id);
+ return div(body).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
}
var subtabs = t.subtabs == null ? List.<Subtab>of() : t.subtabs;
@@ -156,19 +219,22 @@ public class PageTable {
subtabBarChildren.add(
a(hashHref(pageId, t.id, s.id), s.label == null ? s.id : s.label)
.class_(SUBTAB_CLASS)
- .attr("data-subtab-id", s.id)
- .attr("data-parent-tab", t.id));
+ .attr(SUBTAB_ID_ATTR, s.id)
+ .attr(PARENT_TAB_ATTR, t.id));
var subtabBar = nav(subtabBarChildren.toArray()).class_(SUBTAB_BAR_CLASS).attr("role", "tablist");
var subpanelsChildren = new ArrayList<>();
for (var s : subtabs) {
var body = ViewTable.of(ctx, s.view, null);
subpanelsChildren.add(
- div(body).class_(SUBPANEL_CLASS).attr("data-panel-tab", t.id).attr("data-panel-subtab", s.id));
+ div(body).class_(SUBPANEL_CLASS).attr(PANEL_TAB_ATTR, t.id).attr(PANEL_SUBTAB_ATTR, s.id));
}
var subpanels = div(subpanelsChildren.toArray()).class_("jc-subpanels");
- return div(subtabBar, subpanels).class_(PANEL_CLASS).attr("data-panel-tab", t.id);
+ // Tab-scoped only, on purpose: this panel wraps the sub-tab bar and must be visible for EVERY sub-tab, so it
+ // stays sub-tab-agnostic (see the panel markup contract in this class's javadoc). Do not add
+ // data-panel-subtab here - it would pin the whole tab to a single sub-tab and blank it for the others.
+ return div(subtabBar, subpanels).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
}
/** Builds the deep-linkable hash href: {@code #pageId/tabId} or {@code #pageId/tabId/subtabId}. */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
index 60283db7f4..10b7ccc684 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -17,10 +17,12 @@
package org.apache.juneau.rest.server.views;
import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.apache.juneau.commons.utils.StringUtils.escapeForScript;
import java.util.*;
import org.apache.juneau.bean.html5.*;
+import org.apache.juneau.commons.utils.*;
import org.apache.juneau.marshall.*;
import org.apache.juneau.marshall.marshaller.*;
@@ -50,10 +52,12 @@ import org.apache.juneau.marshall.marshaller.*;
* The VIEW_META JSON is emitted as the text content of a {@code <script type="application/json">} element. Per the
* HTML spec such content is <b>raw text</b> (HTML entities are NOT decoded inside it) and must not contain the
* substring {@code </} (nor {@code <!--}), or it would prematurely terminate the element. This emitter therefore
- * escapes every {@code <} in the serialized JSON to its JSON unicode escape {@code \u003c} <b>before</b> insertion,
- * which neutralizes {@code </script>}, {@code <script}, and {@code <!--} break-outs while keeping the payload valid,
- * round-trippable JSON. The JSON is inserted as verbatim raw content (via
- * {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String) rawText}) so Juneau's normal XML/HTML text
+ * hands the serialized JSON to {@link StringUtils#escapeForScript(String)} <b>before</b> insertion, which
+ * neutralizes {@code </script>},
+ * {@code <script}, and {@code <!--} break-outs while keeping the payload valid, round-trippable JSON. That method
+ * is the single, shared, publicly reusable implementation — see its javadoc for the exact vectors covered, and
+ * reuse it rather than hand-rolling an escaper for your own sidecar. The JSON is inserted as verbatim raw content
+ * (via {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String) rawText}) so Juneau's normal XML/HTML text
* entity-encoding does not corrupt the {@code application/json} payload (it would otherwise turn {@code &}/{@code >}
* into {@code &}/{@code >}, which browsers do NOT decode inside a raw-text {@code <script>}). Because
* {@code rawText} is backed by a {@code String} (not a one-shot {@link java.io.Reader}), the returned bean is fully
@@ -137,30 +141,13 @@ public class ViewTable {
var table = table(tableChildren.toArray()).id(id).attr(MARKER_ATTR, id);
- // Sidecar: serialize the VIEW_META, escape '<' -> \u003c, then insert as RAW content (see class javadoc).
+ // Sidecar: serialize the VIEW_META, neutralize script break-outs, then insert as RAW content (class javadoc).
var json = escapeForScript(Json.of(viewDef));
var sidecar = script().type("application/json").id(SIDECAR_ID_PREFIX + id).text(rawText(json));
return div(table, sidecar);
}
- /**
- * Escapes every {@code <} in a serialized-JSON string to its JSON unicode escape {@code \u003c} so the JSON can be
- * safely embedded as raw-text {@code <script>} content (design doc §6.1).
- *
- * <p>
- * A blanket {@code <} → {@code \u003c} substitution is safe: {@code <} never appears inside a JSON escape
- * sequence, and {@code \u003c} is the valid JSON encoding of {@code <}, so the result stays parseable JSON that
- * decodes back to the original value. Neutralizing {@code <} alone covers every HTML break-out sequence for a
- * raw-text element ({@code </script}, {@code <script}, {@code <!--}).
- *
- * @param json The serialized JSON. Must not be <jk>null</jk>.
- * @return The break-out-safe JSON.
- */
- static String escapeForScript(String json) {
- return json.replace("<", "\\u003c");
- }
-
/** Reads a column value from a row: a direct key lookup for a {@code Map}, a bean-property read otherwise. */
private static Object value(MarshallingContext ctx, Object row, String key) {
if (row instanceof java.util.Map<?,?> m)
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
index c2c341c6e9..d94cbc1abe 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
@@ -133,17 +133,46 @@
root.insertBefore(b, root.firstChild);
}
- /** Whether `panel`'s data-panel-tab/data-panel-subtab attributes match the resolved active (tabId, subtabId). */
+ // Every node whose visibility this runtime owns: a top-level tab panel, or a sub-tab panel nested inside one.
+ var PANEL_SELECTOR = ".jc-panel, .jc-subpanel";
+
+ /*
+ * Whether `panel` should be visible for the resolved active (tabId, subtabId).
+ *
+ * PANEL MARKUP CONTRACT (produced by the PageTable emitter, honored here - the two MUST agree):
+ * - `data-panel-tab` scopes a panel to one top-level tab, and is present on EVERY panel.
+ * - `data-panel-subtab` is OPTIONAL and only NARROWS a panel further, to one specific sub-tab.
+ * - A panel that omits `data-panel-subtab` is therefore sub-tab-AGNOSTIC: it is shown whenever its tab is
+ * active, whichever sub-tab that tab resolved to.
+ *
+ * That last rule is what makes a sub-tabbed tab render at all. PageTable emits TWO nested levels for such a
+ * tab: an outer `.jc-panel` (tab-scoped only) wrapping the sub-tab bar plus one `.jc-subpanel` per sub-tab
+ * (tab- AND sub-tab-scoped). Because juneau-views.css hides `.jc-panel` until it carries `.jc-active`,
+ * demanding an exact `data-panel-subtab` match here would leave that outer panel `display:none` - hiding the
+ * sub-tab bar and the active sub-panel nested inside it, i.e. rendering the entire tab blank. The outer panel
+ * cannot fix this from the emitter side either: it must be visible for EVERY one of its sub-tabs, and a static
+ * attribute can only name one of them.
+ */
function panelMatches(panel, tabId, subtabId) {
if (panel.getAttribute("data-panel-tab") !== tabId) return false;
var panelSubtabId = panel.getAttribute("data-panel-subtab");
- return subtabId != null ? panelSubtabId === subtabId : !panelSubtabId;
+ if (!panelSubtabId) return true; // sub-tab-agnostic panel: tab match is sufficient
+ return panelSubtabId === subtabId;
}
- /** Lazily inits (or, if already a DataTable, columns.adjust()s) every view table inside a just-shown panel. */
+ /*
+ * Lazily inits (or, if already a DataTable, columns.adjust()s) the view tables this panel OWNS.
+ *
+ * Tables sitting inside a DESCENDANT panel are skipped - a sub-tabbed tab's outer panel contains one
+ * `.jc-subpanel` per sub-tab, and those tables belong to their own sub-panel, which inits them when IT is
+ * activated. Claiming them here would defeat lazy init (every sub-tab's ajax draw would fire on page load)
+ * and would size their columns while they are still `display:none`, the exact mis-sizing lazy init exists to
+ * avoid.
+ */
function activatePanelViews(panel) {
var tables = panel.querySelectorAll("table[data-juneau-view]");
Array.prototype.forEach.call(tables, function (t) {
+ if (t.closest(PANEL_SELECTOR) !== panel) return;
var $ = window.jQuery;
if ($ && $.fn && $.fn.dataTable && $.fn.dataTable.isDataTable(t)) {
$(t).DataTable().columns.adjust();
@@ -171,7 +200,7 @@
el.classList.toggle("jc-subtab-active", active);
});
- var panels = root.querySelectorAll(".jc-panel, .jc-subpanel");
+ var panels = root.querySelectorAll(PANEL_SELECTOR);
Array.prototype.forEach.call(panels, function (p) {
var active = panelMatches(p, tabId, subtabId);
p.classList.toggle("jc-active", active);
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
index 0a5da6d02c..1eecde1098 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
@@ -27,15 +27,14 @@ import org.junit.jupiter.api.*;
* {@code juneau-pages.js} pure hash-routing logic + DOM binding shim tests (TODO-399 Phase C, Tasks 7-8).
*
* <p>
- * <b>Test-approach resolution (⚠️ UNVERIFIED in the plan):</b> this module has no JS-execution (jsdom/Option-B)
- * harness — confirmed while implementing Task 6 by inspecting {@code ViewsMixin_Serving_Test}, whose {@code
- * e03}/{@code g04} tests are Option-A-only (served-script content-substring assertions, with an explicit code
- * comment noting true JS-execution verification "awaits the deferred Option-B (jsdom) harness"). Per that
- * established convention, these tests extract each pure function's source body from the served script and assert
- * on its control-flow markers, mirroring {@code g04_viewsJs_columnDefsSetDefaultContentForUndefinedSafety}'s
- * function-body-extraction style. The functions are still written as a DOM-free pure layer (see the class-header
- * comment in {@code juneau-pages.js}), so a future Option-B harness can execute them directly with zero
- * refactoring — only the verification depth of these particular tests would change.
+ * <b>Scope:</b> these tests extract each pure function's source body from the served script and assert on its
+ * control-flow markers. They are cheap, always-on tripwires that need no Node, and they are deliberately <i>not</i>
+ * the proof that the runtime works: source shape cannot distinguish a working page from a blank one. That proof
+ * lives in {@link PagePanelVisibility_BrowserTest}, which executes this script in a real browser and asserts on
+ * rendered visibility — opt-in, behind the module's {@code js-tests} Maven profile.
+ * <p>
+ * The functions remain written as a DOM-free pure layer (see the class-header comment in {@code juneau-pages.js}),
+ * so the harness could also drive them directly should these particular assertions ever need that depth.
*/
class JuneauPagesJs_Test extends TestBase {
@@ -129,9 +128,10 @@ class JuneauPagesJs_Test extends TestBase {
// (getAttribute(...) === ...), never interpolated into a querySelector(...) string or innerHTML.
var body = pagesJs();
var fn = functionBody(body, "showActive");
- assertTrue(fn.contains("getAttribute(\"data-tab-id\") === tabId"), fn);
- assertTrue(fn.contains("getAttribute(\"data-subtab-id\") === subtabId"), fn);
- assertFalse(fn.contains("querySelector(\"[data-tab-id=\""), fn);
+ // Spelled from the emitter's constants, so these double as part of the name-correspondence pin.
+ assertTrue(fn.contains("getAttribute(\"" + PageTable.TAB_ID_ATTR + "\") === tabId"), fn);
+ assertTrue(fn.contains("getAttribute(\"" + PageTable.SUBTAB_ID_ATTR + "\") === subtabId"), fn);
+ assertFalse(fn.contains("querySelector(\"[" + PageTable.TAB_ID_ATTR + "=\""), fn);
assertFalse(fn.contains("innerHTML"), fn);
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
index 8a93248bf4..7a0a36b4a7 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
@@ -89,18 +89,18 @@ class PageTable_Emit_Test extends TestBase {
@Test void a02_emitsOneTabBarEntryPerTab() {
var html = Html.of(PageTable.of(leafPage()));
- assertTrue(html.contains("data-tab-id=\"releases\""), html);
- assertTrue(html.contains("data-tab-id=\"users\""), html);
+ assertTrue(html.contains(PageTable.TAB_ID_ATTR + "=\"releases\""), html);
+ assertTrue(html.contains(PageTable.TAB_ID_ATTR + "=\"users\""), html);
assertTrue(html.contains(PageTable.TAB_CLASS), html);
}
@Test void a03_emitsSubTabBarOnlyForTabsWithSubtabs() {
var html = Html.of(PageTable.of(pageWithSubtabs()));
- assertTrue(html.contains("data-subtab-id=\"packages\""), html);
- assertTrue(html.contains("data-subtab-id=\"bundles\""), html);
+ assertTrue(html.contains(PageTable.SUBTAB_ID_ATTR + "=\"packages\""), html);
+ assertTrue(html.contains(PageTable.SUBTAB_ID_ATTR + "=\"bundles\""), html);
assertTrue(html.contains(PageTable.SUBTAB_CLASS), html);
// The leaf "releases" tab has no subtabs -> no subtab-bar markers referencing it.
- assertFalse(html.contains("data-parent-tab=\"releases\""), html);
+ assertFalse(html.contains(PageTable.PARENT_TAB_ATTR + "=\"releases\""), html);
}
@Test void a04_eachViewGetsItsOwnMarkerTableAndSidecar() {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java
new file mode 100644
index 0000000000..63b6fe3031
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java
@@ -0,0 +1,243 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Regression barrier for the panel-visibility contract that decides whether a <b>sub-tabbed</b> tab renders at all.
+ *
+ * <p>
+ * A tab declaring {@link Tab#subtabs} is emitted as two nested levels: an outer {@link PageTable#PANEL_CLASS} panel
+ * (tab-scoped only) wrapping the sub-tab bar plus one {@link PageTable#SUBPANEL_CLASS} per sub-tab (tab- <i>and</i>
+ * sub-tab-scoped). Three independently-editable artifacts have to agree for that to be visible, and a mismatch
+ * between any two of them renders the entire tab <b>blank</b> while leaving leaf tabs working perfectly — which
+ * is precisely how such a mismatch escapes a leaf-tab-only test suite:
+ * <ol class='spaced-list'>
+ * <li><b>{@code juneau-views.css}</b> hides {@code .jc-panel}/{@code .jc-subpanel} until {@code .jc-active} is
+ * added, so the outer panel <i>must</i> receive {@code .jc-active} or its descendants stay invisible no matter
+ * what classes they carry.
+ * <li><b>{@link PageTable}</b> deliberately emits the outer panel with {@link PageTable#PANEL_TAB_ATTR} and
+ * <i>no</i> {@link PageTable#PANEL_SUBTAB_ATTR} (it must be visible for every one of its sub-tabs; a static
+ * attribute could only name one).
+ * <li><b>{@code juneau-pages.js}</b>'s {@code panelMatches} must therefore treat a missing
+ * {@link PageTable#PANEL_SUBTAB_ATTR} as "any sub-tab" rather than demanding an exact match.
+ * </ol>
+ *
+ * <h5 class='section'>Division of labour with the browser harness:</h5>
+ * <p>
+ * Points 1 and 2 are asserted here, directly against the served CSS and the emitted markup, and need nothing but a
+ * JVM. Point 3 is a claim about <i>runtime behavior</i>, so it is proven by executing the runtime in a real browser
+ * in {@link PagePanelVisibility_BrowserTest} rather than by pattern-matching the script's source — a substring
+ * assertion can tell you the code still <i>looks</i> right while the page renders blank.
+ * <p>
+ * What this class keeps for the runtime side is the one thing a behavioural test cannot cover: the attribute and
+ * class <b>names</b> are shared across a Java/JavaScript boundary that no compiler checks, and the browser harness is
+ * opt-in, so a rename that never reached {@code juneau-pages.js} would sail through a default build and blank every
+ * panel at runtime. Section {@code c} pins those spellings so that mismatch fails a build instead.
+ *
+ * <h5 class='section'>Which names are constants, and which are not:</h5>
+ * <p>
+ * Every name the <i>emitter</i> writes and the runtime reads is a {@link PageTable} constant, because Java is one of
+ * the two parties and a constant is how one party states the name once. The active-state classes
+ * ({@code .jc-tab-active}, {@code .jc-subtab-active}, {@code .jc-active}) deliberately are <b>not</b>: the emitter
+ * never writes them, so a public Java constant for them would publish API surface for a string this module's Java
+ * never produces. They are still a two-artifact contract — {@code juneau-pages.js} sets them and
+ * {@code juneau-views.css} styles them — so they get the same protection a different way, in {@code c03}.
+ */
+class PageTable_SubtabPanelContract_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ public static class Release {
+ public String name;
+ }
+
+ private static ViewDef view(String id) {
+ return ViewDef.create(id)
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/" + id + "/data")
+ .columns(Column.of("name").title("Name"))
+ .build();
+ }
+
+ /** A page mixing a leaf tab with a sub-tabbed tab - the leaf tab is the case that never broke. */
+ private static PageDef pageWithSubtabs() {
+ 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();
+ }
+
+ /** Every {@code <div ...>} start tag in the markup, so a panel's OWN attributes can be inspected in isolation. */
+ private static List<String> divTags(String html) {
+ var l = new ArrayList<String>();
+ var i = 0;
+ while ((i = html.indexOf("<div", i)) >= 0) {
+ var end = html.indexOf('>', i);
+ assertTrue(end >= 0, () -> "unterminated <div in:\n" + html);
+ l.add(html.substring(i, end + 1));
+ i = end + 1;
+ }
+ return l;
+ }
+
+ /** The single {@code <div>} start tag containing all of {@code required}; fails when not exactly one matches. */
+ private static String theDivTag(String html, String...required) {
+ var matches = divTags(html).stream().filter(x -> Arrays.stream(required).allMatch(x::contains)).toList();
+ assertEquals(1, matches.size(),
+ () -> "expected exactly one <div> tag containing " + Arrays.toString(required) + ", found " + matches + " in:\n" + html);
+ return matches.get(0);
+ }
+
+ /** {@code data-panel-tab="<id>"} as it appears in the markup, spelled from the emitter's own constant. */
+ private static String tabAttr(String id) {
+ return PageTable.PANEL_TAB_ATTR + "=\"" + id + "\"";
+ }
+
+ /** {@code data-panel-subtab="<id>"}, likewise. */
+ private static String subtabAttr(String id) {
+ return PageTable.PANEL_SUBTAB_ATTR + "=\"" + id + "\"";
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a: the emitted markup side of the contract
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_subtabbedTabOuterPanelIsTabScopedOnly() {
+ // The load-bearing emitter invariant: the outer panel wrapping a sub-tab bar must NOT be pinned to one
+ // sub-tab, because it has to stay visible for all of them.
+ var html = Html.of(PageTable.of(pageWithSubtabs()));
+ var tag = theDivTag(html, PageTable.PANEL_CLASS + "\"", tabAttr("catalog"));
+ assertFalse(tag.contains(PageTable.PANEL_SUBTAB_ATTR),
+ () -> "the outer .jc-panel of a sub-tabbed tab must stay sub-tab-agnostic (adding data-panel-subtab pins the whole tab to one sub-tab and blanks it for the others): " + tag);
+ }
+
+ @Test void a02_eachSubpanelIsTabAndSubtabScoped() {
+ var html = Html.of(PageTable.of(pageWithSubtabs()));
+ for (var id : List.of("packages", "bundles")) {
+ var tag = theDivTag(html, PageTable.SUBPANEL_CLASS, subtabAttr(id));
+ assertTrue(tag.contains(tabAttr("catalog")),
+ () -> "a .jc-subpanel must also carry its parent tab id, so it can never activate under another tab: " + tag);
+ }
+ }
+
+ @Test void a03_leafTabPanelIsAlsoSubtabAgnostic() {
+ // Leaf and sub-tabbed outer panels share one rule ("no data-panel-subtab means any sub-tab"), which is why a
+ // leaf-tab-only suite exercises the rule but never the sub-tabbed case.
+ var html = Html.of(PageTable.of(pageWithSubtabs()));
+ var tag = theDivTag(html, PageTable.PANEL_CLASS + "\"", tabAttr("releases"));
+ assertFalse(tag.contains(PageTable.PANEL_SUBTAB_ATTR), tag);
+ }
+
+ @Test void a04_subpanelsAreNestedInsideTheirTabsOuterPanel() {
+ // Nesting is what makes the outer panel's visibility a precondition for the sub-panels' visibility.
+ var html = Html.of(PageTable.of(pageWithSubtabs()));
+ var outer = html.indexOf(theDivTag(html, PageTable.PANEL_CLASS + "\"", tabAttr("catalog")));
+ var sub = html.indexOf(theDivTag(html, PageTable.SUBPANEL_CLASS, subtabAttr("packages")));
+ assertTrue(outer >= 0 && sub > outer, () -> "expected .jc-subpanel nested after its outer .jc-panel:\n" + html);
+ }
+
+ @Test void a05_pageMetaGivesTheSubtabbedTabANonEmptySubtabList() {
+ // This is what makes resolveInitial(...) hand showActive(...) a NON-NULL subtabId for this tab - the
+ // precondition under which an exact-match panel rule would blank the outer panel.
+ var html = Html.of(PageTable.of(pageWithSubtabs()));
+ var open = html.indexOf("id=\"" + PageTable.SIDECAR_ID_PREFIX + "admin\"");
+ assertTrue(open >= 0, () -> "PAGE_META sidecar not found:\n" + html);
+ var start = html.indexOf('>', open) + 1;
+ var meta = Json.to(html.substring(start, html.indexOf("</script>", start)), Map.class);
+ var catalog = (Map<?,?>) ((List<?>) meta.get("tabs")).get(1);
+ assertEquals("catalog", catalog.get("id"));
+ assertEquals(2, ((List<?>) catalog.get("subtabs")).size());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b: the CSS side of the contract
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_panelsAreHiddenUntilTheRuntimeAddsJcActive() throws Exception {
+ // Why the outer panel's .jc-active matters: without it the whole subtree - sub-tab bar included - is
+ // display:none, regardless of any class the runtime put on the sub-panel inside it.
+ var css = c.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString().replaceAll("\\s+", " ");
+ assertTrue(css.contains(".jc-panel, .jc-subpanel { display: none; }"), css);
+ assertTrue(css.contains(".jc-panel.jc-active, .jc-subpanel.jc-active { display: block; }"), css);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c: the Java-to-JavaScript name correspondence (the half no compiler and no behavioural test can check)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_theRuntimeReadsEveryAttributeNameTheEmitterWrites() throws Exception {
+ // Built from the constants on purpose: rename any of these without mirroring the new spelling in
+ // juneau-pages.js and this fails, instead of the runtime silently failing to recognize what it is handed -
+ // which for a panel attribute means display:none forever, and for a tab-bar attribute means a tab that never
+ // reads as selected.
+ var js = pagesJs();
+ for (var attr : List.of(PageTable.PANEL_TAB_ATTR, PageTable.PANEL_SUBTAB_ATTR, PageTable.TAB_ID_ATTR, PageTable.SUBTAB_ID_ATTR, PageTable.PARENT_TAB_ATTR))
+ assertTrue(js.contains("getAttribute(\"" + attr + "\")"),
+ () -> "juneau-pages.js does not read '" + attr + "' - the emitter and the runtime have drifted apart, and an attribute the runtime cannot recognize is one it silently ignores:\n" + js);
+ }
+
+ @Test void c02_theRuntimeSelectsTheSamePanelClassNamesTheEmitterWrites() throws Exception {
+ // Same irreducible duplication, other half: the runtime's PANEL_SELECTOR names both panel levels literally.
+ var js = pagesJs();
+ assertTrue(js.contains("\"." + PageTable.PANEL_CLASS + ", ." + PageTable.SUBPANEL_CLASS + "\""),
+ () -> "juneau-pages.js must select both emitted panel levels; dropping either one re-hides it:\n" + js);
+ }
+
+ @Test void c03_theStylesheetStylesTheExactActiveClassNamesTheRuntimeToggles() throws Exception {
+ // The active-state class names are the one part of this contract with no Java side at all: the emitter never
+ // writes them, the runtime adds them and the stylesheet reacts to them. So there is no constant to build
+ // these from - the pin is this test naming each one once and requiring both artifacts to agree, which is
+ // exactly the drift a rename in either file would otherwise cause (a class nothing styles, or a style
+ // nothing sets - both render as a page that quietly stops responding to clicks).
+ var js = pagesJs();
+ var css = viewsCss();
+ for (var cls : List.of("jc-tab-active", "jc-subtab-active", "jc-active")) {
+ assertTrue(js.contains("classList.toggle(\"" + cls + "\""),
+ () -> "juneau-pages.js no longer toggles '" + cls + "', but juneau-views.css still styles it:\n" + js);
+ assertTrue(css.contains("." + cls),
+ () -> "juneau-views.css does not style '." + cls + "', so the class juneau-pages.js sets has no visible effect:\n" + css);
+ }
+ }
+
+ private static String pagesJs() throws Exception {
+ return c.get(ViewsMixin.PAGES_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ private static String viewsCss() throws Exception {
+ return c.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
index 15e50808ce..1fe880c6c2 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
@@ -27,11 +27,14 @@ import org.junit.jupiter.api.*;
* Lazy-init seam test for {@code juneau-views.js} (TODO-399 Phase C, Task 6, design doc §"Client page runtime").
*
* <p>
- * There is no JS-execution (jsdom/Option-B) harness in this module yet (confirmed by inspecting
- * {@code ViewsMixin_Serving_Test}, which is Option-A-only: {@code e03}/{@code g04} assert on served-script content
- * substrings, not on executed behavior) — so, per that established convention, this test asserts on the
- * served script text: {@code initTable} must be reachable off the public {@code NS.init} namespace (previously
- * private), and {@code initAll} must skip tables scoped under a {@code [data-juneau-page]} shell.
+ * Asserts on the served script text: {@code initTable} must be reachable off the public {@code NS.init} namespace
+ * (previously private), and {@code initAll} must skip tables scoped under a {@code [data-juneau-page]} shell.
+ * <p>
+ * The <i>consequences</i> of this seam — that a panel lazy-inits exactly the view tables it owns, and that a
+ * sub-tabbed tab's outer panel does not claim its sub-panels' tables — are verified behaviourally in
+ * {@link PagePanelVisibility_BrowserTest}, which stubs {@code NS.init.initTable} in a real browser and records which
+ * views it is called for. These text assertions remain as always-on tripwires for the seam's <i>shape</i>, since
+ * that harness is opt-in.
*/
class ViewsJs_PageSeam_Test extends TestBase {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
index 4b9cf80702..c9f59c647b 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -270,8 +270,9 @@ class ViewsMixin_Serving_Test extends TestBase {
// Regression: the `tag` renderer must mirror console-ui's Tag#normalize/TagHtmlRender token algorithm
// (lowercase both <domain> and <value> into the `.tag.<domain>.<value>` CSS token) so themed chrome.css
// rules (e.g. `.tag.status.released`) match - a raw "RELEASED" cell must no longer render as an
- // unthemed neutral chip. Option-A (content substring) coverage; true JS-execution verification of the
- // exact lowercased/hyphenated output awaits the deferred Option-B (jsdom) harness.
+ // unthemed neutral chip. Content-substring coverage only: the module's browser harness
+ // (PagePanelVisibility_BrowserTest) covers the page runtime, not the renderer registry, so executing this
+ // renderer to check its exact lowercased/hyphenated output would need a second fixture there.
var body = cWithMixin.get(ViewsMixin.RENDERS_JS_PATH).run().assertStatus(200).getContent().asString();
assertTrue(body.contains("normalizeTagToken("), body);
var tagRendererStart = body.indexOf("registerRenderer(\"tag\"");
@@ -375,8 +376,8 @@ class ViewsMixin_Serving_Test extends TestBase {
// Regression: a nullable column's value is OMITTED (not null) by the server's JSON serializer, so
// DataTables' data accessor sees `undefined` and throws "Requested unknown parameter" (datatables.net/tn/4)
// before any renderer runs. Every generated column def must set defaultContent so DataTables substitutes
- // that value instead of warning - Option-A (content substring) coverage; true JS-execution verification of
- // the undefined-safe behavior awaits the deferred Option-B (jsdom) harness.
+ // that value instead of warning - content-substring coverage only: proving the undefined-safe behavior would
+ // mean booting DataTables itself, which the module's browser harness deliberately stubs out rather than loads.
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
var buildColumnDefStart = body.indexOf("function buildColumnDef(");
assertTrue(buildColumnDefStart >= 0, () -> "buildColumnDef not found:\n" + body);