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.terminal.report;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022
023import org.apache.juneau.marshall.json.*;
024
025/**
026 * One event of a terminal-view stream.
027 *
028 * <p>
029 * An immutable value with static factories, one per event kind.  Every factory and <c>with*</c> method validates its
030 * argument against the event contract and throws {@link IllegalArgumentException} naming the member, so a producer
031 * finds its bug where it is written.  {@link #toContractMap()} produces the wire form, and {@link #fromMap(Map)} is
032 * its strict inverse.
033 *
034 * <p>
035 * A {@link #seq()} of <c>0</c> means the event has not been numbered yet; sources assign it.
036 */
037@SuppressWarnings({
038   "java:S1192" // Wire member names and event keys are protocol literals.
039})
040public final class TerminalEvent {
041
042   /** The event kinds.  The wire form is lower case. */
043   public enum Kind {
044      /** A step starts or is updated. */ STEP,
045      /** A step ends. */ END,
046      /** A placeholder suite result. */ SUITE,
047      /** One test result. */ TEST,
048      /** Discard a step's suites and tests. */ REPLACE,
049      /** A notable event. */ NOTE,
050      /** The run finished. */ DONE
051   }
052
053   /** The state of a step that has not ended. */
054   public enum StepState {
055      /** The step is running. */ RUNNING,
056      /** The step is blocked on something outside the run. */ WAITING
057   }
058
059   /** The final status of a step. */
060   public enum EndStatus {
061      /** Passed. */ OK,
062      /** Failed. */ FAIL,
063      /** Skipped. */ SKIP
064   }
065
066   /** The status of a test. */
067   public enum TestStatus {
068      /** Passed. */ PASS,
069      /** Failed. */ FAIL,
070      /** Skipped. */ SKIP,
071      /** Errored. */ ERROR
072   }
073
074   /** The level of a note. */
075   public enum Level {
076      /** Informational. */ INFO,
077      /** Warning. */ WARN,
078      /** Error. */ ERROR
079   }
080
081   /** The final status of a run. */
082   public enum DoneStatus {
083      /** Passed. */ OK,
084      /** Failed. */ FAIL,
085      /** Cancelled. */ CANCELLED
086   }
087
088   /** The maximum length of one serialized event. */
089   public static final int MAX_EVENT_CHARS = 65_536;
090
091   /** The maximum length of a step title. */
092   public static final int MAX_TITLE = 200;
093
094   /** The maximum length of a suite or test name. */
095   public static final int MAX_NAME = 512;
096
097   /** The maximum length of a failure message. */
098   public static final int MAX_MSG = 2000;
099
100   /** The maximum length of a stack trace. */
101   public static final int MAX_TRACE = 8000;
102
103   /** The maximum length of a note text. */
104   public static final int MAX_NOTE = 1000;
105
106   /** The largest integer a JSON number can carry exactly. */
107   public static final long MAX_SAFE_INT = 9_007_199_254_740_991L;
108
109   private static final int MAX_COUNT = 1_000_000_000;
110
111   private static final Map<Kind,List<String>> KEY_ORDER = Map.of(
112      Kind.STEP, List.of("id", "title", "n", "status", "rawOffset"),
113      Kind.END, List.of("id", "status", "ms", "exit"),
114      Kind.SUITE, List.of("step", "fw", "suite", "counts", "rawOffset"),
115      Kind.TEST, List.of("step", "fw", "suite", "name", "status", "ms", "msg", "trace", "rawOffset"),
116      Kind.REPLACE, List.of("step"),
117      Kind.NOTE, List.of("level", "text", "href", "step"),
118      Kind.DONE, List.of("status")
119   );
120
121   private final Kind kind;
122   private final long seq;
123   private final Map<String,Object> members;
124
125   private TerminalEvent(Kind kind, long seq, Map<String,Object> members) {
126      this.kind = kind;
127      this.seq = seq;
128      this.members = members;
129   }
130
131   private TerminalEvent with(String key, Object value) {
132      var m = new HashMap<>(members);
133      m.put(key, value);
134      return new TerminalEvent(kind, seq, m);
135   }
136
137   //-----------------------------------------------------------------------------------------------------------------
138   // Factories
139   //-----------------------------------------------------------------------------------------------------------------
140
141   /**
142    * Creates a <c>step</c> event.
143    *
144    * @param id The step id.  Must match <c>^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$</c>.
145    * @param title The step title, 1-200 chars.
146    * @return A new event.
147    */
148   public static TerminalEvent step(String id, String title) {
149      var m = new HashMap<String,Object>();
150      m.put("id", stepId("step", "id", id));
151      m.put("title", text("step", "title", title, 1, MAX_TITLE));
152      return new TerminalEvent(Kind.STEP, 0, m);
153   }
154
155   /**
156    * Creates an <c>end</c> event.
157    *
158    * @param id The id of a known step.
159    * @param status The final status.
160    * @return A new event.
161    */
162   public static TerminalEvent end(String id, EndStatus status) {
163      var m = new HashMap<String,Object>();
164      m.put("id", stepId("end", "id", id));
165      m.put("status", wire("end", "status", status));
166      return new TerminalEvent(Kind.END, 0, m);
167   }
168
169   /**
170    * Creates a <c>suite</c> placeholder event.
171    *
172    * @param step The id of a known step.
173    * @param fw The test framework key.
174    * @param suite The suite name, 1-512 chars.
175    * @param pass The pass count, 0-1,000,000,000.
176    * @param fail The fail count, 0-1,000,000,000.
177    * @param skip The skip count, 0-1,000,000,000.
178    * @return A new event.
179    */
180   public static TerminalEvent suite(String step, String fw, String suite, int pass, int fail, int skip) {
181      Map<String,Object> m = m(
182         "step", stepId("suite", "step", step),
183         "fw", fw("suite", fw),
184         "suite", text("suite", "suite", suite, 1, MAX_NAME)
185      );
186      Map<String,Object> counts = m("pass", count("pass", pass), "fail", count("fail", fail), "skip", count("skip", skip));
187      m.put("counts", counts);
188      return new TerminalEvent(Kind.SUITE, 0, m);
189   }
190
191   /**
192    * Creates a <c>test</c> event.
193    *
194    * @param step The id of a known step.
195    * @param fw The test framework key.
196    * @param suite The suite name, 1-512 chars.
197    * @param name The test name, 1-512 chars.
198    * @param status The test status.
199    * @return A new event.
200    */
201   public static TerminalEvent test(String step, String fw, String suite, String name, TestStatus status) {
202      Map<String,Object> m = m(
203         "step", stepId("test", "step", step),
204         "fw", fw("test", fw),
205         "suite", text("test", "suite", suite, 1, MAX_NAME),
206         "name", text("test", "name", name, 1, MAX_NAME),
207         "status", wire("test", "status", status)
208      );
209      return new TerminalEvent(Kind.TEST, 0, m);
210   }
211
212   /**
213    * Creates a <c>replace</c> event.
214    *
215    * @param step The id of a known step.
216    * @return A new event.
217    */
218   public static TerminalEvent replace(String step) {
219      var m = new HashMap<String,Object>();
220      m.put("step", stepId("replace", "step", step));
221      return new TerminalEvent(Kind.REPLACE, 0, m);
222   }
223
224   /**
225    * Creates a <c>note</c> event.
226    *
227    * @param level The level.
228    * @param text The plain text, 1-1,000 chars.
229    * @return A new event.
230    */
231   public static TerminalEvent note(Level level, String text) {
232      var m = new HashMap<String,Object>();
233      m.put("level", wire("note", "level", level));
234      m.put("text", text("note", "text", text, 1, MAX_NOTE));
235      return new TerminalEvent(Kind.NOTE, 0, m);
236   }
237
238   /**
239    * Creates a <c>done</c> event.
240    *
241    * @param status The final run status.
242    * @return A new event.
243    */
244   public static TerminalEvent done(DoneStatus status) {
245      var m = new HashMap<String,Object>();
246      m.put("status", wire("done", "status", status));
247      return new TerminalEvent(Kind.DONE, 0, m);
248   }
249
250   //-----------------------------------------------------------------------------------------------------------------
251   // Optional members
252   //-----------------------------------------------------------------------------------------------------------------
253
254   /**
255    * Sets the display ordinal of a <c>step</c> event.
256    *
257    * @param n The ordinal, 1-9999.
258    * @return A copy with the member set.
259    */
260   public TerminalEvent withN(int n) {
261      requireKind("n", Kind.STEP);
262      if (n < 1 || n > 9999)
263         throw iaex("TerminalEvent step n must be 1-9999; got '%s'", n);
264      return with("n", n);
265   }
266
267   /**
268    * Sets the state of a <c>step</c> event.
269    *
270    * @param state The state.
271    * @return A copy with the member set.
272    */
273   public TerminalEvent withState(StepState state) {
274      requireKind("status", Kind.STEP);
275      return with("status", wire("step", "status", state));
276   }
277
278   /**
279    * Sets the raw byte offset of a <c>step</c>, <c>suite</c> or <c>test</c> event.
280    *
281    * <p>
282    * The 0-based offset into the raw output log of the start of the line that produced the event; a terminal view
283    * scrolls to it.
284    *
285    * @param offset The offset, 0 to 9007199254740991.
286    * @return A copy with the member set.
287    */
288   public TerminalEvent withRawOffset(long offset) {
289      requireKind("rawOffset", Kind.STEP, Kind.SUITE, Kind.TEST);
290      if (offset < 0 || offset > MAX_SAFE_INT)
291         throw iaex("TerminalEvent %s rawOffset must be 0-%s; got '%s'", wireName(kind), MAX_SAFE_INT, offset);
292      return with("rawOffset", offset);
293   }
294
295   /**
296    * Sets the duration of an <c>end</c> or <c>test</c> event.
297    *
298    * @param ms The duration in milliseconds, 0 to 9007199254740991.
299    * @return A copy with the member set.
300    */
301   public TerminalEvent withMs(long ms) {
302      requireKind("ms", Kind.END, Kind.TEST);
303      if (ms < 0 || ms > MAX_SAFE_INT)
304         throw iaex("TerminalEvent %s ms must be 0-%s; got '%s'", wireName(kind), MAX_SAFE_INT, ms);
305      return with("ms", ms);
306   }
307
308   /**
309    * Sets the process exit code of an <c>end</c> event.
310    *
311    * @param exit The exit code.
312    * @return A copy with the member set.
313    */
314   public TerminalEvent withExit(int exit) {
315      requireKind("exit", Kind.END);
316      return with("exit", exit);
317   }
318
319   /**
320    * Sets the failure message of a <c>test</c> event.
321    *
322    * @param msg The message, at most 2,000 chars.
323    * @return A copy with the member set.
324    */
325   public TerminalEvent withMsg(String msg) {
326      requireKind("msg", Kind.TEST);
327      return with("msg", text("test", "msg", msg, 0, MAX_MSG));
328   }
329
330   /**
331    * Sets the stack trace of a <c>test</c> event.
332    *
333    * @param trace The trace, at most 8,000 chars.
334    * @return A copy with the member set.
335    */
336   public TerminalEvent withTrace(String trace) {
337      requireKind("trace", Kind.TEST);
338      return with("trace", text("test", "trace", trace, 0, MAX_TRACE));
339   }
340
341   /**
342    * Sets the link of a <c>note</c> event.
343    *
344    * @param href An absolute http(s) URL or a same-origin path.
345    * @return A copy with the member set.
346    */
347   public TerminalEvent withHref(String href) {
348      requireKind("href", Kind.NOTE);
349      if (! TerminalViewChecks.isNoteHref(href))
350         throw iaex("TerminalEvent note href must be an absolute http(s) URL or a same-origin path; got '%s'", TerminalViewChecks.clip(href));
351      return with("href", href);
352   }
353
354   /**
355    * Sets the step a <c>note</c> belongs under.
356    *
357    * @param step A step id.
358    * @return A copy with the member set.
359    */
360   public TerminalEvent withStep(String step) {
361      requireKind("step", Kind.NOTE);
362      return with("step", stepId("note", "step", step));
363   }
364
365   /**
366    * Returns a copy with the sequence number set.
367    *
368    * @param seq The sequence number, 1 to 9007199254740991, or 0 for unassigned.
369    * @return A copy.
370    */
371   public TerminalEvent withSeq(long seq) {
372      if (seq < 0 || seq > MAX_SAFE_INT)
373         throw iaex("TerminalEvent seq must be 0-%s; got '%s'", MAX_SAFE_INT, seq);
374      return new TerminalEvent(kind, seq, members);
375   }
376
377   //-----------------------------------------------------------------------------------------------------------------
378   // Accessors and wire form
379   //-----------------------------------------------------------------------------------------------------------------
380
381   /**
382    * Returns the event kind.
383    *
384    * @return The kind.
385    */
386   public Kind kind() {
387      return kind;
388   }
389
390   /**
391    * Returns the sequence number.
392    *
393    * @return The sequence number, or <c>0</c> when unassigned.
394    */
395   public long seq() {
396      return seq;
397   }
398
399   /**
400    * Returns the event in its wire form.
401    *
402    * <p>
403    * The map is ordered (<c>ev</c>, <c>seq</c> when assigned, then the members in contract order) and omits unset
404    * optional members.  A fresh map is returned on every call.
405    *
406    * @return The wire map.
407    */
408   public Map<String,Object> toContractMap() {
409      var m = new LinkedHashMap<String,Object>();
410      m.put("ev", wireName(kind));
411      if (seq > 0)
412         m.put("seq", seq);
413      for (var key : KEY_ORDER.get(kind)) {
414         var v = members.get(key);
415         if (v == null)
416            continue;
417         if (v instanceof Map<?,?> c)
418            v = new LinkedHashMap<>(c);
419         m.put(key, v);
420      }
421      return m;
422   }
423
424   /**
425    * Checks that the serialized event fits in {@link #MAX_EVENT_CHARS}.
426    *
427    * @return This object.
428    * @throws IllegalArgumentException If it does not.
429    */
430   public TerminalEvent validate() {
431      var len = JsonSerializer.DEFAULT.toString(toContractMap()).length();
432      if (len > MAX_EVENT_CHARS)
433         throw iaex("TerminalEvent serialized size must be <= %s chars; got %s", MAX_EVENT_CHARS, len);
434      return this;
435   }
436
437   /**
438    * Builds an event from its wire form.
439    *
440    * <p>
441    * The strict inverse of {@link #toContractMap()}.  Unknown members are ignored; an unknown <c>ev</c> or a
442    * malformed known member throws.
443    *
444    * @param map The wire map.
445    * @return A new event.
446    * @throws IllegalArgumentException If the map is not a valid event.
447    */
448   @SuppressWarnings({
449      "java:S3776" // Validating every wire member is inherently branchy.
450   })
451   public static TerminalEvent fromMap(Map<?,?> map) {
452      if (map == null)
453         throw iaex("TerminalEvent map must not be null");
454      var ev = map.get("ev");
455      if (! (ev instanceof String evs))
456         throw iaex("TerminalEvent ev must be a string; got '%s'", ev);
457      TerminalEvent e;
458      switch (evs) {
459         case "step" -> {
460            e = step(str(map, "step", "id"), str(map, "step", "title"));
461            if (map.get("n") != null)
462               e = e.withN(toInt(map, "step", "n"));
463            if (map.get("status") != null)
464               e = e.withState(parse(StepState.class, "step", "status", map.get("status")));
465            if (map.get("rawOffset") != null)
466               e = e.withRawOffset(toLong(map, "step", "rawOffset"));
467         }
468         case "end" -> {
469            e = end(str(map, "end", "id"), parse(EndStatus.class, "end", "status", map.get("status")));
470            if (map.get("ms") != null)
471               e = e.withMs(toLong(map, "end", "ms"));
472            if (map.get("exit") != null)
473               e = e.withExit(toInt(map, "end", "exit"));
474         }
475         case "suite" -> {
476            if (! (map.get("counts") instanceof Map<?,?> c))
477               throw iaex("TerminalEvent suite counts must be an object; got '%s'", map.get("counts"));
478            e = suite(str(map, "suite", "step"), str(map, "suite", "fw"), str(map, "suite", "suite"),
479               toInt(c, "suite", "counts.pass"), toInt(c, "suite", "counts.fail"), toInt(c, "suite", "counts.skip"));
480            if (map.get("rawOffset") != null)
481               e = e.withRawOffset(toLong(map, "suite", "rawOffset"));
482         }
483         case "test" -> {
484            e = test(str(map, "test", "step"), str(map, "test", "fw"), str(map, "test", "suite"), str(map, "test", "name"),
485               parse(TestStatus.class, "test", "status", map.get("status")));
486            if (map.get("ms") != null)
487               e = e.withMs(toLong(map, "test", "ms"));
488            if (map.get("msg") != null)
489               e = e.withMsg(str(map, "test", "msg"));
490            if (map.get("trace") != null)
491               e = e.withTrace(str(map, "test", "trace"));
492            if (map.get("rawOffset") != null)
493               e = e.withRawOffset(toLong(map, "test", "rawOffset"));
494         }
495         case "replace" -> e = replace(str(map, "replace", "step"));
496         case "note" -> {
497            e = note(parse(Level.class, "note", "level", map.get("level")), str(map, "note", "text"));
498            if (map.get("href") != null)
499               e = e.withHref(str(map, "note", "href"));
500            if (map.get("step") != null)
501               e = e.withStep(str(map, "note", "step"));
502         }
503         case "done" -> e = done(parse(DoneStatus.class, "done", "status", map.get("status")));
504         default -> throw iaex("TerminalEvent ev must be one of step, end, suite, test, replace, note, done; got '%s'", TerminalViewChecks.clip(evs));
505      }
506      if (map.get("seq") != null)
507         e = e.withSeq(toLong(map, evs, "seq"));
508      return e.validate();
509   }
510
511   //-----------------------------------------------------------------------------------------------------------------
512   // Helpers
513   //-----------------------------------------------------------------------------------------------------------------
514
515   private static String wireName(Kind k) {
516      return k.name().toLowerCase(Locale.ROOT);
517   }
518
519   private void requireKind(String member, Kind...allowed) {
520      for (var k : allowed)
521         if (k == kind)
522            return;
523      throw iaex("TerminalEvent %s does not have a '%s' member", wireName(kind), member);
524   }
525
526   private static String wire(String ev, String member, Enum<?> value) {
527      if (value == null)
528         throw iaex("TerminalEvent %s %s must not be null", ev, member);
529      return value.name().toLowerCase(Locale.ROOT);
530   }
531
532   private static <E extends Enum<E>> E parse(Class<E> type, String ev, String member, Object value) {
533      if (value instanceof String s) {
534         for (var c : type.getEnumConstants())
535            if (c.name().toLowerCase(Locale.ROOT).equals(s))
536               return c;
537      }
538      throw iaex("TerminalEvent %s %s must be one of %s; got '%s'", ev, member,
539         Arrays.stream(type.getEnumConstants()).map(c -> c.name().toLowerCase(Locale.ROOT)).toList(), value instanceof String s ? TerminalViewChecks.clip(s) : String.valueOf(value));
540   }
541
542   private static String stepId(String ev, String member, String value) {
543      if (! TerminalViewChecks.isStepId(value))
544         throw iaex("TerminalEvent %s %s must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$; got '%s'", ev, member, TerminalViewChecks.clip(value));
545      return value;
546   }
547
548   private static String fw(String ev, String value) {
549      if (! TerminalViewChecks.isFw(value))
550         throw iaex("TerminalEvent %s fw must match ^[a-z0-9][a-z0-9._-]{0,31}$; got '%s'", ev, TerminalViewChecks.clip(value));
551      return value;
552   }
553
554   private static String text(String ev, String member, String value, int min, int max) {
555      if (value == null || value.length() < min || value.length() > max)
556         throw iaex("TerminalEvent %s %s must be %s-%s chars; got %s", ev, member, min, max, value == null ? "null" : value.length() + " chars");
557      return value;
558   }
559
560   private static int count(String member, int value) {
561      if (value < 0 || value > MAX_COUNT)
562         throw iaex("TerminalEvent suite counts.%s must be 0-%s; got '%s'", member, MAX_COUNT, value);
563      return value;
564   }
565
566   private static String str(Map<?,?> map, String ev, String member) {
567      var v = map.get(member);
568      if (! (v instanceof String s))
569         throw iaex("TerminalEvent %s %s must be a string; got '%s'", ev, member, v);
570      return s;
571   }
572
573   private static long toLong(Map<?,?> map, String ev, String member) {
574      var key = member.contains(".") ? member.substring(member.indexOf('.') + 1) : member;
575      var v = map.get(key);
576      if (v instanceof Long || v instanceof Integer || v instanceof Short || v instanceof Byte)
577         return ((Number)v).longValue();
578      if (v instanceof Number n) {
579         var d = n.doubleValue();
580         if (d == Math.rint(d) && Math.abs(d) <= MAX_SAFE_INT)
581            return (long)d;
582      }
583      throw iaex("TerminalEvent %s %s must be an integer; got '%s'", ev, member, v);
584   }
585
586   private static int toInt(Map<?,?> map, String ev, String member) {
587      var l = toLong(map, ev, member);
588      if (l < Integer.MIN_VALUE || l > Integer.MAX_VALUE)
589         throw iaex("TerminalEvent %s %s must fit in 32 bits; got '%s'", ev, member, l);
590      return (int)l;
591   }
592
593   @Override /* Object */
594   public boolean equals(Object o) {
595      return o instanceof TerminalEvent e && kind == e.kind && seq == e.seq && toContractMap().equals(e.toContractMap());
596   }
597
598   @Override /* Object */
599   public int hashCode() {
600      return Objects.hash(kind, seq, toContractMap());
601   }
602
603   @Override /* Object */
604   public String toString() {
605      return toContractMap().toString();
606   }
607}