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}