cucumber-reactive-reporter 1.0.11 → 1.1.1

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.
@@ -1,178 +1,324 @@
1
+ import { createRequire } from 'module';
1
2
  import fs from 'fs';
3
+ import 'fs/promises';
2
4
  import ncp from 'ncp';
3
5
  import path from 'path';
4
6
  import ut from 'util';
5
7
 
6
- // import { createRequire } from 'module';
7
-
8
- ncp.limit = 16;
9
-
10
- let modulePath = require.resolve("./package.json"); //trick to resolve path to the installed module
11
-
12
- /**
13
- options.filter - a RegExp instance, against which each file name is tested to determine whether to copy it or not, or a function taking single parameter: copied file name, returning true or false, determining whether to copy file or not.
14
-
15
- options.transform - a function: function (read, write) { read.pipe(write) } used to apply streaming transforms while copying.
16
-
17
- options.clobber - boolean=true. if set to false, ncp will not overwrite destination files that already exist.
18
-
19
- options.dereference - boolean=false. If set to true, ncp will follow symbolic links. For example, a symlink in the source tree pointing to a regular file will become a regular file in the destination tree. Broken symlinks will result in errors.
20
-
21
- options.stopOnErr - boolean=false. If set to true, ncp will behave like cp -r, and stop on the first error it encounters. By default, ncp continues copying, logging all errors and returning an array.
22
-
23
- options.errs - stream. If options.stopOnErr is false, a stream can be provided, and errors will be written to this stream.
24
- */
25
-
26
-
27
- let cp = (source, destination, options) => {
28
- return new Promise((resolve, reject) => {
29
- ncp(source, destination, err => {
30
- if (err) {
31
- reject(new Error(err));
32
- }
33
-
34
- resolve();
35
- });
36
- });
37
- };
38
-
39
- let _makeSafe = input => {
40
- input = input.replace(/&/g, '&');
41
- input = input.replace(/</g, '&lt;');
42
- input = input.replace(/>/g, '&gt;');
43
- return input;
44
- };
45
8
  /**
46
- *
47
- * @param source path to the cucumber results json
48
- * @param dest folder path where html report gets written to
49
- * @param options
9
+ * Purpose: Normalize cucumber JSON into reporter store state.
10
+ * Responsibilities:
11
+ * - Normalize legacy cucumber JSON to a stable feature/scenario/step shape.
12
+ * - Build feature, scenario, and step maps for the UI store.
13
+ * Inputs/Outputs: Accepts parsed cucumber JSON; returns store-shaped state.
14
+ * Invariants: Input must be legacy JSON (features/elements/steps).
15
+ * See: /agents.md
50
16
  */
51
17
 
