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}