@testomatio/reporter 1.1.0-beta-2 → 1.1.0-beta-codecept-logger

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,316 @@
1
+ const debug = require('debug')('@testomatio/reporter:pipe:html');
2
+ const merge = require('lodash.merge');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const chalk = require('chalk');
6
+ const handlebars = require('handlebars');
7
+ const fileUrl = require('file-url');
8
+
9
+ const { fileSystem, isSameTest, ansiRegExp } = require('../utils/utils');
10
+ const { HTML_REPORT } = require('../constants');
11
+
12
+ class HtmlPipe {
13
+ constructor(params, store = {}) {
14
+ this.store = store || {};
15
+ this.title = params.title || process.env.TESTOMATIO_TITLE;
16
+ this.apiKey = params.apiKey || process.env.TESTOMATIO;
17
+ this.isHtml = process.env.TESTOMATIO_HTML_REPORT_SAVE;
18
+
19
+ debug('HTML Pipe: ', this.apiKey ? 'API KEY' : '*no api key provided*');
20
+
21
+ this.isEnabled = false;
22
+ this.htmlOutputPath = "";
23
+ this.fullHtmlOutputPath = "";
24
+ this.filenameMsg = "";
25
+ this.tests = [];
26
+
27
+ if (this.isHtml) {
28
+ this.isEnabled = true;
29
+ this.htmlReportDir = process.env.TESTOMATIO_HTML_REPORT_FOLDER || HTML_REPORT.FOLDER;
30
+
31
+ if (process.env.TESTOMATIO_HTML_FILENAME && process.env.TESTOMATIO_HTML_FILENAME.endsWith(".html")) {
32
+ this.htmlReportName = process.env.TESTOMATIO_HTML_FILENAME
33
+ }
34
+
35
+ if (process.env.TESTOMATIO_HTML_FILENAME && !process.env.TESTOMATIO_HTML_FILENAME.endsWith(".html")) {
36
+ this.htmlReportName = HTML_REPORT.REPORT_DEFAULT_NAME;
37
+ this.filenameMsg = "HTML filename must include the extension \".html\"." +
38
+ ` The default report name "${this.htmlReportDir}/${HTML_REPORT.REPORT_DEFAULT_NAME}" is used!`;
39
+ }
40
+
41
+ if (!process.env.TESTOMATIO_HTML_FILENAME) {
42
+ this.htmlReportName = HTML_REPORT.REPORT_DEFAULT_NAME;
43
+ }
44
+
45
+ this.templateFolderPath = path.resolve(__dirname, '..', 'template');
46
+ this.templateHtmlPath = path.resolve(this.templateFolderPath, HTML_REPORT.TEMPLATE_NAME);
47
+ this.htmlOutputPath = path.join(this.htmlReportDir, this.htmlReportName);
48
+ // create a new folder for the HTML reports
49
+ fileSystem.createDir(this.htmlReportDir);
50
+
51
+ debug(
52
+ chalk.yellow('HTML Pipe:'),
53
+ `Save HTML report: ${this.isEnabled}`,
54
+ `HTML report folder: ${this.htmlReportDir}, report name: ${this.htmlReportName}`
55
+ );
56
+ }
57
+ }
58
+
59
+ async createRun() {
60
+ // empty
61
+ }
62
+
63
+ updateRun() {
64
+ // empty
65
+ }
66
+
67
+ /**
68
+ * Add test data to the result array for saving. As a result of this function, we get a result object to save.
69
+ * @param {Object} test - object which includes each test entry.
70
+ */
71
+ addTest(test) {
72
+ if (!this.isEnabled) return;
73
+
74
+ if (!test.steps || !test.status) return;
75
+
76
+ const index = this.tests.findIndex(t => isSameTest(t, test));
77
+ // update if they were already added
78
+ if (index >= 0) {
79
+ this.tests[index] = merge(this.tests[index], test);
80
+ return;
81
+ }
82
+
83
+ this.tests.push(test);
84
+ }
85
+
86
+ async finishRun(runParams) {
87
+ if (!this.isEnabled) return;
88
+
89
+ if (this.isHtml) {
90
+ // GENERATE HTML reports based on the results data
91
+ this.buildReport({
92
+ runParams,
93
+ tests: this.tests,
94
+ outputPath: this.htmlOutputPath,
95
+ templatePath: this.templateHtmlPath,
96
+ warningMsg: this.filenameMsg
97
+ });
98
+ }
99
+ }
100
+ /**
101
+ * Generates an HTML report based on provided test data and a template.
102
+ * @param {object} opts - Test options used to generate the HTML report:
103
+ * runParams, tests, outputPath, templatePath
104
+ * @returns {void} - This function does not return anything.
105
+ */
106
+
107
+ buildReport(opts) {
108
+ const { runParams, tests, outputPath, templatePath, warningMsg: msg } = opts;
109
+
110
+ debug('HTML tests data:', tests);
111
+
112
+ if (!outputPath) {
113
+ console.log(chalk.yellow(`🚨 HTML export path is not set, ignoring...`));
114
+ return;
115
+ }
116
+
117
+ console.log(chalk.yellow(`⏳ The test results will be added to the HTML report. It will take some time...`));
118
+
119
+ if (msg) {
120
+ console.log(chalk.blue(msg));
121
+ }
122
+
123
+ tests.forEach(test => {
124
+
125
+ if (!test.message || test.message.trim() === "") {
126
+ test.message = "This test has no 'message' code";
127
+ }
128
+
129
+ if (!test.suite_title || test.suite_title.trim() === "") {
130
+ test.suite_title = "Unknown suite";
131
+ }
132
+
133
+ if (!test.title || test.title.trim() === "") {
134
+ test.title = "Unknown test title";
135
+ }
136
+
137
+ if (!test.files || test.files.length === 0) {
138
+ test.files = "This test has no files";
139
+ }
140
+
141
+ if (test.steps) {
142
+ if (!test.steps || test.steps.trim() === "") {
143
+ test.steps = "This test has no 'steps' code";
144
+ }
145
+ else {
146
+ test.steps = removeAnsiColorCodes(test.steps);
147
+ }
148
+ }
149
+
150
+ // TODO: u can added an additional test values to this checks in the future
151
+ });
152
+
153
+ const data = {
154
+ runId: this.store.runId || "",
155
+ status: runParams.status || "No status info",
156
+ parallel: runParams.isParallel || "No parallel info",
157
+ runUrl: this.store.runUrl || "",
158
+ executionTime: testExecutionSumTime(tests),
159
+ executionDate: getCurrentDateTimeFormatted(),
160
+ tests
161
+ };
162
+ // generate output HTML based on the template
163
+ const html = this.#generateHTMLReport(data, templatePath);
164
+
165
+ if (!html) return;
166
+
167
+ fs.writeFileSync(outputPath, html, 'utf-8');
168
+ // Check if the file exists
169
+ if (fs.existsSync(outputPath)) {
170
+ // Get the absolute path of the file
171
+ const absolutePath = path.resolve(outputPath);
172
+ // Convert the file path to a file URL
173
+ const fileUrlPath = fileUrl(absolutePath, {resolve: true});
174
+
175
+ debug('HTML tests data:', fileUrlPath);
176
+
177
+ console.log(chalk.green(`📊 The HTML report was successfully generated. Full filepath: ${fileUrlPath}`));
178
+ } else {
179
+ console.log(chalk.red(`🚨 Failed to generate the HTML report.`));
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Generates an HTML report based on provided test data and a template path.
185
+ * @param {any} data - Test data used to generate the HTML report.
186
+ * @param {string} [templatePath=""] - The path to the HTML template used for generating the report.
187
+ * @returns {string | void} - The generated HTML report as a string or void if templatePath is not provided.
188
+ */
189
+ #generateHTMLReport(data, templatePath = "") {
190
+ if (!templatePath) {
191
+ console.log(chalk.red(`🚨 HTML template not found. Report generation is impossible!`))
192
+ return;
193
+ }
194
+
195
+ const templateSource = fs.readFileSync(templatePath, 'utf8');
196
+ this.#loadReportHelpers();
197
+ try {
198
+ const template = handlebars.compile(templateSource);
199
+
200
+ return template(data);
201
+ }
202
+ catch (e) {
203
+ console.log('Unknown HTML report generation error: ', e);
204
+ }
205
+ }
206
+
207
+ #loadReportHelpers() {
208
+ handlebars.registerHelper('getTestsByStatus', (tests, status) =>
209
+ tests.filter(test => test.status.toLowerCase() === status.toLowerCase()).length
210
+ );
211
+
212
+ handlebars.registerHelper('json', (tests) => {
213
+ function replaceScriptTagsInArray(array) {
214
+ return array.map(obj => {
215
+ const keysToCheck = ["steps", "stack", "title", "suite_title", "message", "code"];
216
+ const newObj = {};
217
+
218
+ for (const key in obj) {
219
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
220
+ if (key === "example") {
221
+ newObj[key] = {};
222
+ for (const subKey in obj[key]) {
223
+ if (Object.prototype.hasOwnProperty.call(obj[key], subKey)) {
224
+ newObj[key][subKey] = typeof obj[key][subKey] === "string"
225
+ ? obj[key][subKey]
226
+ .replace(/<script>/g, "<$cript>")
227
+ .replace(/<\/script>/g, "</$cript>")
228
+ : obj[key][subKey];
229
+ }
230
+ }
231
+ } else if (keysToCheck.includes(key)) {
232
+ newObj[key] = typeof obj[key] === "string"
233
+ ? obj[key].replace(/<script>/g, "<$cript>").replace(/<\/script>/g, "</$cript>")
234
+ : obj[key];
235
+ } else {
236
+ newObj[key] = obj[key];
237
+ }
238
+ }
239
+ }
240
+
241
+ return newObj;
242
+ });
243
+ }
244
+
245
+ // Remove ANSI escape codes
246
+ return JSON.stringify(replaceScriptTagsInArray(tests));
247
+ });
248
+ }
249
+
250
+ toString() {
251
+ return 'HTML Reporter';
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Calculates the total execution time for an array of tests.
257
+ * @param {Object[]} tests - An array of test objects.
258
+ * @param {number} tests[].run_time - The execution time of each test in milliseconds.
259
+ * @returns {string} - The total execution time in a formatted duration string.
260
+ */
261
+ function testExecutionSumTime(tests) {
262
+ const totalMilliseconds = tests.reduce((sum, test) => {
263
+ if (typeof test.run_time === 'number') {
264
+ return sum + test.run_time;
265
+ }
266
+ return sum;
267
+ }, 0);
268
+
269
+ return formatDuration(totalMilliseconds);
270
+ }
271
+
272
+ /**
273
+ * Removes ANSI color codes and converts newline characters to HTML line breaks in a given string.
274
+ * @param {string} str - The input string containing ANSI color codes.
275
+ * @returns {string} - The updated string with removed ANSI color codes and replaced newline characters.
276
+ */
277
+ function removeAnsiColorCodes(str) {
278
+ let updatedStr = str.replace(ansiRegExp(), "");
279
+ updatedStr = updatedStr.replace(/\n/g, '<br>');
280
+
281
+ return updatedStr;
282
+ }
283
+
284
+ /**
285
+ * Formats duration in milliseconds into a human-readable string representation.
286
+ * @param {number} duration - The duration in milliseconds.
287
+ * @returns {string} - The formatted duration string (e.g., "2h 30m 15s 500ms").
288
+ */
289
+ function formatDuration(duration) {
290
+ const milliseconds = duration % 1000;
291
+ duration = (duration - milliseconds) / 1000;
292
+ const seconds = duration % 60;
293
+ duration = (duration - seconds) / 60;
294
+ const minutes = duration % 60;
295
+ const hours = (duration - minutes) / 60;
296
+
297
+ return `${hours}h ${minutes}m ${seconds}s ${milliseconds}ms`;
298
+ }
299
+
300
+ /**
301
+ * Retrieves the current date and time in a formatted string.
302
+ * @returns {string} - The formatted date and time string (e.g., "(01/01/2023 12:00:00)").
303
+ */
304
+ function getCurrentDateTimeFormatted() {
305
+ const currentDate = new Date();
306
+ const day = currentDate.getDate().toString().padStart(2, '0');
307
+ const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');
308
+ const year = currentDate.getFullYear();
309
+ const hours = currentDate.getHours().toString().padStart(2, '0');
310
+ const minutes = currentDate.getMinutes().toString().padStart(2, '0');
311
+ const seconds = currentDate.getSeconds().toString().padStart(2, '0');
312
+
313
+ return `(${day}/${month}/${year} ${hours}:${minutes}:${seconds})`;
314
+ }
315
+
316
+ module.exports = HtmlPipe;
package/lib/pipe/index.js CHANGED
@@ -6,6 +6,7 @@ const TestomatioPipe = require('./testomatio');
6
6
  const GitHubPipe = require('./github');
7
7
  const GitLabPipe = require('./gitlab');
8
8
  const CsvPipe = require('./csv');
9
+ const HtmlPipe = require('./html');
9
10
 
10
11
  function PipeFactory(params, opts) {
11
12
  const extraPipes = [];
@@ -43,6 +44,7 @@ function PipeFactory(params, opts) {
43
44
  new GitHubPipe(params, opts),
44
45
  new GitLabPipe(params, opts),
45
46
  new CsvPipe(params, opts),
47
+ new HtmlPipe(params, opts),
46
48
  ...extraPipes,
47
49
  ];
48
50
 
@@ -5,10 +5,10 @@ const JsonCycle = require('json-cycle');
5
5
  const { APP_PREFIX, STATUS } = require('../constants');
6
6
  const { isValidUrl, foundedTestLog } = require('../utils/utils');
7
7
  const { parseFilterParams, generateFilterRequestParams, setS3Credentials, } = require('../utils/pipe_utils');
8
+ const { TESTOMATIO } = require('../config');
8
9
 
9
- const { TESTOMATIO_RUN } = process.env;
10
- if (TESTOMATIO_RUN) {
11
- process.env.runId = TESTOMATIO_RUN;
10
+ if (process.env.TESTOMATIO_RUN) {
11
+ process.env.runId = process.env.TESTOMATIO_RUN;
12
12
  }
13
13
 
14
14
  /**
@@ -21,7 +21,7 @@ class TestomatioPipe {
21
21
  constructor(params, store) {
22
22
  this.isEnabled = false;
23
23
  this.url = params.testomatioUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
24
- this.apiKey = params.apiKey || process.env.TESTOMATIO;
24
+ this.apiKey = params.apiKey || TESTOMATIO;
25
25
  debug('Testomatio Pipe: ', this.apiKey ? 'API KEY' : '*no api key*');
26
26
  if (!this.apiKey) {
27
27
  return;
@@ -33,6 +33,7 @@ class TestomatioPipe {
33
33
  this.sharedRun = !!process.env.TESTOMATIO_SHARED_RUN;
34
34
  this.groupTitle = params.groupTitle || process.env.TESTOMATIO_RUNGROUP_TITLE;
35
35
  this.env = process.env.TESTOMATIO_ENV;
36
+ this.label = process.env.TESTOMATIO_LABEL;
36
37
 
37
38
  this.axios = axios.create({
38
39
  baseURL: `${this.url.trim()}`,
@@ -131,6 +132,7 @@ class TestomatioPipe {
131
132
  jira_id: this.jiraId,
132
133
  env: this.env,
133
134
  title: this.title,
135
+ label: this.label,
134
136
  shared_run: this.sharedRun,
135
137
  }).filter(([, value]) => !!value),
136
138
  );
@@ -28,7 +28,7 @@ class KeyValueStorage {
28
28
  /**
29
29
  * Returns key-values pairs for the test as object
30
30
  * @param {*} context testId or test context from test runner
31
- * @returns {Object} key-values pairs as object
31
+ * @returns {Object} key-values pairs as object, e.g. {priority: 'high', browser: 'chrome'}
32
32
  */
33
33
  get(context) {
34
34
  if (!context) return null;
@@ -187,12 +187,12 @@ class Logger {
187
187
  /* prevent multiple console interceptions (cause of infinite loop)
188
188
  actual only for "console", because its used as default output and is intercepted by default */
189
189
  const isUserLoggerConsole = userLogger.toString?.().toLowerCase() === '[object console]';
190
- if (isUserLoggerConsole && process.env.TESTOMATIO_LOGGER_CONSOLE_INTERCEPTED) {
190
+ if (isUserLoggerConsole && global.TESTOMATIO_LOGGER_CONSOLE_INTERCEPTED) {
191
191
  debug(`Try to intercept console, but it is already intercepted`);
192
192
  return;
193
193
  }
194
194
 
195
- process.env.TESTOMATIO_LOGGER_CONSOLE_INTERCEPTED = 'true';
195
+ global.TESTOMATIO_LOGGER_CONSOLE_INTERCEPTED = true;
196
196
  debug(isUserLoggerConsole ? 'console intercepted' : 'User logger intercepted');
197
197
 
198
198
  // override user logger (any, e.g. console) methods to intercept log messages
@@ -0,0 +1,249 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link href="https://fonts.googleapis.com/css2?family=Fira+Mono:wght@400;500;700&display=swap" rel="stylesheet">
7
+ <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/accordionjs@2.1.2/accordion.min.css">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
10
+ <title>Report {{runId}} - Testomat.io</title>
11
+ <link rel="stylesheet" href="./style.css">
12
+ {{!-- TODO: styling create as a new additional file instead of template => copy default one --}}
13
+ <style>
14
+ body {
15
+ font-family: "Fira Sans", sans-serif;
16
+ }
17
+ </style>
18
+ </head>
19
+
20
+ <body class="bg-gray-50 pt-6 px-4 lg:pt-4 lg:px-40 h-full;">
21
+ <div id="testomatio_report">
22
+ <div class="flex items-center flex-col space-y-4 md:flex-row md:justify-between md:h-14 mb-6">
23
+ <div class="flex flex-col md:flex-row space-x-3 items-center">
24
+ <h2 class="text-gray-900 leading-none text-3xl font-semibold mb-0">{{runId}}
25
+ </h2>
26
+ {{!-- <span class="text-sm text-gray-500">
27
+ {{executionDate}}
28
+ </span> --}}
29
+ </div>
30
+ <div class="flex space-x-3 items-center">
31
+ <a class="inline-flex items-center border border-transparent leading-none text-white bg-indigo-500 hover:bg-indigo-700 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer px-4 py-2 text-base leading-none"
32
+ style="min-height: 38px"
33
+ href="{{runUrl}}"
34
+ target="_blank">
35
+ Full Report
36
+ </a>
37
+ </div>
38
+ </div>
39
+ <div class="bg-white border overflow-hidden rounded-lg divide-y divide-gray-200 mb-12">
40
+ <div class="flex flex-col lg:divide-x divide-gray-200 sm:flex-col md:flex-col lg:flex-row">
41
+ <div class="w-full lg:w-1/2">
42
+ <!-- Left chart section -->
43
+ <div class="p-4">
44
+ <h2 class="text-xl font-semibold mb-4">Testomatio Test Results</h2>
45
+ <span class="text-sm text-gray-500 items-center">{{executionDate}}</span>
46
+ <div class="hart-container" style="height: 200px;">
47
+ <canvas id="testomatio_chart"></canvas>
48
+ <div for="testomatio_chart"></div>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ <div class="w-full lg:w-1/2">
53
+ <!-- Right chart section -->
54
+ <div class="p-4">
55
+ <h2 class="text-xl font-semibold mb-4">Testomatio Run Info</h2>
56
+ <dl>
57
+ <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
58
+ <dt class="text-sm font-medium text-gray-500">
59
+ RUN Status
60
+ </dt>
61
+ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
62
+ <span class="inline-flex items-center justify-center px-2 py-0.5 text-xs font-bold leading-none bg-green-200 text-green-600 rounded-full">
63
+ {{status}}
64
+ </span>
65
+ </dd>
66
+ </div>
67
+ <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
68
+ <dt class="text-sm font-medium text-gray-500">
69
+ Duration
70
+ </dt>
71
+ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
72
+ <span>
73
+ <i class="fa fa-clock-o"></i>
74
+ </span>
75
+ {{executionTime}}
76
+ </dd>
77
+ </div>
78
+ </div>
79
+ </dl>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </div>
84
+
85
+ <div class="px-4 py-5 lg:px-20 lg:py-5">
86
+ <!-- TODO: maybe add a search section by test NAME!! -->
87
+ <div class="px-4 py-5 lg:px-20 lg:py-5 border rounded-md mb-4">
88
+ <div class="mb-2">
89
+ <h3 class="text-2xl leading-10 font-medium text-gray-900">
90
+ Search and Summary
91
+ </h3>
92
+ </div>
93
+ <div class="flex items-center space-x-4">
94
+ <div class="relative">
95
+ <input type="text" id="search-input" placeholder="Search by test title..." class="border rounded-md py-1 px-2 w-48 focus:outline-none focus:ring focus:border-blue-300">
96
+
97
+ </div>
98
+ <div class="test-status">
99
+ <div class="flex items-center">
100
+ <span class="font-medium mr-2">All:</span>
101
+ <span class="text-blue-600 font-semibold">{{tests.length}}</span>
102
+ <span class="font-medium ml-4 mr-2">Passed:</span>
103
+ <span class="text-green-600 font-semibold">{{getTestsByStatus tests "PASSED"}}</span>
104
+ <span class="font-medium ml-4 mr-2">Failed:</span>
105
+ <span class="text-red-600 font-semibold">{{getTestsByStatus tests "FAILED"}}</span>
106
+ <span class="font-medium ml-4 mr-2">Skipped:</span>
107
+ <span class="text-yellow-600 font-semibold">{{getTestsByStatus tests "SKIPPED"}}</span>
108
+ </div>
109
+ </div>
110
+
111
+ </div>
112
+ </div>
113
+
114
+ <ul class="accordionjs bg-gray-50 rounded-md divide-y divide-gray-200 text-gray-900">
115
+ <li class="pl-3 pr-4 py-3 text-sm cursor-pointer">
116
+ <h4 class="text-lg flex items-center space-x-1">
117
+ <span class="font-medium">Passed</span>
118
+ <span class="inline-flex items-center justify-center px-2 py-0.5 text-xs font-bold leading-none bg-green-200 text-green-600 rounded-full">
119
+ {{getTestsByStatus tests "PASSED"}}
120
+ </span>
121
+ </h4>
122
+ <ul role="list" class="pl-2 divide-y divide-gray-200">
123
+ {{#each tests}}
124
+ <ul role="list" class="pl-2 divide-y divide-gray-200">
125
+ {{#eq status "passed"}}
126
+ <li class="py-3 flex justify-between items-center">
127
+ <div>
128
+ <div class="success"></div>
129
+ <a class="testrun hover:underline" data-toggle="test-{{test_id}}" data-target="test-code-{{test_id}}">
130
+ {{title}} title
131
+ </a>
132
+ </div>
133
+ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
134
+ <span>
135
+ <i class="fa fa-clock-o"></i>
136
+ </span>
137
+ {{#if run_time}}
138
+ {{run_time}}ms
139
+ {{/if}}
140
+ </dd>
141
+ </li>
142
+ <li class="py-3 test-code-container test-code" id="test-code-{{test_id}}">
143
+ <pre>
144
+ <code>
145
+ {{steps}}
146
+ </code>
147
+ </pre>
148
+ </li>
149
+ {{/eq}}
150
+ </ul>
151
+ {{/each}}
152
+ </ul>
153
+ </li>
154
+
155
+ <li class="pl-3 pr-4 py-3 text-sm cursor-pointer">
156
+ <h4 class="text-lg flex items-center space-x-1">
157
+ <span class="font-medium">Failed</span>
158
+ <span class="inline-flex items-center justify-center px-2 py-0.5 text-xs font-bold leading-none bg-red-200 text-red-500 rounded-full">
159
+ {{getTestsByStatus tests "FAILED"}}
160
+ </span>
161
+ </h4>
162
+
163
+ <ul role="list" class="pl-2 divide-y divide-gray-200">
164
+ {{#each tests}}
165
+ {{#eq status "failed"}}
166
+ <li class="py-3 flex justify-between items-center">
167
+ <div>
168
+ <div class="fail"></div>
169
+ <a class="testrun hover:underline" data-toggle="test-{{test_id}}" data-target="test-code-{{test_id}}">
170
+ {{title}} title
171
+ </a>
172
+ </div>
173
+ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
174
+ <span>
175
+ <i class="fa fa-clock-o"></i>
176
+ </span>
177
+ {{#if run_time}}
178
+ {{run_time}}ms
179
+ {{/if}}
180
+ </dd>
181
+ </li>
182
+ <li class="py-3 test-code-container test-code" id="test-code-{{test_id}}">
183
+ <pre>
184
+ <code>
185
+ {{steps}}
186
+ </code>
187
+ </pre>
188
+ </li>
189
+ {{/eq}}
190
+ {{/each}}
191
+ </ul>
192
+ </li>
193
+ </ul>
194
+ </div>
195
+ </div>
196
+ <div
197
+ class="fixed bg-white border-t inset-x-0 bottom-0 mx-auto py-1 px-3 sm:px-6 lg:px-8 flex items-center justify-between flex-wrap">
198
+ <p class="ml-3 text-gray-500 text-xs lg:truncate">
199
+ This is a public report generated by <a class="hover:underline text-gray-700 font-semibold"
200
+ href="https://testomat.io" target="_blank">Testomat.io</a>. Everyone who has accesse to this link can
201
+ see this report.<br>
202
+ </p>
203
+ </div>
204
+
205
+ </body>
206
+ <!-- Jquery from a CDN -->
207
+ <script type="module" src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.3.2/chart.min.js"></script>
208
+ <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
209
+ <script src="https://cdn.jsdelivr.net/npm/accordionjs@2.1.2/accordion.min.js"></script>
210
+ <!-- Chart.js from a CDN -->
211
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
212
+
213
+ <!-- Include Handlebars from a CDN -->
214
+ <script src="https://cdn.jsdelivr.net/npm/handlebars@latest/dist/handlebars.js"></script>
215
+ <!-- Handlebars tamplate actions -->
216
+ <script>
217
+ $(document).ready(function ($) {
218
+ $(".accordionjs").accordionjs();
219
+
220
+ $('#copy-url').on('click', () => {
221
+ navigator.clipboard.writeText(window.location.href);
222
+ })
223
+ });
224
+ //Open code section handler
225
+ document.querySelectorAll('.test-code').forEach(element => {
226
+ element.style.display = 'none';
227
+ });
228
+ const testrunLinks = document.querySelectorAll('.testrun');
229
+
230
+ testrunLinks.forEach(link => {
231
+ link.addEventListener('click', function () {
232
+ // get "data-toggle" & "data-target" from link env
233
+ const toggleId = this.getAttribute('data-toggle');
234
+ const targetId = this.getAttribute('data-target');
235
+
236
+ // get "tests.code" elements and change view
237
+ const targetElement = document.querySelector(`#${targetId}`);
238
+
239
+ // SET "tests.code" viewing after each click
240
+ if (targetElement.style.display === 'none') {
241
+ targetElement.style.display = 'block';
242
+ } else {
243
+ targetElement.style.display = 'none';
244
+ }
245
+ });
246
+ });
247
+ </script>
248
+ <script type="text/javascript" src="./chart-my.js"></script>
249
+ </html>