52
-
53
- const generate = async (source, dest, options) => {
54
- options ? true : options = {};
55
- const CUCUMBER_JSON_PATH = "_cucumber-results.json";
56
- const SETTINGS_JSON_PATH = "_reporter_settings.json";
57
- const HTML_PATH = path.join(path.dirname(modulePath), "react"); // "linkTags": [{
58
- // "pattern": "[a-zA-Z]*-(\\d)*$",
59
- // "link": "https://bydeluxe.atlassian.net/browse/"
60
- // }]
61
- //defaults
62
-
63
- const {
64
- title = "Cucumber Report",
65
- //report page title
66
- description = "Cucumber report",
67
- //description to be set at the page header
68
- metadata = {},
69
- linkTags = null
70
- } = options;
71
-
72
- let __dirname = path.resolve();
73
-
74
- if (path.isAbsolute(source) === false) {
75
- source = path.join(__dirname, source);
18
+ const LEGACY_FORMAT_HELP = ["Unsupported cucumber output format.", "This reporter expects legacy JSON (features/elements/steps).", "If you are using the message formatter, rerun with --format json:<file> or", 'use inputFormat: "auto" to detect message output.'].join(" ");
19
+ const INPUT_FORMAT_HELP = ['inputFormat must be "legacy-json" or "auto".', 'Use "legacy-json" for --format json:<file> output.', 'Use "auto" to detect and reject message formatter output explicitly.'].join(" ");
20
+ const ATTACHMENTS_ENCODING_HELP = ['attachmentsEncoding must be "auto", "base64", or "raw".', 'Use "raw" if your cucumber JSON stores text attachments unencoded.', 'Use "base64" if text attachments are base64-encoded.', 'Use "auto" to decode base64-looking text attachments.'].join(" ");
21
+ const normalizeMimeType = value => String(value ?? "").split(";")[0].trim().toLowerCase();
22
+ const shouldDecodeEmbedding = mimeType => {
23
+ if (!mimeType) {
24
+ return false;
76
25
  }
77
-
78
- fs.accessSync(source);
79
-
80
- if (!dest) {
81
- dest = path.dirname(source);
82
- } else {
83
- if (path.isAbsolute(dest) === false) {
84
- dest = path.resolve(dest);
26
+ if (mimeType.startsWith("text/")) {
27
+ return true;
28
+ }
29
+ return mimeType === "application/json" || mimeType === "application/xml";
30
+ };
31
+ const looksLikeBase64 = value => {
32
+ if (typeof value !== "string") {
33
+ return false;
34
+ }
35
+ const trimmed = value.trim();
36
+ if (!trimmed || trimmed.length % 4 !== 0) {
37
+ return false;
38
+ }
39
+ if (/[^A-Za-z0-9+/=]/.test(trimmed)) {
40
+ return false;
41
+ }
42
+ return true;
43
+ };
44
+ const isLikelyText = value => {
45
+ if (typeof value !== "string") {
46
+ return false;
47
+ }
48
+ if (value.includes("\uFFFD")) {
49
+ return false;
50
+ }
51
+ const sample = value.slice(0, 2000);
52
+ if (!sample.length) {
53
+ return true;
54
+ }
55
+ let printable = 0;
56
+ for (const char of sample) {
57
+ const code = char.charCodeAt(0);
58
+ if (code === 9 || code === 10 || code === 13) {
59
+ printable += 1;
60
+ continue;
61
+ }
62
+ if (code >= 32 && code !== 127) {
63
+ printable += 1;
85
64
  }
86
65
  }
87
-
88
- console.log(`__dirname: ${__dirname}\n` + `html path: ${HTML_PATH}\n` + `source: ${source}\n` + `destination: ${dest}\n` + `title: ${title}\n` + `description: ${description}\n` + `metadata: ${ut.inspect(metadata, false, null)}\n` + `linkTags: ${ut.inspect(linkTags, false, null)}\n`); //validate input json and make a copy
89
-
90
- let str = fs.readFileSync(source).toString();
91
- let obj = JSON.parse(str);
92
-
93
- let out = _prepDataForStore(obj);
94
-
95
- let modifiedJSON = JSON.stringify(out);
96
- let destExists = true;
97
-
66
+ return printable / sample.length > 0.85;
67
+ };
68
+ const decodeBase64Text = value => {
69
+ if (!looksLikeBase64(value)) {
70
+ return null;
71
+ }
72
+ const decoded = Buffer.from(value, "base64").toString("utf8");
73
+ if (!isLikelyText(decoded)) {
74
+ return null;
75
+ }
76
+ return decoded;
77
+ };
78
+ const formatJsonText = value => {
79
+ if (typeof value !== "string") {
80
+ return null;
81
+ }
98
82
  try {
99
- fs.accessSync(dest);
83
+ const parsed = JSON.parse(value);
84
+ return JSON.stringify(parsed, null, 2);
100
85
  } catch (err) {
101
- destExists = false;
86
+ return null;
102
87
  }
103
-
104
- if (!destExists) {
105
- fs.mkdirSync(dest, {
106
- recursive: true
107
- });
88
+ };
89
+ const normalizeEmbeddings = (embeddings, {
90
+ attachmentsEncoding
91
+ }) => {
92
+ if (!Array.isArray(embeddings)) {
93
+ return embeddings;
108
94
  }
109
-
110
- fs.writeFileSync(path.join(dest, CUCUMBER_JSON_PATH), modifiedJSON);
111
- fs.writeFileSync(path.join(dest, SETTINGS_JSON_PATH), JSON.stringify(options));
112
- await cp(HTML_PATH, dest); //swap out some tokens in the html
113
-
114
- let indexPagePath = path.join(dest, "index.html");
115
- let htmlStr = fs.readFileSync(indexPagePath, "utf8").toString();
116
- let modified = htmlStr.replace(/-=title=-/g, _makeSafe(title));
117
- fs.writeFileSync(indexPagePath, modified, "utf8");
118
- console.log("done");
95
+ return embeddings.map(embedding => normalizeEmbedding(embedding, {
96
+ attachmentsEncoding
97
+ }));
119
98
  };
120
-
121
- let _prepDataForStore = data => {
122
- let state = {};
123
- state.features = {};
124
- state.features.list = [];
125
- state.features.featuresMap = {};
126
- state.scenarios = {};
127
- state.scenarios.list = [];
128
- state.scenarios.scenariosMap = {};
129
- state.steps = {};
130
- state.steps.stepsMap = {};
131
- state.steps.totalDurationNanoSec = 0; //parse
132
-
133
- let featureIndex = 0;
134
- console.time("loadTotal");
135
-
136
- for (let f of data) {
137
- //FEATURE
138
- //cucumber id field is not guaranteed to be unique for feature/scenario/step
139
- f.id = `${featureIndex++}_${f.id}`;
140
-
141
- _processFeature(state, f); //SCENARIO
142
-
143
-
144
- let numScenarios = f.elements.length; //avoid multiple lookups;
145
-
146
- if (f.elements && numScenarios) {
147
- let sc_index = 0;
148
-
149
- for (let sc of f.elements) {
150
- //need to make scenario id unique as well
151
- sc_index++;
152
- let sc_id_arr = sc.id.split(";");
153
- sc_id_arr[0] = f.id;
154
-
155
- if (sc_id_arr.length) {
156
- sc_id_arr[1] = `${sc_index - 1}_${sc_id_arr[1]}`;
157
- }
158
-
159
- sc.id = sc_id_arr.join(";");
160
-
161
- _processScenario(state, f.id, sc); //STEPS
162
-
163
-
164
- for (let st of sc.steps) {
165
- _processStep(state, sc.id, st);
166
- }
99
+ const normalizeEmbedding = (embedding, {
100
+ attachmentsEncoding
101
+ }) => {
102
+ if (!embedding || typeof embedding !== "object") {
103
+ return embedding;
104
+ }
105
+ const mimeType = normalizeMimeType(embedding.mime_type ?? embedding.media?.type);
106
+ if (attachmentsEncoding === "raw") {
107
+ if (mimeType === "application/json" && typeof embedding.data === "string") {
108
+ const formatted = formatJsonText(embedding.data);
109
+ if (formatted) {
110
+ return {
111
+ ...embedding,
112
+ data: formatted
113
+ };
167
114
  }
168
115
  }
116
+ return embedding;
117
+ }
118
+ if (!shouldDecodeEmbedding(mimeType)) {
119
+ return embedding;
120
+ }
121
+ if (typeof embedding.data !== "string") {
122
+ return embedding;
123
+ }
124
+ // Legacy cucumber JSON embeds text payloads as base64; decode for readable output.
125
+ const decoded = decodeBase64Text(embedding.data);
126
+ if (!decoded) {
127
+ return embedding;
128
+ }
129
+ if (mimeType === "application/json") {
130
+ const formatted = formatJsonText(decoded);
131
+ if (!formatted) {
132
+ return embedding;
133
+ }
134
+ return {
135
+ ...embedding,
136
+ data: formatted
137
+ };
138
+ } else if (["application/xml", "text/xml", "text/html"].includes(mimeType)) {
139
+ if (!decoded.includes("<")) {
140
+ return embedding;
141
+ }
142
+ }
143
+ return {
144
+ ...embedding,
145
+ data: decoded
146
+ };
147
+ };
148
+ const resolveAttachmentsEncoding = ({
149
+ attachmentsEncoding,
150
+ cucumberVersion
151
+ }) => {
152
+ if (!attachmentsEncoding) {
153
+ const parsed = parseCucumberMajor(cucumberVersion);
154
+ if (Number.isFinite(parsed)) {
155
+ return parsed < 7 ? "raw" : "base64";
156
+ }
157
+ return "auto";
158
+ }
159
+ if (!["auto", "base64", "raw"].includes(attachmentsEncoding)) {
160
+ throw new Error(ATTACHMENTS_ENCODING_HELP);
161
+ }
162
+ return attachmentsEncoding;
163
+ };
164
+ const parseCucumberMajor = cucumberVersion => {
165
+ if (!cucumberVersion) {
166
+ return null;
169
167
  }
168
+ const value = String(cucumberVersion).trim();
169
+ if (!value) {
170
+ return null;
171
+ }
172
+ const match = value.match(/(\d+)(?:\.\d+)?/);
173
+ if (!match) {
174
+ return null;
175
+ }
176
+ const major = Number.parseInt(match[1], 10);
177
+ return Number.isFinite(major) ? major : null;
178
+ };
170
179
 
171
- console.timeEnd("loadTotal");
180
+ /**
181
+ * Convert cucumber JSON into the reporter store shape.
182
+ * @param {unknown} input parsed cucumber JSON
183
+ * @returns {Object} normalized state for the UI store
184
+ * @throws {Error} when input is not legacy cucumber JSON
185
+ * @example
186
+ * const state = prepareStoreState(legacyJsonArray);
187
+ */
188
+ const prepareStoreState = (input, {
189
+ inputFormat = "legacy-json",
190
+ attachmentsEncoding,
191
+ cucumberVersion
192
+ } = {}) => {
193
+ if (!["legacy-json", "auto"].includes(inputFormat)) {
194
+ throw new Error(INPUT_FORMAT_HELP);
195
+ }
196
+ if (inputFormat === "auto" && looksLikeMessageStream(input)) {
197
+ throw new Error(LEGACY_FORMAT_HELP);
198
+ }
199
+ const resolvedEncoding = resolveAttachmentsEncoding({
200
+ attachmentsEncoding,
201
+ cucumberVersion
202
+ });
203
+ const features = resolveFeatures(input);
204
+ if (!features) {
205
+ throw new Error(LEGACY_FORMAT_HELP);
206
+ }
207
+ const state = createEmptyState();
208
+ let featureIndex = 0;
209
+ for (const rawFeature of features) {
210
+ if (!rawFeature) {
211
+ continue;
212
+ }
213
+ const feature = normalizeFeature(rawFeature, featureIndex);
214
+ featureIndex += 1;
215
+ processFeature(state, feature);
216
+ processFeatureElements(state, feature, {
217
+ attachmentsEncoding: resolvedEncoding
218
+ });
219
+ }
172
220
  return state;
173
221
  };
174
-
175
- let _processFeature = (state, f) => {
222
+ const createEmptyState = () => ({
223
+ features: {
224
+ list: [],
225
+ featuresMap: {}
226
+ },
227
+ scenarios: {
228
+ list: [],
229
+ scenariosMap: {}
230
+ },
231
+ steps: {
232
+ stepsMap: {},
233
+ totalDurationNanoSec: 0
234
+ }
235
+ });
236
+ const looksLikeMessageStream = input => {
237
+ if (!Array.isArray(input)) {
238
+ return false;
239
+ }
240
+ return input.some(item => {
241
+ if (!item || typeof item !== "object") {
242
+ return false;
243
+ }
244
+ return "gherkinDocument" in item || "pickle" in item || "testCaseStarted" in item || "testCaseFinished" in item || "envelope" in item;
245
+ });
246
+ };
247
+ const resolveFeatures = input => {
248
+ if (Array.isArray(input)) {
249
+ return input;
250
+ }
251
+ if (input && Array.isArray(input.features)) {
252
+ return input.features;
253
+ }
254
+ return null;
255
+ };
256
+ const normalizeFeature = (feature, index) => {
257
+ const baseId = feature?.id ?? feature?.name ?? "feature";
258
+ const elements = normalizeElements(feature);
259
+ return {
260
+ ...feature,
261
+ id: `${index}_${baseId}`,
262
+ elements,
263
+ tags: Array.isArray(feature?.tags) ? feature.tags : []
264
+ };
265
+ };
266
+ const normalizeElements = feature => {
267
+ if (!feature) {
268
+ return [];
269
+ }
270
+ if (Array.isArray(feature.elements)) {
271
+ return feature.elements;
272
+ }
273
+ if (Array.isArray(feature.scenarios)) {
274
+ return feature.scenarios;
275
+ }
276
+ if (Array.isArray(feature.children)) {
277
+ return flattenChildren(feature.children);
278
+ }
279
+ return [];
280
+ };
281
+ const flattenChildren = children => {
282
+ const flattened = [];
283
+ for (const child of children) {
284
+ if (!child) {
285
+ continue;
286
+ }
287
+ if (child.scenario) {
288
+ flattened.push(child.scenario);
289
+ continue;
290
+ }
291
+ if (child.background) {
292
+ flattened.push(child.background);
293
+ continue;
294
+ }
295
+ if (child.rule && Array.isArray(child.rule.children)) {
296
+ flattened.push(...flattenChildren(child.rule.children));
297
+ continue;
298
+ }
299
+ if (Array.isArray(child.children)) {
300
+ flattened.push(...flattenChildren(child.children));
301
+ continue;
302
+ }
303
+ flattened.push(child);
304
+ }
305
+ return flattened;
306
+ };
307
+ const normalizeScenario = (featureId, scenario, index) => {
308
+ const baseId = scenario?.id ?? scenario?.name ?? "scenario";
309
+ const scenarioId = buildScenarioId(featureId, baseId, index);
310
+ return {
311
+ ...scenario,
312
+ id: scenarioId,
313
+ tags: Array.isArray(scenario?.tags) ? scenario.tags : []
314
+ };
315
+ };
316
+ const buildScenarioId = (featureId, scenarioId, index) => {
317
+ const parts = String(scenarioId).split(";");
318
+ const suffix = parts.length > 1 ? parts[1] : parts[0];
319
+ return `${featureId};${index}_${suffix}`;
320
+ };
321
+ const processFeature = (state, feature) => {
176
322
  const {
177
323
  description,
178
324
  elements,
@@ -180,42 +326,34 @@ let _processFeature = (state, f) => {
180
326
  keyword,
181
327
  line,
182
328
  name,
183
- tags: [...tags],
329
+ tags,
184
330
  uri
185
- } = f;
186
- const allTags = [...tags]; //figure out if it has failed stuff
187
-
331
+ } = feature;
332
+ const allTags = Array.isArray(tags) ? [...tags] : [];
188
333
  let numFailedScenarios = 0;
189
334
  let numSkippedScenarios = 0;
190
-
191
- if (elements && elements.length) {
192
- for (let el of elements) {
193
- //collect scenario tags
194
- if (el.tags && el.tags.length) {
195
- let temp = allTags.map(t => t.name);
196
- el.tags.forEach(tag => {
197
- if (temp.includes(tag.name) === false) {
198
- allTags.push(tag);
199
- }
200
- });
335
+ const elementList = Array.isArray(elements) ? elements : [];
336
+ for (const element of elementList) {
337
+ const elementTags = Array.isArray(element?.tags) ? element.tags : [];
338
+ const seen = allTags.map(tag => tag.name);
339
+ for (const tag of elementTags) {
340
+ if (tag?.name && !seen.includes(tag.name)) {
341
+ allTags.push(tag);
201
342
  }
202
-
203
- if (el.steps && el.steps.length) {
204
- for (let step of el.steps) {
205
- if (step.result && step.result.status === "failed") {
206
- numFailedScenarios++;
207
- break;
208
- }
209
-
210
- if (step.result && step.result.status === "skipped") {
211
- numSkippedScenarios++;
212
- break;
213
- }
214
- }
343
+ }
344
+ const steps = Array.isArray(element?.steps) ? element.steps : [];
345
+ for (const step of steps) {
346
+ const status = step?.result?.status;
347
+ if (status === "failed") {
348
+ numFailedScenarios += 1;
349
+ break;
350
+ }
351
+ if (status === "skipped") {
352
+ numSkippedScenarios += 1;
353
+ break;
215
354
  }
216
355
  }
217
356
  }
218
-
219
357
  state.features.list.push(id);
220
358
  state.features.featuresMap[id] = {
221
359
  id,
@@ -224,20 +362,19 @@ let _processFeature = (state, f) => {
224
362
  keyword,
225
363
  name,
226
364
  line,
227
- tags,
365
+ tags: Array.isArray(tags) ? tags : [],
228
366
  allTags,
229
367
  numFailedScenarios,
230
368
  numSkippedScenarios
231
369
  };
232
370
  };
233
-
234
- let _processScenario = (state, featureId, scenario) => {
371
+ const processScenario = (state, featureId, scenario) => {
235
372
  const {
236
373
  id,
237
374
  keyword,
238
375
  line,
239
376
  name,
240
- tags: [...tags],
377
+ tags,
241
378
  type,
242
379
  uri
243
380
  } = scenario;
@@ -251,13 +388,44 @@ let _processScenario = (state, featureId, scenario) => {
251
388
  name,
252
389
  passedSteps: 0,
253
390
  skippedSteps: 0,
254
- tags,
391
+ tags: Array.isArray(tags) ? tags : [],
255
392
  type,
256
393
  uri
257
394
  };
258
395
  };
259
-
260
- let _processStep = (state, scenarioId, st) => {
396
+ const processFeatureElements = (state, feature, {
397
+ attachmentsEncoding
398
+ }) => {
399
+ const elements = feature.elements;
400
+ if (!elements.length) {
401
+ return;
402
+ }
403
+ let scenarioIndex = 0;
404
+ for (const rawScenario of elements) {
405
+ if (!rawScenario) {
406
+ continue;
407
+ }
408
+ const scenario = normalizeScenario(feature.id, rawScenario, scenarioIndex);
409
+ scenarioIndex += 1;
410
+ processScenario(state, feature.id, scenario);
411
+ processScenarioSteps(state, scenario, {
412
+ attachmentsEncoding
413
+ });
414
+ }
415
+ };
416
+ const processScenarioSteps = (state, scenario, {
417
+ attachmentsEncoding
418
+ }) => {
419
+ const steps = Array.isArray(scenario.steps) ? scenario.steps : [];
420
+ for (const step of steps) {
421
+ processStep(state, scenario.id, step, {
422
+ attachmentsEncoding
423
+ });
424
+ }
425
+ };
426
+ const processStep = (state, scenarioId, step, {
427
+ attachmentsEncoding
428
+ }) => {
261
429
  const {
262
430
  arguments: args,
263
431
  embeddings,
@@ -265,18 +433,22 @@ let _processStep = (state, scenarioId, st) => {
265
433
  keyword,
266
434
  line,
267
435
  name,
268
- result: {
269
- duration,
270
- error_message,
271
- status
272
- }
273
- } = st;
274
- let location = "";
275
- if (st.match) location = st.match.location;
276
- let step = {
277
- args,
436
+ result
437
+ } = step ?? {};
438
+ const {
278
439
  duration,
279
- embeddings,
440
+ error_message,
441
+ status
442
+ } = result ?? {};
443
+ const durationValue = typeof duration === "string" ? Number(duration) : duration;
444
+ const location = step?.match?.location ?? "";
445
+ const normalizedEmbeddings = normalizeEmbeddings(embeddings, {
446
+ attachmentsEncoding
447
+ });
448
+ const stepData = {
449
+ args,
450
+ duration: durationValue,
451
+ embeddings: normalizedEmbeddings,
280
452
  error_message,
281
453
  keyword,
282
454
  line,
@@ -284,28 +456,177 @@ let _processStep = (state, scenarioId, st) => {
284
456
  name,
285
457
  status
286
458
  };
287
- if (!state.steps.stepsMap[scenarioId]) state.steps.stepsMap[scenarioId] = {
288
- steps: []
289
- };
290
- state.steps.stepsMap[scenarioId].steps.push(step);
291
-
292
- if (isNaN(duration) === false) {
293
- state.steps.totalDurationNanoSec = state.steps.totalDurationNanoSec + duration;
459
+ if (!state.steps.stepsMap[scenarioId]) {
460
+ state.steps.stepsMap[scenarioId] = {
461
+ steps: []
462
+ };
294
463
  }
295
-
296
- if (!hidden || embeddings && embeddings.length) {
464
+ state.steps.stepsMap[scenarioId].steps.push(stepData);
465
+ if (Number.isFinite(durationValue)) {
466
+ state.steps.totalDurationNanoSec += durationValue;
467
+ }
468
+ if (!hidden || normalizedEmbeddings && normalizedEmbeddings.length) {
297
469
  if (status === "passed") {
298
- state.scenarios.scenariosMap[scenarioId].passedSteps++;
470
+ state.scenarios.scenariosMap[scenarioId].passedSteps += 1;
299
471
  } else if (status === "skipped") {
300
- state.scenarios.scenariosMap[scenarioId].skippedSteps++;
472
+ state.scenarios.scenariosMap[scenarioId].skippedSteps += 1;
301
473
  }
302
474
  }
303
-
304
475
  if (status === "failed") {
305
- state.scenarios.scenariosMap[scenarioId].failedSteps++;
476
+ state.scenarios.scenariosMap[scenarioId].failedSteps += 1;
306
477
  }
307
478
  };
308
479
 
480
+ /**
481
+ * Purpose: Generate HTML reports from cucumber JSON output.
482
+ * Responsibilities:
483
+ * - Normalize cucumber JSON into store state.
484
+ * - Copy report assets and write report metadata.
485
+ * Inputs/Outputs: Reads a cucumber JSON file and writes a report folder.
486
+ * Invariants: Expects legacy cucumber JSON (features/elements/steps).
487
+ * See: /agents.md
488
+ */
489
+ const require = createRequire(import.meta.url);
490
+ ncp.limit = 16;
491
+ const modulePath = require.resolve("./package.json"); //trick to resolve path to the installed module
492
+
493
+ /**
494
+ options.filter - a RegExp instance, against which each file name is tested to determine whether to copy it or not, or a function taking single parameter: copied file name, returning true or false, determining whether to copy file or not.
495
+
496
+ options.transform - a function: function (read, write) { read.pipe(write) } used to apply streaming transforms while copying.
497
+
498
+ options.clobber - boolean=true. if set to false, ncp will not overwrite destination files that already exist.
499
+
500
+ options.dereference - boolean=false. If set to true, ncp will follow symbolic links. For example, a symlink in the source tree pointing to a regular file will become a regular file in the destination tree. Broken symlinks will result in errors.
501
+
502
+ options.stopOnErr - boolean=false. If set to true, ncp will behave like cp -r, and stop on the first error it encounters. By default, ncp continues copying, logging all errors and returning an array.
503
+
504
+ options.errs - stream. If options.stopOnErr is false, a stream can be provided, and errors will be written to this stream.
505
+ */
506
+
507
+ let cp = (source, destination, options) => {
508
+ return new Promise((resolve, reject) => {
509
+ ncp(source, destination, err => {
510
+ if (err) {
511
+ reject(new Error(err));
512
+ }
513
+ resolve();
514
+ });
515
+ });
516
+ };
517
+ const _makeSafe = input => {
518
+ input = input.replace(/&/g, '&amp;');
519
+ input = input.replace(/</g, '&lt;');
520
+ input = input.replace(/>/g, '&gt;');
521
+ return input;
522
+ };
523
+
524
+ /**
525
+ * Generate a report from cucumber JSON output.
526
+ * @param {string} source path to the cucumber results JSON file
527
+ * @param {string} dest folder path where the HTML report gets written
528
+ * @param {Object} options report configuration overrides
529
+ * @param {"legacy-json"|"auto"} [options.inputFormat] input JSON format selector
530
+ * @param {"auto"|"base64"|"raw"} [options.attachmentsEncoding] attachment encoding
531
+ * @param {string} [options.cucumberVersion] cucumber version (for encoding hints)
532
+ * @returns {Promise<void>} resolves when report assets are written
533
+ * @throws {Error} when input JSON is invalid or unsupported
534
+ * @example
535
+ * await generate("results/cucumber.json", "reports/out", { title: "Run #1" });
536
+ */
537
+ const generate = async (source, dest, options) => {
538
+ options ? true : options = {};
539
+ const CUCUMBER_JSON_PATH = "_cucumber-results.json";
540
+ const SETTINGS_JSON_PATH = "_reporter_settings.json";
541
+ const HTML_PATH = path.join(path.dirname(modulePath), "react");
542
+
543
+ // "linkTags": [{
544
+ // "pattern": "[a-zA-Z]*-(\\d)*$",
545
+ // "link": "https://bydeluxe.atlassian.net/browse/"
546
+
547
+ // }]
548
+ //defaults
549
+ const {
550
+ title = "Cucumber Report",
551
+ //report page title
552
+ description = "Cucumber report",
553
+ //description to be set at the page header
554
+ metadata = {},
555
+ linkTags = null,
556
+ inputFormat = "legacy-json",
557
+ attachmentsEncoding,
558
+ cucumberVersion
559
+ } = options;
560
+ let __dirname = path.resolve();
561
+ if (path.isAbsolute(source) === false) {
562
+ source = path.join(__dirname, source);
563
+ }
564
+ fs.accessSync(source);
565
+ if (!dest) {
566
+ dest = path.dirname(source);
567
+ } else {
568
+ if (path.isAbsolute(dest) === false) {
569
+ dest = path.resolve(dest);
570
+ }
571
+ }
572
+ console.log(`__dirname: ${__dirname}\n` + `html path: ${HTML_PATH}\n` + `source: ${source}\n` + `destination: ${dest}\n` + `title: ${title}\n` + `description: ${description}\n` + `metadata: ${ut.inspect(metadata, false, null)}\n` + `linkTags: ${ut.inspect(linkTags, false, null)}\n`);
573
+
574
+ //validate input json and make a copy
575
+ let str = fs.readFileSync(source, "utf8");
576
+ let obj = parseInputData(source, str);
577
+ let out = prepareStoreState(obj, {
578
+ inputFormat,
579
+ attachmentsEncoding,
580
+ cucumberVersion
581
+ });
582
+ let modifiedJSON = JSON.stringify(out);
583
+ let destExists = true;
584
+ try {
585
+ fs.accessSync(dest);
586
+ } catch (err) {
587
+ destExists = false;
588
+ }
589
+ if (!destExists) {
590
+ fs.mkdirSync(dest, {
591
+ recursive: true
592
+ });
593
+ }
594
+ fs.writeFileSync(path.join(dest, CUCUMBER_JSON_PATH), modifiedJSON);
595
+ fs.writeFileSync(path.join(dest, SETTINGS_JSON_PATH), JSON.stringify(options));
596
+ await cp(HTML_PATH, dest);
597
+ //swap out some tokens in the html
598
+ let indexPagePath = path.join(dest, "index.html");
599
+ let htmlStr = fs.readFileSync(indexPagePath, "utf8").toString();
600
+ let modified = htmlStr.replace(/-=title=-/g, _makeSafe(title));
601
+ fs.writeFileSync(indexPagePath, modified, "utf8");
602
+ console.log("done");
603
+ };
604
+ const parseInputData = (source, rawText) => {
605
+ try {
606
+ return JSON.parse(rawText);
607
+ } catch (err) {
608
+ const ndjson = parseNdjson(rawText);
609
+ if (ndjson) {
610
+ return ndjson;
611
+ }
612
+ throw new Error(`Invalid JSON in ${source}: ${err.message}`);
613
+ }
614
+ };
615
+ const parseNdjson = rawText => {
616
+ const lines = rawText.split(/\r?\n/).filter(line => line.trim().length);
617
+ if (!lines.length) {
618
+ return null;
619
+ }
620
+ const items = [];
621
+ for (const line of lines) {
622
+ try {
623
+ items.push(JSON.parse(line));
624
+ } catch (err) {
625
+ return null;
626
+ }
627
+ }
628
+ return items;
629
+ };
309
630
  var index = {
310
631
  generate: generate
311
632
  };