001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.test; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.util.*; 022import java.util.regex.*; 023import java.util.stream.*; 024 025import org.apache.juneau.marshall.collections.*; 026import org.apache.juneau.console.*; 027 028/** 029 * Test helper that extracts the {@code #juneau-page} contract and {@code <template>} ids from a 030 * rendered console page, validates them against {@link PageContractSchema}, and offers fluent 031 * assertions so adopters assert on the contract instead of on rendered chrome HTML. 032 * 033 * <p> 034 * Failures throw {@link AssertionError}, so the class works under any test framework. 035 * 036 * <p> 037 * {@link #assertPage(String)} also fails immediately if the contract still carries the pre-rename top-level 038 * <c>version</c> key (now <c>contractVersion</c>), even when {@link #isValid()} is never called. 039 * 040 * <h5 class='section'>Example:</h5> 041 * <p class='bjava'> 042 * String <jv>html</jv> = <jv>client</jv>.get(<js>"/slo"</js>).run().getContent().asString(); 043 * PageContractAssert.<jsm>assertPage</jsm>(<jv>html</jv>) 044 * .isValid() 045 * .hasContractVersion(<js>"1"</js>) 046 * .hasActiveNav(<js>"slo"</js>) 047 * .hasNavPath(<js>"home"</js>, <js>"about"</js>) 048 * .hasNavHref(<js>"slo"</js>, <js>"/slo"</js>) 049 * .hasFooterText(<js>"Sandbox Support Console"</js>) 050 * .hasHeaderSlot(<js>"banner"</js>) 051 * .hasCard(<js>"releases"</js>, <js>"datatables"</js>) 052 * .hasCardKey(<js>"releases"</js>, <js>"/table/dataUrl"</js>, <js>"/rest/releases/data"</js>) 053 * .templateContains(<js>"jc-seg-1"</js>, <js>"id=\"ssc-table-slot\""</js>); 054 * 055 * PageContractAssert.<jsm>assertPage</jsm>(<jv>html</jv>) 056 * .subscribes(<js>"tasks"</js>, <js>"selection:changes"</js>, <js>"params"</js>) 057 * .declaresTopic(<js>"ops.jobs"</js>) 058 * .hasBridge(<js>"ops"</js>) 059 * .hasNoWiringErrors(); 060 * </p> 061 * 062 * @since 10.0.0 063 */ 064@SuppressWarnings({ 065 "java:S1192" // Duplicated literals read more clearly inline than as constants. 066}) 067public final class PageContractAssert { 068 069 private static final Pattern ISLAND = Pattern.compile( 070 "<script type=\"application/json\" id=\"juneau-page\">(.*?)</script>", Pattern.DOTALL); 071 private static final Pattern TEMPLATE_OPEN = Pattern.compile("<template data-(card|slot)=\"([^\"]+)\">"); 072 073 private final String json; 074 private final JsonMap contract; 075 private final Map<String,String> templates; 076 077 private PageContractAssert(String html) { 078 var m = ISLAND.matcher(html); 079 if (! m.find()) 080 throw new AssertionError("no <script type=\"application/json\" id=\"juneau-page\"> in page"); 081 json = m.group(1); 082 try { 083 contract = JsonMap.ofString(json); 084 } catch (Exception e) { 085 throw new AssertionError("unparseable page contract: " + e.getMessage(), e); 086 } 087 if (contract.containsKey("version")) 088 throw new AssertionError("page contract key 'version' was renamed to 'contractVersion'; regenerate the page with a current Juneau"); 089 templates = scanTemplates(html); 090 } 091 092 /** 093 * Parses the contract and templates out of a rendered page. 094 * 095 * @param html The full rendered page. 096 * @return A new assertion object. 097 * @throws AssertionError If the page has no parseable contract island. 098 */ 099 public static PageContractAssert assertPage(String html) { 100 return new PageContractAssert(html); 101 } 102 103 /** 104 * Asserts the contract passes the schema and rules R-1..R-4. 105 * 106 * @return This object. 107 */ 108 public PageContractAssert isValid() { 109 var errors = PageContractSchema.get().validate(json, templates.keySet()); 110 if (! errors.isEmpty()) 111 throw new AssertionError("page contract is invalid:\n" + String.join("\n", errors)); 112 return this; 113 } 114 115 /** @param v The expected contract version. @return This object. */ 116 public PageContractAssert hasContractVersion(String v) { 117 return eq("contractVersion", v, contract.getString("contractVersion")); 118 } 119 120 /** @param v The expected page title. @return This object. */ 121 public PageContractAssert hasTitle(String v) { 122 return eq("title", v, contract.getString("title")); 123 } 124 125 /** @param name The expected theme name. @return This object. */ 126 public PageContractAssert hasTheme(String name) { 127 var t = contract.getMap("theme"); 128 return eq("theme.name", name, t == null ? null : t.getString("name")); 129 } 130 131 /** @param idPath The expected active path, root first. @return This object. */ 132 public PageContractAssert hasActiveNav(String... idPath) { 133 var actual = strings(contract.getList("activeNav")); 134 if (! actual.equals(List.of(idPath))) 135 throw new AssertionError("activeNav: expected " + List.of(idPath) + " but was " + actual); 136 return this; 137 } 138 139 /** Asserts {@code activeNav} is empty (the JS prefix fallback applies). @return This object. */ 140 public PageContractAssert hasNoActiveNav() { 141 var actual = strings(contract.getList("activeNav")); 142 if (! actual.isEmpty()) 143 throw new AssertionError("activeNav: expected [] but was " + actual); 144 return this; 145 } 146 147 /** @param idPath A root-first id path that must exist in {@code nav}. @return This object. */ 148 public PageContractAssert hasNavPath(String... idPath) { 149 if (idPath.length == 0) 150 throw new AssertionError("hasNavPath() requires at least one path segment"); 151 node(List.of(idPath)); 152 return this; 153 } 154 155 /** 156 * @param id A node id, or a {@code /}-separated id path such as {@code "home/about"}. 157 * @param href The expected href. 158 * @return This object. 159 */ 160 public PageContractAssert hasNavHref(String id, String href) { 161 var n = node(List.of(id.split("/"))); 162 if (neq(href, n.getString("href"))) 163 throw new AssertionError("nav '" + id + "' href: expected '" + href + "' but was '" + n.getString("href") + "'"); 164 return this; 165 } 166 167 /** 168 * @param idPath A {@code /}-separated id path. 169 * @param childIds The expected child ids, in order. 170 * @return This object. 171 */ 172 public PageContractAssert hasNavChildren(String idPath, String... childIds) { 173 var n = node(List.of(idPath.split("/"))); 174 var kids = n.getList("children"); 175 var actual = kids == null ? List.<String>of() : kids.stream().map(k -> ((JsonMap)k).getString("id")).toList(); 176 if (! actual.equals(List.of(childIds))) 177 throw new AssertionError("nav '" + idPath + "' children: expected " + List.of(childIds) + " but was " + actual); 178 return this; 179 } 180 181 /** @param v The expected {@code header.title}. @return This object. */ 182 public PageContractAssert hasHeaderTitle(String v) { 183 var h = contract.getMap("header"); 184 return eq("header.title", v, h == null ? null : h.getString("title")); 185 } 186 187 /** @param name A header slot name ({@code brand}, {@code actions}, {@code replace}, {@code banner}). @return This object. */ 188 public PageContractAssert hasHeaderSlot(String name) { 189 return slot("header", name); 190 } 191 192 /** @param v The expected {@code footer.text}. @return This object. */ 193 public PageContractAssert hasFooterText(String v) { 194 var f = contract.getMap("footer"); 195 return eq("footer.text", v, f == null ? null : f.getString("text")); 196 } 197 198 /** @param name A footer slot name ({@code content}). @return This object. */ 199 public PageContractAssert hasFooterSlot(String name) { 200 return slot("footer", name); 201 } 202 203 /** 204 * @param id The card id. 205 * @param type The expected card type. 206 * @return This object. 207 */ 208 public PageContractAssert hasCard(String id, String type) { 209 var c = card(id); 210 if (neq(type, c.getString("type"))) 211 throw new AssertionError("card '" + id + "' type: expected '" + type + "' but was '" + c.getString("type") + "'"); 212 return this; 213 } 214 215 /** @param ids The expected card ids, in contract order. @return This object. */ 216 public PageContractAssert hasCardOrder(String... ids) { 217 var actual = cardIds(); 218 if (! actual.equals(List.of(ids))) 219 throw new AssertionError("card order: expected " + List.of(ids) + " but was " + actual); 220 return this; 221 } 222 223 /** @param cardId The card id. @param topic A topic the card must subscribe to. @return This object. */ 224 public PageContractAssert subscribes(String cardId, String topic) { 225 subscription(cardId, topic, null); 226 return this; 227 } 228 229 /** 230 * @param cardId The card id. 231 * @param topic A topic the card must subscribe to. 232 * @param role The role it must be wired to. 233 * @return This object. 234 */ 235 public PageContractAssert subscribes(String cardId, String topic, String role) { 236 subscription(cardId, topic, role); 237 return this; 238 } 239 240 /** @param cardId The card id. @param topic A custom topic the card must declare in {@code publishes}. @return This object. */ 241 public PageContractAssert publishes(String cardId, String topic) { 242 var topics = topicsOf(card(cardId).getList("publishes")); 243 if (! topics.contains(topic)) 244 throw new AssertionError("card '" + cardId + "' does not publish '" + topic + "'; publishes: " + topics); 245 return this; 246 } 247 248 /** @param topic A top-level {@code topics} entry that must exist. @return This object. */ 249 public PageContractAssert declaresTopic(String topic) { 250 var topics = topicsOf(contract.getList("topics")); 251 if (! topics.contains(topic)) 252 throw new AssertionError("no topics entry '" + topic + "'; topics: " + topics); 253 return this; 254 } 255 256 /** @param id A top-level {@code bridges} id that must exist. @return This object. */ 257 public PageContractAssert hasBridge(String id) { 258 var ids = new ArrayList<String>(); 259 var l = contract.getList("bridges"); 260 if (l != null) 261 for (var o : l) 262 ids.add(((JsonMap)o).getString("id")); 263 if (! ids.contains(id)) 264 throw new AssertionError("no bridge '" + id + "'; bridges: " + ids); 265 return this; 266 } 267 268 /** 269 * Runs R-10 and R-11 against the standard card-type registry. Pair it with {@code BusPolicy.check(contract())} 270 * (juneau-console-bus) for the server-grant side (E-55). A page with custom card types needs the 271 * {@link #hasNoWiringErrors(CardTypeRegistry)} overload with the registry that knows them. 272 * 273 * @return This object. 274 */ 275 public PageContractAssert hasNoWiringErrors() { 276 return hasNoWiringErrors(CardTypeRegistry.standard()); 277 } 278 279 /** @param registry The registry that knows the page's card types. @return This object. */ 280 public PageContractAssert hasNoWiringErrors(CardTypeRegistry registry) { 281 var problems = BusWiringValidator.validate(contract, registry); 282 if (! problems.isEmpty()) 283 throw new AssertionError("page wiring has errors:\n" 284 + problems.stream().map(p -> p.code() + " " + p.message()).collect(Collectors.joining("\n"))); 285 return this; 286 } 287 288 private JsonMap subscription(String cardId, String topic, String role) { 289 var l = card(cardId).getList("subscribes"); 290 var roles = new ArrayList<String>(); 291 if (l != null) 292 for (var o : l) { 293 var m = (JsonMap)o; 294 if (! topic.equals(m.getString("topic"))) 295 continue; 296 if (role == null || role.equals(m.getString("as"))) 297 return m; 298 roles.add(m.getString("as")); 299 } 300 if (! roles.isEmpty()) 301 throw new AssertionError("card '" + cardId + "' subscribes to '" + topic + "' as " + roles + ", not '" + role + "'"); 302 throw new AssertionError("card '" + cardId + "' does not subscribe to '" + topic + "'; subscribes: " + topicsOf(l)); 303 } 304 305 private static List<String> topicsOf(List<?> l) { 306 var out = new ArrayList<String>(); 307 if (l != null) 308 for (var o : l) 309 out.add(((JsonMap)o).getString("topic")); 310 return out; 311 } 312 313 /** 314 * @param id The card id. 315 * @param src The expected {@code src} value. 316 * @return This object. 317 */ 318 public PageContractAssert hasCardSrc(String id, String src) { 319 return eq("card '" + id + "' src", src, card(id).getString("src")); 320 } 321 322 /** 323 * @param id The card id. 324 * @param jsonPointer A {@code /}-separated path into the card's JSON, for example {@code "/table/dataUrl"}. 325 * @param expected The expected value at that path. 326 * @return This object. 327 */ 328 public PageContractAssert hasCardKey(String id, String jsonPointer, Object expected) { 329 var actual = resolvePointer(card(id), jsonPointer); 330 if (neq(expected, actual)) 331 throw new AssertionError("card '" + id + "' " + jsonPointer + ": expected '" + expected + "' but was '" + actual + "'"); 332 return this; 333 } 334 335 /** 336 * @param templateId A {@code data-card} or {@code data-slot} template id. 337 * @param fragment Markup that must appear in the template. 338 * @return This object. 339 */ 340 public PageContractAssert templateContains(String templateId, String fragment) { 341 if (! template(templateId).contains(fragment)) 342 throw new AssertionError("template '" + templateId + "' does not contain '" + fragment + "'"); 343 return this; 344 } 345 346 /** 347 * Asserts this page and another render the same contract and the same template markup. 348 * 349 * <p> 350 * Use it to prove two ways of producing a page (for example a hand-written template and its builder twin) agree. 351 * The contracts are compared as key-sorted JSON; list order is kept, because card, nav and option order is part of 352 * the page. Templates are compared by id on their {@linkplain String#strip() stripped} markup, so the whitespace a 353 * template author's layout puts around a card body does not count. 354 * 355 * <p> 356 * The failure message names the first difference, for example 357 * {@code page contracts differ at $.cards[0].bare: this=true other=false}. 358 * 359 * @param other The page this one must equal. 360 * @return This object. 361 */ 362 public PageContractAssert isTwinOf(PageContractAssert other) { 363 assertTwins(contract, templates, other.contract, other.templates); 364 return this; 365 } 366 367 /** 368 * Asserts two contract and template pairs are twins, as {@link #isTwinOf(PageContractAssert)} does. 369 * 370 * <p> 371 * Exposed so a test that must first adjust either side (for example remove a card known to differ) can still use the 372 * shared comparison. 373 * 374 * @param contract The first contract. 375 * @param templates The first page's templates, by id. 376 * @param otherContract The second contract. 377 * @param otherTemplates The second page's templates, by id. 378 * @throws AssertionError If the contracts or templates differ. 379 */ 380 public static void assertTwins(Map<?,?> contract, Map<String,String> templates, Map<?,?> otherContract, Map<String,String> otherTemplates) { 381 var diff = firstDiff("$", canonical(contract), canonical(otherContract)); 382 if (diff != null) 383 throw new AssertionError("page contracts differ at " + diff); 384 var a = stripped(templates); 385 var b = stripped(otherTemplates); 386 var ids = new TreeSet<>(a.keySet()); 387 ids.addAll(b.keySet()); 388 for (var id : ids) 389 if (neq(a.get(id), b.get(id))) 390 throw new AssertionError("page template '" + id + "' differs: this=" + a.get(id) + " other=" + b.get(id)); 391 } 392 393 private static Map<String,String> stripped(Map<String,String> templates) { 394 var out = new TreeMap<String,String>(); 395 templates.forEach((k, v) -> out.put(k, v.strip())); 396 return out; 397 } 398 399 // Recursively key-sorts every Map; List order is kept. 400 private static Object canonical(Object v) { 401 if (v instanceof Map<?,?> m) { 402 var out = new TreeMap<String,Object>(); 403 m.forEach((k, x) -> out.put(String.valueOf(k), canonical(x))); 404 return out; 405 } 406 if (v instanceof List<?> l) 407 return l.stream().map(PageContractAssert::canonical).toList(); 408 return v; 409 } 410 411 // The first place two canonical trees differ, or null. 412 private static String firstDiff(String path, Object a, Object b) { 413 if (! neq(a, b)) 414 return null; 415 if (a instanceof Map<?,?> x && b instanceof Map<?,?> y) { 416 var keys = new TreeSet<Object>(x.keySet()); 417 keys.addAll(y.keySet()); 418 for (var k : keys) { 419 var d = firstDiff(path + "." + k, x.get(k), y.get(k)); 420 if (d != null) 421 return d; 422 } 423 } 424 if (a instanceof List<?> x && b instanceof List<?> y && x.size() == y.size()) 425 for (var i = 0; i < x.size(); i++) { 426 var d = firstDiff(path + "[" + i + "]", x.get(i), y.get(i)); 427 if (d != null) 428 return d; 429 } 430 return path + ": this=" + a + " other=" + b; 431 } 432 433 /** 434 * Escape hatch for asserts this class does not offer. 435 * 436 * @return The parsed contract. 437 */ 438 public JsonMap contract() { 439 return contract; 440 } 441 442 /** 443 * @return Every {@code <template data-card|data-slot>} id mapped to its inner markup, in document order. 444 */ 445 public Map<String,String> templates() { 446 return u(templates); 447 } 448 449 /** 450 * @param templateId A {@code data-card} or {@code data-slot} template id. 451 * @return The template's inner markup. 452 */ 453 public String template(String templateId) { 454 var t = templates.get(templateId); 455 if (t == null) 456 throw new AssertionError("no <template data-card|data-slot=\"" + templateId + "\">; templates: " + templates.keySet()); 457 return t; 458 } 459 460 private PageContractAssert eq(String what, String expected, String actual) { 461 if (neq(expected, actual)) 462 throw new AssertionError(what + ": expected '" + expected + "' but was '" + actual + "'"); 463 return this; 464 } 465 466 private PageContractAssert slot(String scope, String name) { 467 var sec = contract.getMap(scope); 468 var slots = sec == null ? null : sec.getMap("slots"); 469 if (slots == null || ! slots.containsKey(name)) 470 throw new AssertionError(scope + ".slots has no '" + name + "'"); 471 return this; 472 } 473 474 private JsonMap node(List<String> path) { 475 var level = contract.getList("nav"); 476 JsonMap found = null; 477 for (var id : path) { 478 found = null; 479 if (level != null) 480 for (var o : level) 481 if (id.equals(((JsonMap)o).getString("id"))) 482 found = (JsonMap)o; 483 if (found == null) 484 throw new AssertionError("nav path " + path + " not found (failed at '" + id + "')"); 485 level = found.getList("children"); 486 } 487 return found; 488 } 489 490 /** 491 * @param id The card id. 492 * @return The card's parsed JSON. 493 */ 494 public JsonMap card(String id) { 495 for (var o : contract.getList("cards")) 496 if (id.equals(((JsonMap)o).getString("id"))) 497 return (JsonMap)o; 498 throw new AssertionError("no card '" + id + "'; cards: " + cardIds()); 499 } 500 501 private List<String> cardIds() { 502 return contract.getList("cards").stream().map(o -> ((JsonMap)o).getString("id")).toList(); 503 } 504 505 private static Object resolvePointer(Object node, String pointer) { 506 if (pointer.isEmpty()) 507 return node; 508 if (! pointer.startsWith("/")) 509 throw new AssertionError("json pointer '" + pointer + "' must start with '/'"); 510 var cur = node; 511 for (var raw : pointer.substring(1).split("/", -1)) { 512 var seg = raw.replace("~1", "/").replace("~0", "~"); 513 if (cur instanceof Map<?,?> m) { 514 cur = m.get(seg); 515 } else if (cur instanceof List<?> l) { 516 var idx = Integer.parseInt(seg); 517 cur = idx >= 0 && idx < l.size() ? l.get(idx) : null; 518 } else { 519 throw new AssertionError("json pointer '" + pointer + "' hit a leaf before '" + seg + "'; node was: " + cur); 520 } 521 } 522 return cur; 523 } 524 525 private static List<String> strings(List<?> l) { 526 return l == null ? List.of() : l.stream().map(String::valueOf).toList(); 527 } 528 529 // Depth-aware: page markup may itself contain <template> elements. 530 private static Map<String,String> scanTemplates(String html) { 531 var out = new LinkedHashMap<String,String>(); 532 var m = TEMPLATE_OPEN.matcher(html); 533 var from = 0; 534 while (m.find(from)) { 535 var start = m.end(); 536 var depth = 1; 537 var i = start; 538 while (depth > 0) { 539 var open = html.indexOf("<template", i); 540 var close = html.indexOf("</template>", i); 541 if (close < 0) 542 throw new AssertionError("unterminated <template data-" + m.group(1) + "=\"" + m.group(2) + "\">"); 543 if (open >= 0 && open < close) { 544 depth++; 545 i = open + 9; 546 } else { 547 depth--; 548 i = close + 11; 549 } 550 } 551 out.put(m.group(2), html.substring(start, i - 11)); 552 from = i; 553 } 554 return out; 555 } 556}