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.terminal.report.ReportJson.*;
020import static org.apache.juneau.terminal.report.ReportText.*;
021
022import java.nio.file.*;
023import java.util.*;
024
025import org.apache.juneau.terminal.report.TerminalEvent.*;
026
027/**
028 * Reads Playwright's <c>--reporter=json</c> output.
029 *
030 * <p>
031 * The framework key is <c>playwright</c>.  Suites and specs are walked recursively; a suite whose title equals its file
032 * is the file-level suite and does not contribute to the test name.  A <c>flaky</c> test (failed, then passed on retry)
033 * is a pass.  A truncated file yields no tests and a warning.
034 */
035@SuppressWarnings({
036   "java:S1192", // JSON keys of the Playwright report are protocol literals.
037   "java:S3776" // Walking the suite tree and folding retries is inherently branchy.
038})
039public final class PlaywrightJsonReportReader implements ReportReader {
040
041   private static final int MAX_DEPTH = 100;
042   private static final String SEP = " › ";
043
044   @Override /* ReportReader */
045   public String fw() {
046      return "playwright";
047   }
048
049   @Override /* ReportReader */
050   public ReportResult read(Path path, ReportLimits lim) {
051      var out = new ReportBuilder();
052      var root = readRoot(path, lim, out);
053      if (root == null)
054         return out.build(fw());
055      var files = new ArrayList<String>();
056      var rows = new ArrayList<ReportTest>();
057      var rowFiles = new ArrayList<String>();
058      for (var s : list(root.get("suites"))) {
059         var sm = map(s);
060         if (sm != null && ! walk(sm, List.of(), null, 0, lim, out, files, rows, rowFiles))
061            break;
062      }
063      var display = stripCommonDir(files);
064      for (var i = 0; i < rows.size(); i++) {
065         var r = rows.get(i);
066         var suite = clip(display.get(files.indexOf(rowFiles.get(i))), lim.maxNameChars());
067         out.tests.add(new ReportTest(suite, r.name(), r.status(), r.ms(), r.msg(), r.trace()));
068      }
069      return out.build(fw());
070   }
071
072   /** Returns <jk>false</jk> when the test limit stopped the walk. */
073   @SuppressWarnings({
074      "java:S107" // Internal recursive walker; parameters are the traversal state.
075   })
076   private boolean walk(Map<String,Object> suite, List<String> titles, String parentFile, int depth, ReportLimits lim, ReportBuilder out,
077         List<String> files, List<ReportTest> rows, List<String> rowFiles) {
078      if (depth > MAX_DEPTH)
079         return true;
080      var file = str(suite.get("file")) != null ? str(suite.get("file")) : parentFile;
081      var title = str(suite.get("title"));
082      var path = titles;
083      if (title != null && ! title.isEmpty() && ! title.equals(str(suite.get("file")))) {
084         path = new ArrayList<>(titles);
085         path.add(title);
086      }
087      for (var sp : list(suite.get("specs"))) {
088         var spec = map(sp);
089         if (spec == null)
090            continue;
091         var specFile = str(spec.get("file")) != null ? str(spec.get("file")) : file;
092         var key = specFile == null ? "(unknown file)" : specFile;
093         if (! files.contains(key))
094            files.add(key);
095         var names = new ArrayList<>(path);
096         if (str(spec.get("title")) != null)
097            names.add(str(spec.get("title")));
098         for (var t : list(spec.get("tests"))) {
099            var tm = map(t);
100            if (tm == null)
101               continue;
102            if (rows.size() >= lim.maxTests()) {
103               out.truncated = true;
104               out.warnings.add("report truncated at " + lim.maxTests() + " tests");
105               return false;
106            }
107            rows.add(test(names, tm, lim));
108            rowFiles.add(key);
109         }
110      }
111      for (var child : list(suite.get("suites"))) {
112         var cm = map(child);
113         if (cm != null && ! walk(cm, path, file, depth + 1, lim, out, files, rows, rowFiles))
114            return false;
115      }
116      return true;
117   }
118
119   private static ReportTest test(List<String> names, Map<String,Object> t, ReportLimits lim) {
120      var name = String.join(SEP, names);
121      var project = str(t.get("projectName"));
122      if (project != null && ! project.isEmpty())
123         name += " [" + project + "]";
124      var results = list(t.get("results"));
125      Map<String,Object> last = null;
126      long total = 0;
127      var hasMs = false;
128      Map<String,Object> failing = null;
129      for (var r : results) {
130         var rm = map(r);
131         if (rm == null)
132            continue;
133         last = rm;
134         var d = ms(rm.get("duration"));
135         if (d != null) {
136            total += d;
137            hasMs = true;
138         }
139         if (failing == null && isFailed(str(rm.get("status"))))
140            failing = rm;
141      }
142      var status = status(str(t.get("status")), last == null ? null : str(last.get("status")));
143      String msg = null;
144      String trace = null;
145      if (status == TestStatus.FAIL && failing != null) {
146         var text = errorText(failing);
147         if (text != null) {
148            var body = stripAnsi(text).strip();
149            var first = TerminalViewChecks.firstLine(body);
150            msg = first.isEmpty() ? null : clip(first, lim.maxMsgChars());
151            trace = clip(body, lim.maxTraceChars());
152         }
153      }
154      return new ReportTest(null, clip(name, lim.maxNameChars()), status, hasMs ? Long.valueOf(total) : null, msg, trace);
155   }
156
157   private static boolean isFailed(String s) {
158      return "failed".equals(s) || "timedOut".equals(s) || "interrupted".equals(s);
159   }
160
161   private static TestStatus status(String testStatus, String lastResult) {
162      if (testStatus != null) {
163         switch (testStatus) {
164            case "expected", "flaky": return TestStatus.PASS;
165            case "unexpected": return TestStatus.FAIL;
166            case "skipped": return TestStatus.SKIP;
167            default: break;
168         }
169      }
170      if (lastResult == null)
171         return TestStatus.SKIP;
172      if ("passed".equals(lastResult))
173         return TestStatus.PASS;
174      return isFailed(lastResult) ? TestStatus.FAIL : TestStatus.SKIP;
175   }
176
177   private static String errorText(Map<String,Object> result) {
178      var e = map(result.get("error"));
179      if (e != null && str(e.get("message")) != null)
180         return str(e.get("message"));
181      for (var x : list(result.get("errors"))) {
182         var xm = map(x);
183         if (xm != null && str(xm.get("message")) != null)
184            return str(xm.get("message"));
185      }
186      return null;
187   }
188}