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