doc-detective 3.5.0-dev.0 → 3.5.0-dev.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.
Files changed (44) hide show
  1. package/.github/FUNDING.yml +14 -14
  2. package/.github/dependabot.yml +11 -11
  3. package/.github/workflows/auto-dev-release.yml +173 -173
  4. package/.github/workflows/npm-test.yaml +95 -96
  5. package/.github/workflows/update-core.yaml +131 -131
  6. package/CONTRIBUTIONS.md +27 -27
  7. package/LICENSE +661 -661
  8. package/README.md +110 -110
  9. package/dev/dev.config.json +3 -8
  10. package/dev/dev.spec.json +30 -30
  11. package/dev/index.js +5 -5
  12. package/package.json +47 -47
  13. package/reference.png +0 -0
  14. package/samples/.doc-detective.json +94 -94
  15. package/samples/doc-content-detect.md +10 -10
  16. package/samples/doc-content-inline-tests.md +23 -23
  17. package/samples/docker-hello.spec.json +15 -15
  18. package/samples/env +2 -2
  19. package/samples/http.spec.yaml +37 -37
  20. package/samples/kitten-search-detect.md +7 -7
  21. package/samples/kitten-search-inline.md +15 -15
  22. package/samples/kitten-search.spec.json +28 -28
  23. package/samples/local-gui.md +5 -5
  24. package/samples/tests.spec.json +70 -70
  25. package/samples/variables.env +4 -4
  26. package/scripts/bump-sync-version-core.js +108 -110
  27. package/src/checkDependencies.js +84 -84
  28. package/src/index.js +72 -72
  29. package/src/utils.js +1023 -1023
  30. package/test/artifacts/cleanup.spec.json +18 -18
  31. package/test/artifacts/config.json +6 -6
  32. package/test/artifacts/doc-content.md +23 -23
  33. package/test/artifacts/env +2 -2
  34. package/test/artifacts/httpRequest.spec.yaml +37 -37
  35. package/test/artifacts/runShell.spec.json +29 -29
  36. package/test/artifacts/setup.spec.json +18 -18
  37. package/test/artifacts/test.spec.json +46 -46
  38. package/test/resolvedTests.test.js +193 -193
  39. package/test/runTests.test.js +53 -53
  40. package/test/server/index.js +185 -185
  41. package/test/server/public/index.html +174 -174
  42. package/test/test-config.json +12 -12
  43. package/test/test-results.json +124 -124
  44. package/test/utils.test.js +634 -298
package/src/utils.js CHANGED
@@ -1,1023 +1,1023 @@
1
- const yargs = require("yargs/yargs");
2
- const { hideBin } = require("yargs/helpers");
3
- const { validate, resolvePaths, readFile } = require("doc-detective-common");
4
- const path = require("path");
5
- const fs = require("fs");
6
- const { spawn } = require("child_process");
7
- const os = require("os");
8
- const axios = require("axios");
9
-
10
- exports.setArgs = setArgs;
11
- exports.setConfig = setConfig;
12
- exports.outputResults = outputResults;
13
- exports.spawnCommand = spawnCommand;
14
- exports.setMeta = setMeta;
15
- exports.getVersionData = getVersionData;
16
- exports.log = log;
17
- exports.getResolvedTestsFromEnv = getResolvedTestsFromEnv;
18
- exports.reportResults = reportResults;
19
-
20
- // Log function that respects logLevel
21
- function log(message, level = "info", config = {}) {
22
- const logLevels = ["silent", "error", "warning", "info", "debug"];
23
- const currentLevel = config.logLevel || "info";
24
- const currentLevelIndex = logLevels.indexOf(currentLevel);
25
- const messageLevelIndex = logLevels.indexOf(level);
26
-
27
- // Only log if the message level is at or above the current log level
28
- if (currentLevelIndex >= messageLevelIndex && messageLevelIndex > 0) {
29
- if (level === "error") {
30
- console.error(message);
31
- } else {
32
- console.log(message);
33
- }
34
- }
35
- }
36
-
37
- // Define args
38
- function setArgs(args) {
39
- if (!args) return {};
40
- let argv = yargs(hideBin(args))
41
- .option("config", {
42
- alias: "c",
43
- description: "Path to a `config.json` or `config.yaml` file.",
44
- type: "string",
45
- })
46
- .option("input", {
47
- alias: "i",
48
- description:
49
- "Path to test specifications and documentation source files. May be paths to specific files or to directories to scan for files.",
50
- type: "string",
51
- })
52
- .option("output", {
53
- alias: "o",
54
- description:
55
- "Path of the directory in which to store the output of Doc Detective commands.",
56
- type: "string",
57
- })
58
- .option("logLevel", {
59
- alias: "l",
60
- description:
61
- "Detail level of logging events. Accepted values: silent, error, warning, info (default), debug",
62
- type: "string",
63
- })
64
- .option("allow-unsafe", {
65
- description: "Allow execution of potentially unsafe tests",
66
- type: "boolean",
67
- })
68
- .help()
69
- .alias("help", "h").argv;
70
-
71
- return argv;
72
- }
73
-
74
- // Get resolved tests from environment variable, if set
75
- async function getResolvedTestsFromEnv(config = {}) {
76
- if (!process.env.DOC_DETECTIVE_API) {
77
- return null;
78
- }
79
-
80
- let resolvedTests = null;
81
- let apiConfig = null;
82
- try {
83
- // Parse the environment variable as JSON
84
- apiConfig = JSON.parse(process.env.DOC_DETECTIVE_API);
85
-
86
- // Validate the structure: { accountId, url, token, contextIds }
87
- if (!apiConfig.accountId || !apiConfig.url || !apiConfig.token || !apiConfig.contextIds) {
88
- log(
89
- "Invalid DOC_DETECTIVE_API: must contain 'accountId', 'url', 'token', and 'contextIds' properties",
90
- "error",
91
- config
92
- );
93
- process.exit(1);
94
- }
95
-
96
- log(`CLI:Fetching resolved tests from ${apiConfig.url}/resolved-tests`, "debug", config);
97
-
98
- // Make GET request to the specified URL with token in header
99
- const response = await axios.get(`${apiConfig.url}/resolved-tests`, {
100
- headers: {
101
- "x-runner-token": apiConfig.token,
102
- },
103
- });
104
-
105
- // The response is the resolvedTests
106
- resolvedTests = response.data;
107
-
108
- // Validate against resolvedTests_v3 schema
109
- const validation = validate({
110
- schemaKey: "resolvedTests_v3",
111
- object: resolvedTests,
112
- });
113
-
114
- if (!validation.valid) {
115
- log(
116
- "Invalid resolvedTests from API response. " + validation.errors,
117
- "error",
118
- config
119
- );
120
- process.exit(1);
121
- }
122
-
123
- // Get config from environment variable for merging
124
- const envConfig = await getConfigFromEnv();
125
- if (envConfig) {
126
- // Apply config overrides to resolvedTests.config
127
- if (resolvedTests.config) {
128
- resolvedTests.config = { ...resolvedTests.config, ...envConfig };
129
- } else {
130
- resolvedTests.config = envConfig;
131
- }
132
- }
133
-
134
- log(
135
- `CLI:RESOLVED_TESTS:\n${JSON.stringify(resolvedTests, null, 2)}`,
136
- "debug",
137
- config
138
- );
139
- } catch (error) {
140
- log(
141
- `Error fetching resolved tests from DOC_DETECTIVE_API: ${error.message}`,
142
- "error",
143
- config
144
- );
145
- process.exit(1);
146
- }
147
- return { apiConfig, resolvedTests };
148
- }
149
-
150
- async function getConfigFromEnv() {
151
- if (!process.env.DOC_DETECTIVE_CONFIG) {
152
- return null;
153
- }
154
-
155
- let envConfig = null;
156
- try {
157
- // Parse the environment variable as JSON
158
- envConfig = JSON.parse(process.env.DOC_DETECTIVE_CONFIG);
159
-
160
- // Validate the environment variable config
161
- const envValidation = validate({
162
- schemaKey: "config_v3",
163
- object: envConfig,
164
- });
165
-
166
- if (!envValidation.valid) {
167
- console.error(
168
- "Invalid config from DOC_DETECTIVE_CONFIG environment variable.",
169
- envValidation.errors
170
- );
171
- process.exit(1);
172
- }
173
-
174
- log(`CLI:ENV_CONFIG:\n${JSON.stringify(envConfig, null, 2)}`, "debug", envConfig);
175
- } catch (error) {
176
- console.error(
177
- `Error parsing DOC_DETECTIVE_CONFIG environment variable: ${error.message}`
178
- );
179
- process.exit(1);
180
- }
181
- return envConfig;
182
- }
183
-
184
- // Override config values based on args and validate the config
185
- async function setConfig({ configPath, args }) {
186
- if (args.config && !configPath) {
187
- configPath = args.config;
188
- }
189
-
190
- // If config file exists, read it
191
- let config = {};
192
- if (configPath) {
193
- try {
194
- config = await readFile({ fileURLOrPath: configPath });
195
- } catch (error) {
196
- console.error(`Error reading config file at ${configPath}: ${error}`);
197
- return null;
198
- }
199
- }
200
-
201
- // Check for DOC_DETECTIVE_CONFIG environment variable
202
- const envConfig = await getConfigFromEnv();
203
- if (envConfig) {
204
- // Merge with file config, preferring environment variable config (use raw envConfig, not validated with defaults)
205
- config = { ...config, ...envConfig };
206
- }
207
-
208
- // Validate config
209
- const validation = validate({
210
- schemaKey: "config_v3",
211
- object: config,
212
- });
213
- if (!validation.valid) {
214
- // Output validation errors
215
- console.error("Invalid config.", validation.errors);
216
- process.exit(1);
217
- }
218
-
219
- // Accept coerced and defaulted values
220
- config = validation.object;
221
- // Set default values
222
- config = {
223
- ...config,
224
- input: config.input || ".",
225
- output: config.output || ".",
226
- recursive: config.recursive || true,
227
- relativePathBase: config.relativePathBase || "file",
228
- loadVariables: config.loadVariables || ".env",
229
- detectSteps: config.detectSteps || true,
230
- logLevel: config.logLevel || "info",
231
- fileTypes: config.fileTypes || ["markdown", "asciidoc", "html"],
232
- telemetry: config.telemetry || { send: true },
233
- };
234
- // Override config values
235
- if (configPath) {
236
- config.configPath = configPath;
237
- }
238
- if (args.input) {
239
- // If input includes commas, split it into an array
240
- args.input = args.input.split(",").map((item) => item.trim());
241
- // Resolve paths
242
- args.input = args.input.map((item) => {
243
- if (item.startsWith("https://") || item.startsWith("http://")) {
244
- return item; // Don't resolve URLs
245
- }
246
- return path.resolve(item);
247
- });
248
- // Add to config
249
- config.input = args.input;
250
- }
251
- if (args.output) {
252
- config.output = path.resolve(args.output);
253
- }
254
- if (args.logLevel) {
255
- config.logLevel = args.logLevel;
256
- }
257
- if (typeof args.allowUnsafe === "boolean") {
258
- config.allowUnsafeSteps = args.allowUnsafe;
259
- }
260
- // Resolve paths
261
- config = await resolvePaths({
262
- config: config,
263
- object: config,
264
- filePath: configPath || ".",
265
- nested: false,
266
- objectType: "config",
267
- });
268
-
269
- return config;
270
- }
271
-
272
- // Internal reporters
273
- const reporters = {
274
- // JSON reporter: outputs results to a JSON file
275
- jsonReporter: async (config = {}, outputPath, results, options = {}) => {
276
- // Define supported output extensions
277
- const outputExtensions = [".json"];
278
-
279
- // Normalize output path
280
- outputPath = path.resolve(outputPath);
281
-
282
- let data = JSON.stringify(results, null, 2);
283
- let outputFile = "";
284
- let outputDir = "";
285
- let reportType = "doc-detective-results";
286
- if (options.command) {
287
- if (options.command === "runCoverage") {
288
- reportType = "coverageResults";
289
- } else if (options.command === "runTests") {
290
- reportType = "testResults";
291
- }
292
- }
293
-
294
- // Detect if output ends with a supported extension
295
- if (outputExtensions.some((ext) => outputPath.endsWith(ext))) {
296
- outputDir = path.dirname(outputPath);
297
- outputFile = outputPath;
298
- // If outputFile already exists, add a counter to the filename
299
- if (fs.existsSync(outputFile)) {
300
- let counter = 0;
301
- while (fs.existsSync(outputFile.replace(".json", `-${counter}.json`))) {
302
- counter++;
303
- }
304
- outputFile = outputFile.replace(".json", `-${counter}.json`);
305
- }
306
- } else {
307
- outputDir = outputPath;
308
- outputFile = path.resolve(outputDir, `${reportType}-${Date.now()}.json`);
309
- }
310
-
311
- try {
312
- // Create output directory if it doesn't exist
313
- if (!fs.existsSync(outputDir)) {
314
- fs.mkdirSync(outputDir, { recursive: true });
315
- }
316
-
317
- // Write results to output file
318
- fs.writeFileSync(outputFile, data);
319
- console.log(`See detailed results at ${outputFile}\n`);
320
- return outputFile;
321
- } catch (err) {
322
- console.error(`Error writing results to ${outputFile}. ${err}`);
323
- return null;
324
- }
325
- },
326
-
327
- // Terminal reporter: outputs a summary to the terminal
328
- terminalReporter: async (config = {}, outputPath, results, options = {}) => {
329
- // Defines colors for terminal output
330
- const colors = {
331
- red: "\x1b[31m",
332
- green: "\x1b[32m",
333
- yellow: "\x1b[33m",
334
- cyan: "\x1b[36m",
335
- reset: "\x1b[0m",
336
- bold: "\x1b[1m",
337
- };
338
-
339
- // Check if we have the new results format with summary
340
- if (!results) {
341
- console.log("No results available.");
342
- return;
343
- }
344
-
345
- // Handle results that have a summary section
346
- if (results.summary) {
347
- // Extract summary data
348
- const { specs, tests, contexts, steps } = results.summary;
349
-
350
- // Calculate totals
351
- const totalSpecs = specs
352
- ? specs.pass + specs.fail + specs.warning + specs.skipped
353
- : 0;
354
- const totalTests = tests
355
- ? tests.pass + tests.fail + tests.warning + tests.skipped
356
- : 0;
357
- const totalContexts = contexts
358
- ? contexts.pass + contexts.fail + contexts.warning + contexts.skipped
359
- : 0;
360
- const totalSteps = steps
361
- ? steps.pass + steps.fail + steps.warning + steps.skipped
362
- : 0;
363
-
364
- // Any failures overall?
365
- const hasFailures =
366
- (specs && specs.fail > 0) ||
367
- (tests && tests.fail > 0) ||
368
- (contexts && contexts.fail > 0) ||
369
- (steps && steps.fail > 0);
370
-
371
- // Any skipped overall?
372
- const allSpecsSkipped =
373
- specs && specs.pass === 0 && specs.fail === 0 && specs.skipped > 0;
374
-
375
- console.log(
376
- `\n${colors.bold}===== Doc Detective Results Summary =====${colors.reset}`
377
- );
378
-
379
- // Print specs summary if available
380
- if (specs) {
381
- console.log(`\n${colors.bold}Specs:${colors.reset}`);
382
- console.log(`Total: ${totalSpecs}`);
383
- if (specs.pass > 0) {
384
- console.log(`${colors.green}Passed: ${specs.pass}${colors.reset}`);
385
- } else {
386
- console.log(`Passed: ${specs.pass}`);
387
- }
388
- console.log(
389
- `${specs.fail > 0 ? colors.red : colors.green}Failed: ${specs.fail}${
390
- colors.reset
391
- }`
392
- );
393
- if (specs.warning > 0)
394
- console.log(
395
- `${colors.yellow}Warnings: ${specs.warning}${colors.reset}`
396
- );
397
- if (specs.skipped > 0)
398
- console.log(
399
- `${colors.yellow}Skipped: ${specs.skipped}${colors.reset}`
400
- );
401
- }
402
-
403
- // Print tests summary if available
404
- if (tests) {
405
- console.log(`\n${colors.bold}Tests:${colors.reset}`);
406
- console.log(`Total: ${totalTests}`);
407
- if (tests.pass > 0) {
408
- console.log(`${colors.green}Passed: ${tests.pass}${colors.reset}`);
409
- } else {
410
- console.log(`Passed: ${tests.pass}`);
411
- }
412
- console.log(
413
- `${tests.fail > 0 ? colors.red : colors.green}Failed: ${tests.fail}${
414
- colors.reset
415
- }`
416
- );
417
- if (tests.warning > 0)
418
- console.log(
419
- `${colors.yellow}Warnings: ${tests.warning}${colors.reset}`
420
- );
421
- if (tests.skipped > 0)
422
- console.log(
423
- `${colors.yellow}Skipped: ${tests.skipped}${colors.reset}`
424
- );
425
- }
426
-
427
- // Print contexts summary if available
428
- if (contexts) {
429
- console.log(`\n${colors.bold}Contexts:${colors.reset}`);
430
- console.log(`Total: ${totalContexts}`);
431
- if (contexts.pass > 0) {
432
- console.log(`${colors.green}Passed: ${contexts.pass}${colors.reset}`);
433
- } else {
434
- console.log(`Passed: ${contexts.pass}`);
435
- }
436
- console.log(
437
- `${contexts.fail > 0 ? colors.red : colors.green}Failed: ${
438
- contexts.fail
439
- }${colors.reset}`
440
- );
441
- if (contexts.warning > 0)
442
- console.log(
443
- `${colors.yellow}Warnings: ${contexts.warning}${colors.reset}`
444
- );
445
- if (contexts.skipped > 0)
446
- console.log(
447
- `${colors.yellow}Skipped: ${contexts.skipped}${colors.reset}`
448
- );
449
- }
450
-
451
- // Print steps summary if available
452
- if (steps) {
453
- console.log(`\n${colors.bold}Steps:${colors.reset}`);
454
- console.log(`Total: ${totalSteps}`);
455
- if (steps.pass > 0) {
456
- console.log(`${colors.green}Passed: ${steps.pass}${colors.reset}`);
457
- } else {
458
- console.log(`Passed: ${steps.pass}`);
459
- }
460
- console.log(
461
- `${steps.fail > 0 ? colors.red : colors.green}Failed: ${steps.fail}${
462
- colors.reset
463
- }`
464
- );
465
- if (steps.warning > 0)
466
- console.log(
467
- `${colors.yellow}Warnings: ${steps.warning}${colors.reset}`
468
- );
469
- if (steps.skipped > 0)
470
- console.log(
471
- `${colors.yellow}Skipped: ${steps.skipped}${colors.reset}`
472
- );
473
- }
474
-
475
- // If all specs were skipped, call it out
476
- if (allSpecsSkipped) {
477
- console.log(
478
- `\n${colors.yellow}⚠️ All items were skipped. No specs passed or failed. ⚠️${colors.reset}`
479
- );
480
- }
481
-
482
- // If we have specs with failures, display them
483
- if (results.specs && hasFailures) {
484
- console.log(
485
- `\n${colors.bold}${colors.red}Failed Items:${colors.reset}`
486
- );
487
-
488
- // Collect failures
489
- const failedSpecs = [];
490
- const failedTests = [];
491
- const failedContexts = [];
492
- const failedSteps = [];
493
-
494
- // Collect skipped
495
- const skippedSpecs = [];
496
- const skippedTests = [];
497
- const skippedContexts = [];
498
- const skippedSteps = [];
499
-
500
- // Process specs array to collect failures and skipped
501
- results.specs.forEach((spec, specIndex) => {
502
- // Check if spec has failed
503
- if (spec.result === "FAIL") {
504
- failedSpecs.push({
505
- index: specIndex,
506
- id: spec.specId || `Spec ${specIndex + 1}`,
507
- });
508
- }
509
- // Check if spec was skipped
510
- if (spec.result === "SKIPPED") {
511
- skippedSpecs.push({
512
- index: specIndex,
513
- id: spec.specId || `Spec ${specIndex + 1}`,
514
- });
515
- }
516
-
517
- // Process tests in this spec
518
- if (spec.tests && spec.tests.length > 0) {
519
- spec.tests.forEach((test, testIndex) => {
520
- // Check if test has failed
521
- if (test.result === "FAIL") {
522
- failedTests.push({
523
- specIndex,
524
- testIndex,
525
- specId: spec.specId || `Spec ${specIndex + 1}`,
526
- id: test.testId || `Test ${testIndex + 1}`,
527
- });
528
- }
529
- // Check if test was skipped
530
- if (test.result === "SKIPPED") {
531
- skippedTests.push({
532
- specIndex,
533
- testIndex,
534
- specId: spec.specId || `Spec ${specIndex + 1}`,
535
- id: test.testId || `Test ${testIndex + 1}`,
536
- });
537
- }
538
-
539
- // Process contexts in this test
540
- if (test.contexts && test.contexts.length > 0) {
541
- test.contexts.forEach((context, contextIndex) => {
542
- // Check if context has failed
543
- if (
544
- context.result === "FAIL" ||
545
- (context.result && context.result.status === "FAIL")
546
- ) {
547
- failedContexts.push({
548
- specIndex,
549
- testIndex,
550
- contextIndex,
551
- specId: spec.specId || `Spec ${specIndex + 1}`,
552
- testId: test.testId || `Test ${testIndex + 1}`,
553
- platform: context.platform || "unknown",
554
- browser: context.browser
555
- ? context.browser.name
556
- : "unknown",
557
- });
558
- }
559
- // Check if context was skipped
560
- if (
561
- context.result === "SKIPPED" ||
562
- (context.result && context.result.status === "SKIPPED")
563
- ) {
564
- skippedContexts.push({
565
- specIndex,
566
- testIndex,
567
- contextIndex,
568
- specId: spec.specId || `Spec ${specIndex + 1}`,
569
- testId: test.testId || `Test ${testIndex + 1}`,
570
- platform: context.platform || "unknown",
571
- browser: context.browser
572
- ? context.browser.name
573
- : "unknown",
574
- });
575
- }
576
-
577
- // Process steps in this context
578
- if (context.steps && context.steps.length > 0) {
579
- context.steps.forEach((step, stepIndex) => {
580
- // Check if step has failed
581
- if (step.result === "FAIL") {
582
- failedSteps.push({
583
- specIndex,
584
- testIndex,
585
- contextIndex,
586
- stepIndex,
587
- specId: spec.specId || `Spec ${specIndex + 1}`,
588
- testId: test.testId || `Test ${testIndex + 1}`,
589
- platform: context.platform || "unknown",
590
- browser: context.browser
591
- ? context.browser.name
592
- : "unknown",
593
- stepId: step.stepId || `Step ${stepIndex + 1}`,
594
- error: step.resultDescription || "Unknown error",
595
- });
596
- }
597
- // Check if step was skipped
598
- if (step.result === "SKIPPED") {
599
- skippedSteps.push({
600
- specIndex,
601
- testIndex,
602
- contextIndex,
603
- stepIndex,
604
- specId: spec.specId || `Spec ${specIndex + 1}`,
605
- testId: test.testId || `Test ${testIndex + 1}`,
606
- platform: context.platform || "unknown",
607
- browser: context.browser
608
- ? context.browser.name
609
- : "unknown",
610
- stepId: step.stepId || `Step ${stepIndex + 1}`,
611
- });
612
- }
613
- });
614
- }
615
- });
616
- }
617
- });
618
- }
619
- });
620
-
621
- // Display failures
622
- if (failedSpecs.length > 0) {
623
- console.log(`\n${colors.red}Failed Specs:${colors.reset}`);
624
- failedSpecs.forEach((item, i) => {
625
- console.log(`${colors.red}${i + 1}. ${item.id}${colors.reset}`);
626
- });
627
- }
628
-
629
- if (failedTests.length > 0) {
630
- console.log(`\n${colors.red}Failed Tests:${colors.reset}`);
631
- failedTests.forEach((item, i) => {
632
- console.log(
633
- `${colors.red}${i + 1}. ${item.id} (from ${item.specId})${
634
- colors.reset
635
- }`
636
- );
637
- });
638
- }
639
-
640
- if (failedContexts.length > 0) {
641
- console.log(`\n${colors.red}Failed Contexts:${colors.reset}`);
642
- failedContexts.forEach((item, i) => {
643
- console.log(
644
- `${colors.red}${i + 1}. ${item.platform}/${item.browser} (from ${
645
- item.testId
646
- })${colors.reset}`
647
- );
648
- });
649
- }
650
-
651
- if (failedSteps.length > 0) {
652
- console.log(`\n${colors.red}Failed Steps:${colors.reset}`);
653
- failedSteps.forEach((item, i) => {
654
- console.log(
655
- `${colors.red}${i + 1}. ${item.platform}/${item.browser} - ${
656
- item.stepId
657
- }${colors.reset}`
658
- );
659
- console.log(` Error: ${item.error}`);
660
- });
661
- }
662
-
663
- // Display skipped items in yellow
664
- if (skippedSpecs.length > 0) {
665
- console.log(`\n${colors.yellow}Skipped Specs:${colors.reset}`);
666
- skippedSpecs.forEach((item, i) => {
667
- console.log(`${colors.yellow}${i + 1}. ${item.id}${colors.reset}`);
668
- });
669
- }
670
- if (skippedTests.length > 0) {
671
- console.log(`\n${colors.yellow}Skipped Tests:${colors.reset}`);
672
- skippedTests.forEach((item, i) => {
673
- console.log(
674
- `${colors.yellow}${i + 1}. ${item.id} (from ${item.specId})${
675
- colors.reset
676
- }`
677
- );
678
- });
679
- }
680
- if (skippedContexts.length > 0) {
681
- console.log(`\n${colors.yellow}Skipped Contexts:${colors.reset}`);
682
- skippedContexts.forEach((item, i) => {
683
- console.log(
684
- `${colors.yellow}${i + 1}. ${item.platform}/${
685
- item.browser
686
- } (from ${item.testId})${colors.reset}`
687
- );
688
- });
689
- }
690
- if (skippedSteps.length > 0) {
691
- console.log(`\n${colors.yellow}Skipped Steps:${colors.reset}`);
692
- skippedSteps.forEach((item, i) => {
693
- console.log(
694
- `${colors.yellow}${i + 1}. ${item.platform}/${item.browser} - ${
695
- item.stepId
696
- }${colors.reset}`
697
- );
698
- });
699
- }
700
- } else if (!hasFailures && !allSpecsSkipped) {
701
- // Celebration when all tests pass
702
- console.log(`\n${colors.green}🎉 All items passed! 🎉${colors.reset}`);
703
- }
704
- } else {
705
- console.log(
706
- "No tests were executed or results are in an unknown format."
707
- );
708
- }
709
-
710
- console.log("\n===============================\n");
711
- },
712
- };
713
-
714
- // Export reporters for external use
715
- exports.reporters = reporters;
716
-
717
- // Helper function to register custom reporters
718
- function registerReporter(name, reporterFunction) {
719
- if (typeof reporterFunction !== "function") {
720
- throw new Error("Reporter must be a function");
721
- }
722
- reporters[name] = reporterFunction;
723
- return true;
724
- }
725
-
726
- // Export the registerReporter function
727
- exports.registerReporter = registerReporter;
728
-
729
- async function reportResults({ apiConfig, results }) {
730
- // Transform results into the required format for the API
731
- // Extract contexts from the nested structure and format them
732
- const contexts = [];
733
-
734
- if (results.specs) {
735
- results.specs.forEach((spec) => {
736
- if (spec.tests) {
737
- spec.tests.forEach((test) => {
738
- if (test.contexts) {
739
- test.contexts.forEach((context) => {
740
- // Extract or generate contextId
741
- const contextId =
742
- context.contextId;
743
-
744
- // Convert result status to lowercase (PASS -> passed, FAIL -> failed, etc.)
745
- let status;
746
- if (context.result === "PASS") {
747
- status = "passed";
748
- } else if (context.result === "FAIL") {
749
- status = "failed";
750
- } else if (context.result === "WARNING") {
751
- status = "warning";
752
- } else if (context.result === "SKIPPED") {
753
- status = "skipped";
754
- }
755
- if (!status) {
756
- log(config, "error", `Unknown context result status for context ID ${contextId}`);
757
- return;
758
- }
759
-
760
- // Build the context payload with the entire context object embedded
761
- contexts.push({
762
- contextId: contextId,
763
- status: status,
764
- result: context,
765
- });
766
- });
767
- }
768
- });
769
- }
770
- });
771
- }
772
-
773
- // POST to the /contexts endpoint
774
- try {
775
- const url = `${apiConfig.url}/contexts`;
776
- const payload = { contexts };
777
-
778
- console.log(payload);
779
-
780
- const response = await axios.post(url, payload, {
781
- headers: {
782
- "x-runner-token": apiConfig.token,
783
- },
784
- });
785
- console.log("Results reported successfully:", response.data);
786
- } catch (error) {
787
- console.error(
788
- `Error reporting results to ${apiConfig.url}/contexts: ${error.message}`
789
- );
790
- }
791
- }
792
-
793
- async function outputResults(config = {}, outputPath, results, options = {}) {
794
- // Default to using both built-in reporters if none specified
795
- const defaultReporters = ["terminal", "json"];
796
-
797
- let activeReporters = options.reporters || defaultReporters;
798
-
799
- // If the reporters option is provided as strings, normalize them
800
- if (activeReporters.length > 0) {
801
- // Convert any shorthand names to full reporter names
802
- activeReporters = activeReporters.map((reporter) => {
803
- if (typeof reporter === "string") {
804
- // Convert shorthand names to actual reporter keys
805
- switch (reporter.toLowerCase()) {
806
- case "json":
807
- return "jsonReporter";
808
- case "terminal":
809
- return "terminalReporter";
810
- default:
811
- return reporter;
812
- }
813
- }
814
- return reporter;
815
- });
816
- }
817
-
818
- // Execute each reporter
819
- const reporterPromises = activeReporters.map((reporter) => {
820
- if (typeof reporter === "function") {
821
- // Direct function reference
822
- return reporter(config, outputPath, results, options);
823
- } else if (typeof reporter === "string" && reporters[reporter]) {
824
- // String reference to built-in or registered reporter
825
- return reporters[reporter](config, outputPath, results, options);
826
- } else if (typeof reporter === "string" && !reporters[reporter]) {
827
- console.error(
828
- `Reporter "${reporter}" not found. Available reporters: ${Object.keys(
829
- reporters
830
- ).join(", ")}`
831
- );
832
- return Promise.resolve();
833
- } else {
834
- console.error(`Invalid reporter: ${reporter}`);
835
- return Promise.resolve();
836
- }
837
- });
838
-
839
- // Wait for all reporters to complete
840
- return Promise.all(reporterPromises);
841
- }
842
-
843
- // Perform a native command in the current working directory.
844
- async function spawnCommand(cmd, args) {
845
- // Split command into command and arguments
846
- if (cmd.includes(" ")) {
847
- const cmdArray = cmd.split(" ");
848
- cmd = cmdArray[0];
849
- cmdArgs = cmdArray.slice(1);
850
- // Add arguments to args array
851
- if (args) {
852
- args = cmdArgs.concat(args);
853
- } else {
854
- args = cmdArgs;
855
- }
856
- }
857
-
858
- const runCommand = spawn(cmd, args, {
859
- env: process.env, // Explicitly pass environment variables
860
- });
861
-
862
- // Capture stdout
863
- let stdout = "";
864
- for await (const chunk of runCommand.stdout) {
865
- stdout += chunk;
866
- }
867
- // Remove trailing newline
868
- stdout = stdout.replace(/\n$/, "");
869
-
870
- // Capture stderr
871
- let stderr = "";
872
- for await (const chunk of runCommand.stderr) {
873
- stderr += chunk;
874
- }
875
- // Remove trailing newline
876
- stderr = stderr.replace(/\n$/, "");
877
-
878
- // Capture exit code
879
- const exitCode = await new Promise((resolve, reject) => {
880
- runCommand.on("close", resolve);
881
- });
882
-
883
- return { stdout, stderr, exitCode };
884
- }
885
-
886
- function setMeta() {
887
- const platformMap = {
888
- win32: "windows",
889
- darwin: "mac",
890
- linux: "linux",
891
- };
892
-
893
- // Set meta
894
- const meta =
895
- process.env["DOC_DETECTIVE_META"] !== undefined
896
- ? JSON.parse(process.env["DOC_DETECTIVE_META"])
897
- : {};
898
- const package = require("../package.json");
899
- meta.distribution = "doc-detective";
900
- meta.dist_version = package.version;
901
- meta.dist_platform = platformMap[os.platform()] || os.platform();
902
- meta.dist_platform_version = os.release();
903
- meta.dist_platform_arch = os.arch();
904
- meta.dist_deployment = meta.dist_deployment || "node";
905
- meta.dist_deployment_version =
906
- meta.dist_deployment_version || process.version;
907
- meta.dist_interface = meta.dist_interface || "cli";
908
- process.env["DOC_DETECTIVE_META"] = JSON.stringify(meta);
909
- }
910
-
911
- // Get version data programmatically (no console output)
912
- function getVersionData() {
913
- try {
914
- // Get main package version
915
- const mainPackage = require("../package.json");
916
- const versionData = {
917
- main: {
918
- "doc-detective": {
919
- version: mainPackage.version,
920
- expected: "main package",
921
- },
922
- },
923
- dependencies: {},
924
- context: {
925
- executionMethod: "direct node execution",
926
- nodeVersion: process.version,
927
- platform: `${os.platform()} ${os.arch()}`,
928
- timestamp: new Date().toISOString(),
929
- },
930
- locations: {},
931
- };
932
-
933
- // Auto-discover all doc-detective-* packages in node_modules
934
- const nodeModulesPath = path.resolve(process.cwd(), "node_modules");
935
- const dependenciesToCheck = [];
936
-
937
- if (fs.existsSync(nodeModulesPath)) {
938
- const nodeModulesContents = fs.readdirSync(nodeModulesPath);
939
- nodeModulesContents.forEach((dir) => {
940
- if (dir.startsWith("doc-detective-") && dir !== "doc-detective") {
941
- dependenciesToCheck.push(dir);
942
- }
943
- });
944
- }
945
-
946
- // Detect execution method
947
- const isNpx =
948
- process.env.npm_execpath && process.env.npm_execpath.includes("npx");
949
- const isNpm = process.env.npm_execpath && !isNpx;
950
-
951
- if (isNpx) {
952
- versionData.context.executionMethod = "npx";
953
- } else if (isNpm) {
954
- versionData.context.executionMethod = "npm";
955
- }
956
-
957
- // Check installed versions of dependencies
958
- dependenciesToCheck.sort().forEach((dep) => {
959
- try {
960
- // Try to read the dependency's package.json
961
- const depPackagePath = path.resolve(
962
- process.cwd(),
963
- "node_modules",
964
- dep,
965
- "package.json"
966
- );
967
- if (fs.existsSync(depPackagePath)) {
968
- const depPackage = JSON.parse(
969
- fs.readFileSync(depPackagePath, "utf8")
970
- );
971
- const installedVersion = depPackage.version;
972
-
973
- // Look for expected version in main package dependencies or devDependencies
974
- const expectedVersion =
975
- mainPackage.dependencies[dep] ||
976
- mainPackage.devDependencies[dep] ||
977
- "not specified in main package";
978
-
979
- versionData.dependencies[dep] = {
980
- installed: installedVersion,
981
- expected: expectedVersion,
982
- status:
983
- expectedVersion !== "not specified in main package" &&
984
- !expectedVersion.includes(installedVersion) &&
985
- !installedVersion.includes(expectedVersion.replace(/[\^~]/, ""))
986
- ? "mismatch"
987
- : "ok",
988
- };
989
-
990
- versionData.locations[dep] = path.resolve(
991
- process.cwd(),
992
- "node_modules",
993
- dep
994
- );
995
- } else {
996
- versionData.dependencies[dep] = {
997
- installed: null,
998
- expected:
999
- mainPackage.dependencies[dep] ||
1000
- mainPackage.devDependencies[dep] ||
1001
- "not specified",
1002
- status: "not found",
1003
- error: "package.json not found",
1004
- };
1005
- }
1006
- } catch (error) {
1007
- versionData.dependencies[dep] = {
1008
- installed: null,
1009
- expected:
1010
- mainPackage.dependencies[dep] ||
1011
- mainPackage.devDependencies[dep] ||
1012
- "not specified",
1013
- status: "error",
1014
- error: error.message,
1015
- };
1016
- }
1017
- });
1018
-
1019
- return versionData;
1020
- } catch (error) {
1021
- return { error: error.message };
1022
- }
1023
- }
1
+ const yargs = require("yargs/yargs");
2
+ const { hideBin } = require("yargs/helpers");
3
+ const { validate, resolvePaths, readFile } = require("doc-detective-common");
4
+ const path = require("path");
5
+ const fs = require("fs");
6
+ const { spawn } = require("child_process");
7
+ const os = require("os");
8
+ const axios = require("axios");
9
+
10
+ exports.setArgs = setArgs;
11
+ exports.setConfig = setConfig;
12
+ exports.outputResults = outputResults;
13
+ exports.spawnCommand = spawnCommand;
14
+ exports.setMeta = setMeta;
15
+ exports.getVersionData = getVersionData;
16
+ exports.log = log;
17
+ exports.getResolvedTestsFromEnv = getResolvedTestsFromEnv;
18
+ exports.reportResults = reportResults;
19
+
20
+ // Log function that respects logLevel
21
+ function log(message, level = "info", config = {}) {
22
+ const logLevels = ["silent", "error", "warning", "info", "debug"];
23
+ const currentLevel = config.logLevel || "info";
24
+ const currentLevelIndex = logLevels.indexOf(currentLevel);
25
+ const messageLevelIndex = logLevels.indexOf(level);
26
+
27
+ // Only log if the message level is at or above the current log level
28
+ if (currentLevelIndex >= messageLevelIndex && messageLevelIndex > 0) {
29
+ if (level === "error") {
30
+ console.error(message);
31
+ } else {
32
+ console.log(message);
33
+ }
34
+ }
35
+ }
36
+
37
+ // Define args
38
+ function setArgs(args) {
39
+ if (!args) return {};
40
+ let argv = yargs(hideBin(args))
41
+ .option("config", {
42
+ alias: "c",
43
+ description: "Path to a `config.json` or `config.yaml` file.",
44
+ type: "string",
45
+ })
46
+ .option("input", {
47
+ alias: "i",
48
+ description:
49
+ "Path to test specifications and documentation source files. May be paths to specific files or to directories to scan for files.",
50
+ type: "string",
51
+ })
52
+ .option("output", {
53
+ alias: "o",
54
+ description:
55
+ "Path of the directory in which to store the output of Doc Detective commands.",
56
+ type: "string",
57
+ })
58
+ .option("logLevel", {
59
+ alias: "l",
60
+ description:
61
+ "Detail level of logging events. Accepted values: silent, error, warning, info (default), debug",
62
+ type: "string",
63
+ })
64
+ .option("allow-unsafe", {
65
+ description: "Allow execution of potentially unsafe tests",
66
+ type: "boolean",
67
+ })
68
+ .help()
69
+ .alias("help", "h").argv;
70
+
71
+ return argv;
72
+ }
73
+
74
+ // Get resolved tests from environment variable, if set
75
+ async function getResolvedTestsFromEnv(config = {}) {
76
+ if (!process.env.DOC_DETECTIVE_API) {
77
+ return null;
78
+ }
79
+
80
+ let resolvedTests = null;
81
+ let apiConfig = null;
82
+ try {
83
+ // Parse the environment variable as JSON
84
+ apiConfig = JSON.parse(process.env.DOC_DETECTIVE_API);
85
+
86
+ // Validate the structure: { accountId, url, token, contextIds }
87
+ if (!apiConfig.accountId || !apiConfig.url || !apiConfig.token || !apiConfig.contextIds) {
88
+ log(
89
+ "Invalid DOC_DETECTIVE_API: must contain 'accountId', 'url', 'token', and 'contextIds' properties",
90
+ "error",
91
+ config
92
+ );
93
+ process.exit(1);
94
+ }
95
+
96
+ log(`CLI:Fetching resolved tests from ${apiConfig.url}/resolved-tests`, "debug", config);
97
+
98
+ // Make GET request to the specified URL with token in header
99
+ const response = await axios.get(`${apiConfig.url}/resolved-tests`, {
100
+ headers: {
101
+ "x-runner-token": apiConfig.token,
102
+ },
103
+ });
104
+
105
+ // The response is the resolvedTests
106
+ resolvedTests = response.data;
107
+
108
+ // Validate against resolvedTests_v3 schema
109
+ const validation = validate({
110
+ schemaKey: "resolvedTests_v3",
111
+ object: resolvedTests,
112
+ });
113
+
114
+ if (!validation.valid) {
115
+ log(
116
+ "Invalid resolvedTests from API response. " + validation.errors,
117
+ "error",
118
+ config
119
+ );
120
+ process.exit(1);
121
+ }
122
+
123
+ // Get config from environment variable for merging
124
+ const envConfig = await getConfigFromEnv();
125
+ if (envConfig) {
126
+ // Apply config overrides to resolvedTests.config
127
+ if (resolvedTests.config) {
128
+ resolvedTests.config = { ...resolvedTests.config, ...envConfig };
129
+ } else {
130
+ resolvedTests.config = envConfig;
131
+ }
132
+ }
133
+
134
+ log(
135
+ `CLI:RESOLVED_TESTS:\n${JSON.stringify(resolvedTests, null, 2)}`,
136
+ "debug",
137
+ config
138
+ );
139
+ } catch (error) {
140
+ log(
141
+ `Error fetching resolved tests from DOC_DETECTIVE_API: ${error.message}`,
142
+ "error",
143
+ config
144
+ );
145
+ process.exit(1);
146
+ }
147
+ return { apiConfig, resolvedTests };
148
+ }
149
+
150
+ async function getConfigFromEnv() {
151
+ if (!process.env.DOC_DETECTIVE_CONFIG) {
152
+ return null;
153
+ }
154
+
155
+ let envConfig = null;
156
+ try {
157
+ // Parse the environment variable as JSON
158
+ envConfig = JSON.parse(process.env.DOC_DETECTIVE_CONFIG);
159
+
160
+ // Validate the environment variable config
161
+ const envValidation = validate({
162
+ schemaKey: "config_v3",
163
+ object: envConfig,
164
+ });
165
+
166
+ if (!envValidation.valid) {
167
+ console.error(
168
+ "Invalid config from DOC_DETECTIVE_CONFIG environment variable.",
169
+ envValidation.errors
170
+ );
171
+ process.exit(1);
172
+ }
173
+
174
+ log(`CLI:ENV_CONFIG:\n${JSON.stringify(envConfig, null, 2)}`, "debug", envConfig);
175
+ } catch (error) {
176
+ console.error(
177
+ `Error parsing DOC_DETECTIVE_CONFIG environment variable: ${error.message}`
178
+ );
179
+ process.exit(1);
180
+ }
181
+ return envConfig;
182
+ }
183
+
184
+ // Override config values based on args and validate the config
185
+ async function setConfig({ configPath, args }) {
186
+ if (args.config && !configPath) {
187
+ configPath = args.config;
188
+ }
189
+
190
+ // If config file exists, read it
191
+ let config = {};
192
+ if (configPath) {
193
+ try {
194
+ config = await readFile({ fileURLOrPath: configPath });
195
+ } catch (error) {
196
+ console.error(`Error reading config file at ${configPath}: ${error}`);
197
+ return null;
198
+ }
199
+ }
200
+
201
+ // Check for DOC_DETECTIVE_CONFIG environment variable
202
+ const envConfig = await getConfigFromEnv();
203
+ if (envConfig) {
204
+ // Merge with file config, preferring environment variable config (use raw envConfig, not validated with defaults)
205
+ config = { ...config, ...envConfig };
206
+ }
207
+
208
+ // Validate config
209
+ const validation = validate({
210
+ schemaKey: "config_v3",
211
+ object: config,
212
+ });
213
+ if (!validation.valid) {
214
+ // Output validation errors
215
+ console.error("Invalid config.", validation.errors);
216
+ process.exit(1);
217
+ }
218
+
219
+ // Accept coerced and defaulted values
220
+ config = validation.object;
221
+ // Set default values
222
+ config = {
223
+ ...config,
224
+ input: config.input || ".",
225
+ output: config.output || ".",
226
+ recursive: config.recursive ?? true,
227
+ relativePathBase: config.relativePathBase || "file",
228
+ loadVariables: config.loadVariables || ".env",
229
+ detectSteps: config.detectSteps ?? true,
230
+ logLevel: config.logLevel || "info",
231
+ fileTypes: config.fileTypes || ["markdown", "asciidoc", "html"],
232
+ telemetry: config.telemetry || { send: true },
233
+ };
234
+ // Override config values
235
+ if (configPath) {
236
+ config.configPath = configPath;
237
+ }
238
+ if (args.input) {
239
+ // If input includes commas, split it into an array
240
+ args.input = args.input.split(",").map((item) => item.trim());
241
+ // Resolve paths
242
+ args.input = args.input.map((item) => {
243
+ if (item.startsWith("https://") || item.startsWith("http://")) {
244
+ return item; // Don't resolve URLs
245
+ }
246
+ return path.resolve(item);
247
+ });
248
+ // Add to config
249
+ config.input = args.input;
250
+ }
251
+ if (args.output) {
252
+ config.output = path.resolve(args.output);
253
+ }
254
+ if (args.logLevel) {
255
+ config.logLevel = args.logLevel;
256
+ }
257
+ if (typeof args.allowUnsafe === "boolean") {
258
+ config.allowUnsafeSteps = args.allowUnsafe;
259
+ }
260
+ // Resolve paths
261
+ config = await resolvePaths({
262
+ config: config,
263
+ object: config,
264
+ filePath: configPath || ".",
265
+ nested: false,
266
+ objectType: "config",
267
+ });
268
+
269
+ return config;
270
+ }
271
+
272
+ // Internal reporters
273
+ const reporters = {
274
+ // JSON reporter: outputs results to a JSON file
275
+ jsonReporter: async (config = {}, outputPath, results, options = {}) => {
276
+ // Define supported output extensions
277
+ const outputExtensions = [".json"];
278
+
279
+ // Normalize output path
280
+ outputPath = path.resolve(outputPath);
281
+
282
+ let data = JSON.stringify(results, null, 2);
283
+ let outputFile = "";
284
+ let outputDir = "";
285
+ let reportType = "doc-detective-results";
286
+ if (options.command) {
287
+ if (options.command === "runCoverage") {
288
+ reportType = "coverageResults";
289
+ } else if (options.command === "runTests") {
290
+ reportType = "testResults";
291
+ }
292
+ }
293
+
294
+ // Detect if output ends with a supported extension
295
+ if (outputExtensions.some((ext) => outputPath.endsWith(ext))) {
296
+ outputDir = path.dirname(outputPath);
297
+ outputFile = outputPath;
298
+ // If outputFile already exists, add a counter to the filename
299
+ if (fs.existsSync(outputFile)) {
300
+ let counter = 0;
301
+ while (fs.existsSync(outputFile.replace(".json", `-${counter}.json`))) {
302
+ counter++;
303
+ }
304
+ outputFile = outputFile.replace(".json", `-${counter}.json`);
305
+ }
306
+ } else {
307
+ outputDir = outputPath;
308
+ outputFile = path.resolve(outputDir, `${reportType}-${Date.now()}.json`);
309
+ }
310
+
311
+ try {
312
+ // Create output directory if it doesn't exist
313
+ if (!fs.existsSync(outputDir)) {
314
+ fs.mkdirSync(outputDir, { recursive: true });
315
+ }
316
+
317
+ // Write results to output file
318
+ fs.writeFileSync(outputFile, data);
319
+ console.log(`See detailed results at ${outputFile}\n`);
320
+ return outputFile;
321
+ } catch (err) {
322
+ console.error(`Error writing results to ${outputFile}. ${err}`);
323
+ return null;
324
+ }
325
+ },
326
+
327
+ // Terminal reporter: outputs a summary to the terminal
328
+ terminalReporter: async (config = {}, outputPath, results, options = {}) => {
329
+ // Defines colors for terminal output
330
+ const colors = {
331
+ red: "\x1b[31m",
332
+ green: "\x1b[32m",
333
+ yellow: "\x1b[33m",
334
+ cyan: "\x1b[36m",
335
+ reset: "\x1b[0m",
336
+ bold: "\x1b[1m",
337
+ };
338
+
339
+ // Check if we have the new results format with summary
340
+ if (!results) {
341
+ console.log("No results available.");
342
+ return;
343
+ }
344
+
345
+ // Handle results that have a summary section
346
+ if (results.summary) {
347
+ // Extract summary data
348
+ const { specs, tests, contexts, steps } = results.summary;
349
+
350
+ // Calculate totals
351
+ const totalSpecs = specs
352
+ ? specs.pass + specs.fail + specs.warning + specs.skipped
353
+ : 0;
354
+ const totalTests = tests
355
+ ? tests.pass + tests.fail + tests.warning + tests.skipped
356
+ : 0;
357
+ const totalContexts = contexts
358
+ ? contexts.pass + contexts.fail + contexts.warning + contexts.skipped
359
+ : 0;
360
+ const totalSteps = steps
361
+ ? steps.pass + steps.fail + steps.warning + steps.skipped
362
+ : 0;
363
+
364
+ // Any failures overall?
365
+ const hasFailures =
366
+ (specs && specs.fail > 0) ||
367
+ (tests && tests.fail > 0) ||
368
+ (contexts && contexts.fail > 0) ||
369
+ (steps && steps.fail > 0);
370
+
371
+ // Any skipped overall?
372
+ const allSpecsSkipped =
373
+ specs && specs.pass === 0 && specs.fail === 0 && specs.skipped > 0;
374
+
375
+ console.log(
376
+ `\n${colors.bold}===== Doc Detective Results Summary =====${colors.reset}`
377
+ );
378
+
379
+ // Print specs summary if available
380
+ if (specs) {
381
+ console.log(`\n${colors.bold}Specs:${colors.reset}`);
382
+ console.log(`Total: ${totalSpecs}`);
383
+ if (specs.pass > 0) {
384
+ console.log(`${colors.green}Passed: ${specs.pass}${colors.reset}`);
385
+ } else {
386
+ console.log(`Passed: ${specs.pass}`);
387
+ }
388
+ console.log(
389
+ `${specs.fail > 0 ? colors.red : colors.green}Failed: ${specs.fail}${
390
+ colors.reset
391
+ }`
392
+ );
393
+ if (specs.warning > 0)
394
+ console.log(
395
+ `${colors.yellow}Warnings: ${specs.warning}${colors.reset}`
396
+ );
397
+ if (specs.skipped > 0)
398
+ console.log(
399
+ `${colors.yellow}Skipped: ${specs.skipped}${colors.reset}`
400
+ );
401
+ }
402
+
403
+ // Print tests summary if available
404
+ if (tests) {
405
+ console.log(`\n${colors.bold}Tests:${colors.reset}`);
406
+ console.log(`Total: ${totalTests}`);
407
+ if (tests.pass > 0) {
408
+ console.log(`${colors.green}Passed: ${tests.pass}${colors.reset}`);
409
+ } else {
410
+ console.log(`Passed: ${tests.pass}`);
411
+ }
412
+ console.log(
413
+ `${tests.fail > 0 ? colors.red : colors.green}Failed: ${tests.fail}${
414
+ colors.reset
415
+ }`
416
+ );
417
+ if (tests.warning > 0)
418
+ console.log(
419
+ `${colors.yellow}Warnings: ${tests.warning}${colors.reset}`
420
+ );
421
+ if (tests.skipped > 0)
422
+ console.log(
423
+ `${colors.yellow}Skipped: ${tests.skipped}${colors.reset}`
424
+ );
425
+ }
426
+
427
+ // Print contexts summary if available
428
+ if (contexts) {
429
+ console.log(`\n${colors.bold}Contexts:${colors.reset}`);
430
+ console.log(`Total: ${totalContexts}`);
431
+ if (contexts.pass > 0) {
432
+ console.log(`${colors.green}Passed: ${contexts.pass}${colors.reset}`);
433
+ } else {
434
+ console.log(`Passed: ${contexts.pass}`);
435
+ }
436
+ console.log(
437
+ `${contexts.fail > 0 ? colors.red : colors.green}Failed: ${
438
+ contexts.fail
439
+ }${colors.reset}`
440
+ );
441
+ if (contexts.warning > 0)
442
+ console.log(
443
+ `${colors.yellow}Warnings: ${contexts.warning}${colors.reset}`
444
+ );
445
+ if (contexts.skipped > 0)
446
+ console.log(
447
+ `${colors.yellow}Skipped: ${contexts.skipped}${colors.reset}`
448
+ );
449
+ }
450
+
451
+ // Print steps summary if available
452
+ if (steps) {
453
+ console.log(`\n${colors.bold}Steps:${colors.reset}`);
454
+ console.log(`Total: ${totalSteps}`);
455
+ if (steps.pass > 0) {
456
+ console.log(`${colors.green}Passed: ${steps.pass}${colors.reset}`);
457
+ } else {
458
+ console.log(`Passed: ${steps.pass}`);
459
+ }
460
+ console.log(
461
+ `${steps.fail > 0 ? colors.red : colors.green}Failed: ${steps.fail}${
462
+ colors.reset
463
+ }`
464
+ );
465
+ if (steps.warning > 0)
466
+ console.log(
467
+ `${colors.yellow}Warnings: ${steps.warning}${colors.reset}`
468
+ );
469
+ if (steps.skipped > 0)
470
+ console.log(
471
+ `${colors.yellow}Skipped: ${steps.skipped}${colors.reset}`
472
+ );
473
+ }
474
+
475
+ // If all specs were skipped, call it out
476
+ if (allSpecsSkipped) {
477
+ console.log(
478
+ `\n${colors.yellow}⚠️ All items were skipped. No specs passed or failed. ⚠️${colors.reset}`
479
+ );
480
+ }
481
+
482
+ // If we have specs with failures, display them
483
+ if (results.specs && hasFailures) {
484
+ console.log(
485
+ `\n${colors.bold}${colors.red}Failed Items:${colors.reset}`
486
+ );
487
+
488
+ // Collect failures
489
+ const failedSpecs = [];
490
+ const failedTests = [];
491
+ const failedContexts = [];
492
+ const failedSteps = [];
493
+
494
+ // Collect skipped
495
+ const skippedSpecs = [];
496
+ const skippedTests = [];
497
+ const skippedContexts = [];
498
+ const skippedSteps = [];
499
+
500
+ // Process specs array to collect failures and skipped
501
+ results.specs.forEach((spec, specIndex) => {
502
+ // Check if spec has failed
503
+ if (spec.result === "FAIL") {
504
+ failedSpecs.push({
505
+ index: specIndex,
506
+ id: spec.specId || `Spec ${specIndex + 1}`,
507
+ });
508
+ }
509
+ // Check if spec was skipped
510
+ if (spec.result === "SKIPPED") {
511
+ skippedSpecs.push({
512
+ index: specIndex,
513
+ id: spec.specId || `Spec ${specIndex + 1}`,
514
+ });
515
+ }
516
+
517
+ // Process tests in this spec
518
+ if (spec.tests && spec.tests.length > 0) {
519
+ spec.tests.forEach((test, testIndex) => {
520
+ // Check if test has failed
521
+ if (test.result === "FAIL") {
522
+ failedTests.push({
523
+ specIndex,
524
+ testIndex,
525
+ specId: spec.specId || `Spec ${specIndex + 1}`,
526
+ id: test.testId || `Test ${testIndex + 1}`,
527
+ });
528
+ }
529
+ // Check if test was skipped
530
+ if (test.result === "SKIPPED") {
531
+ skippedTests.push({
532
+ specIndex,
533
+ testIndex,
534
+ specId: spec.specId || `Spec ${specIndex + 1}`,
535
+ id: test.testId || `Test ${testIndex + 1}`,
536
+ });
537
+ }
538
+
539
+ // Process contexts in this test
540
+ if (test.contexts && test.contexts.length > 0) {
541
+ test.contexts.forEach((context, contextIndex) => {
542
+ // Check if context has failed
543
+ if (
544
+ context.result === "FAIL" ||
545
+ (context.result && context.result.status === "FAIL")
546
+ ) {
547
+ failedContexts.push({
548
+ specIndex,
549
+ testIndex,
550
+ contextIndex,
551
+ specId: spec.specId || `Spec ${specIndex + 1}`,
552
+ testId: test.testId || `Test ${testIndex + 1}`,
553
+ platform: context.platform || "unknown",
554
+ browser: context.browser
555
+ ? context.browser.name
556
+ : "unknown",
557
+ });
558
+ }
559
+ // Check if context was skipped
560
+ if (
561
+ context.result === "SKIPPED" ||
562
+ (context.result && context.result.status === "SKIPPED")
563
+ ) {
564
+ skippedContexts.push({
565
+ specIndex,
566
+ testIndex,
567
+ contextIndex,
568
+ specId: spec.specId || `Spec ${specIndex + 1}`,
569
+ testId: test.testId || `Test ${testIndex + 1}`,
570
+ platform: context.platform || "unknown",
571
+ browser: context.browser
572
+ ? context.browser.name
573
+ : "unknown",
574
+ });
575
+ }
576
+
577
+ // Process steps in this context
578
+ if (context.steps && context.steps.length > 0) {
579
+ context.steps.forEach((step, stepIndex) => {
580
+ // Check if step has failed
581
+ if (step.result === "FAIL") {
582
+ failedSteps.push({
583
+ specIndex,
584
+ testIndex,
585
+ contextIndex,
586
+ stepIndex,
587
+ specId: spec.specId || `Spec ${specIndex + 1}`,
588
+ testId: test.testId || `Test ${testIndex + 1}`,
589
+ platform: context.platform || "unknown",
590
+ browser: context.browser
591
+ ? context.browser.name
592
+ : "unknown",
593
+ stepId: step.stepId || `Step ${stepIndex + 1}`,
594
+ error: step.resultDescription || "Unknown error",
595
+ });
596
+ }
597
+ // Check if step was skipped
598
+ if (step.result === "SKIPPED") {
599
+ skippedSteps.push({
600
+ specIndex,
601
+ testIndex,
602
+ contextIndex,
603
+ stepIndex,
604
+ specId: spec.specId || `Spec ${specIndex + 1}`,
605
+ testId: test.testId || `Test ${testIndex + 1}`,
606
+ platform: context.platform || "unknown",
607
+ browser: context.browser
608
+ ? context.browser.name
609
+ : "unknown",
610
+ stepId: step.stepId || `Step ${stepIndex + 1}`,
611
+ });
612
+ }
613
+ });
614
+ }
615
+ });
616
+ }
617
+ });
618
+ }
619
+ });
620
+
621
+ // Display failures
622
+ if (failedSpecs.length > 0) {
623
+ console.log(`\n${colors.red}Failed Specs:${colors.reset}`);
624
+ failedSpecs.forEach((item, i) => {
625
+ console.log(`${colors.red}${i + 1}. ${item.id}${colors.reset}`);
626
+ });
627
+ }
628
+
629
+ if (failedTests.length > 0) {
630
+ console.log(`\n${colors.red}Failed Tests:${colors.reset}`);
631
+ failedTests.forEach((item, i) => {
632
+ console.log(
633
+ `${colors.red}${i + 1}. ${item.id} (from ${item.specId})${
634
+ colors.reset
635
+ }`
636
+ );
637
+ });
638
+ }
639
+
640
+ if (failedContexts.length > 0) {
641
+ console.log(`\n${colors.red}Failed Contexts:${colors.reset}`);
642
+ failedContexts.forEach((item, i) => {
643
+ console.log(
644
+ `${colors.red}${i + 1}. ${item.platform}/${item.browser} (from ${
645
+ item.testId
646
+ })${colors.reset}`
647
+ );
648
+ });
649
+ }
650
+
651
+ if (failedSteps.length > 0) {
652
+ console.log(`\n${colors.red}Failed Steps:${colors.reset}`);
653
+ failedSteps.forEach((item, i) => {
654
+ console.log(
655
+ `${colors.red}${i + 1}. ${item.platform}/${item.browser} - ${
656
+ item.stepId
657
+ }${colors.reset}`
658
+ );
659
+ console.log(` Error: ${item.error}`);
660
+ });
661
+ }
662
+
663
+ // Display skipped items in yellow
664
+ if (skippedSpecs.length > 0) {
665
+ console.log(`\n${colors.yellow}Skipped Specs:${colors.reset}`);
666
+ skippedSpecs.forEach((item, i) => {
667
+ console.log(`${colors.yellow}${i + 1}. ${item.id}${colors.reset}`);
668
+ });
669
+ }
670
+ if (skippedTests.length > 0) {
671
+ console.log(`\n${colors.yellow}Skipped Tests:${colors.reset}`);
672
+ skippedTests.forEach((item, i) => {
673
+ console.log(
674
+ `${colors.yellow}${i + 1}. ${item.id} (from ${item.specId})${
675
+ colors.reset
676
+ }`
677
+ );
678
+ });
679
+ }
680
+ if (skippedContexts.length > 0) {
681
+ console.log(`\n${colors.yellow}Skipped Contexts:${colors.reset}`);
682
+ skippedContexts.forEach((item, i) => {
683
+ console.log(
684
+ `${colors.yellow}${i + 1}. ${item.platform}/${
685
+ item.browser
686
+ } (from ${item.testId})${colors.reset}`
687
+ );
688
+ });
689
+ }
690
+ if (skippedSteps.length > 0) {
691
+ console.log(`\n${colors.yellow}Skipped Steps:${colors.reset}`);
692
+ skippedSteps.forEach((item, i) => {
693
+ console.log(
694
+ `${colors.yellow}${i + 1}. ${item.platform}/${item.browser} - ${
695
+ item.stepId
696
+ }${colors.reset}`
697
+ );
698
+ });
699
+ }
700
+ } else if (!hasFailures && !allSpecsSkipped) {
701
+ // Celebration when all tests pass
702
+ console.log(`\n${colors.green}🎉 All items passed! 🎉${colors.reset}`);
703
+ }
704
+ } else {
705
+ console.log(
706
+ "No tests were executed or results are in an unknown format."
707
+ );
708
+ }
709
+
710
+ console.log("\n===============================\n");
711
+ },
712
+ };
713
+
714
+ // Export reporters for external use
715
+ exports.reporters = reporters;
716
+
717
+ // Helper function to register custom reporters
718
+ function registerReporter(name, reporterFunction) {
719
+ if (typeof reporterFunction !== "function") {
720
+ throw new Error("Reporter must be a function");
721
+ }
722
+ reporters[name] = reporterFunction;
723
+ return true;
724
+ }
725
+
726
+ // Export the registerReporter function
727
+ exports.registerReporter = registerReporter;
728
+
729
+ async function reportResults({ apiConfig, results }) {
730
+ // Transform results into the required format for the API
731
+ // Extract contexts from the nested structure and format them
732
+ const contexts = [];
733
+
734
+ if (results.specs) {
735
+ results.specs.forEach((spec) => {
736
+ if (spec.tests) {
737
+ spec.tests.forEach((test) => {
738
+ if (test.contexts) {
739
+ test.contexts.forEach((context) => {
740
+ // Extract or generate contextId
741
+ const contextId =
742
+ context.contextId;
743
+
744
+ // Convert result status to lowercase (PASS -> passed, FAIL -> failed, etc.)
745
+ let status;
746
+ if (context.result === "PASS") {
747
+ status = "passed";
748
+ } else if (context.result === "FAIL") {
749
+ status = "failed";
750
+ } else if (context.result === "WARNING") {
751
+ status = "warning";
752
+ } else if (context.result === "SKIPPED") {
753
+ status = "skipped";
754
+ }
755
+ if (!status) {
756
+ log(config, "error", `Unknown context result status for context ID ${contextId}`);
757
+ return;
758
+ }
759
+
760
+ // Build the context payload with the entire context object embedded
761
+ contexts.push({
762
+ contextId: contextId,
763
+ status: status,
764
+ result: context,
765
+ });
766
+ });
767
+ }
768
+ });
769
+ }
770
+ });
771
+ }
772
+
773
+ // POST to the /contexts endpoint
774
+ try {
775
+ const url = `${apiConfig.url}/contexts`;
776
+ const payload = { contexts };
777
+
778
+ console.log(payload);
779
+
780
+ const response = await axios.post(url, payload, {
781
+ headers: {
782
+ "x-runner-token": apiConfig.token,
783
+ },
784
+ });
785
+ console.log("Results reported successfully:", response.data);
786
+ } catch (error) {
787
+ console.error(
788
+ `Error reporting results to ${apiConfig.url}/contexts: ${error.message}`
789
+ );
790
+ }
791
+ }
792
+
793
+ async function outputResults(config = {}, outputPath, results, options = {}) {
794
+ // Default to using both built-in reporters if none specified
795
+ const defaultReporters = ["terminal", "json"];
796
+
797
+ let activeReporters = options.reporters || defaultReporters;
798
+
799
+ // If the reporters option is provided as strings, normalize them
800
+ if (activeReporters.length > 0) {
801
+ // Convert any shorthand names to full reporter names
802
+ activeReporters = activeReporters.map((reporter) => {
803
+ if (typeof reporter === "string") {
804
+ // Convert shorthand names to actual reporter keys
805
+ switch (reporter.toLowerCase()) {
806
+ case "json":
807
+ return "jsonReporter";
808
+ case "terminal":
809
+ return "terminalReporter";
810
+ default:
811
+ return reporter;
812
+ }
813
+ }
814
+ return reporter;
815
+ });
816
+ }
817
+
818
+ // Execute each reporter
819
+ const reporterPromises = activeReporters.map((reporter) => {
820
+ if (typeof reporter === "function") {
821
+ // Direct function reference
822
+ return reporter(config, outputPath, results, options);
823
+ } else if (typeof reporter === "string" && reporters[reporter]) {
824
+ // String reference to built-in or registered reporter
825
+ return reporters[reporter](config, outputPath, results, options);
826
+ } else if (typeof reporter === "string" && !reporters[reporter]) {
827
+ console.error(
828
+ `Reporter "${reporter}" not found. Available reporters: ${Object.keys(
829
+ reporters
830
+ ).join(", ")}`
831
+ );
832
+ return Promise.resolve();
833
+ } else {
834
+ console.error(`Invalid reporter: ${reporter}`);
835
+ return Promise.resolve();
836
+ }
837
+ });
838
+
839
+ // Wait for all reporters to complete
840
+ return Promise.all(reporterPromises);
841
+ }
842
+
843
+ // Perform a native command in the current working directory.
844
+ async function spawnCommand(cmd, args) {
845
+ // Split command into command and arguments
846
+ if (cmd.includes(" ")) {
847
+ const cmdArray = cmd.split(" ");
848
+ cmd = cmdArray[0];
849
+ cmdArgs = cmdArray.slice(1);
850
+ // Add arguments to args array
851
+ if (args) {
852
+ args = cmdArgs.concat(args);
853
+ } else {
854
+ args = cmdArgs;
855
+ }
856
+ }
857
+
858
+ const runCommand = spawn(cmd, args, {
859
+ env: process.env, // Explicitly pass environment variables
860
+ });
861
+
862
+ // Capture stdout
863
+ let stdout = "";
864
+ for await (const chunk of runCommand.stdout) {
865
+ stdout += chunk;
866
+ }
867
+ // Remove trailing newline
868
+ stdout = stdout.replace(/\n$/, "");
869
+
870
+ // Capture stderr
871
+ let stderr = "";
872
+ for await (const chunk of runCommand.stderr) {
873
+ stderr += chunk;
874
+ }
875
+ // Remove trailing newline
876
+ stderr = stderr.replace(/\n$/, "");
877
+
878
+ // Capture exit code
879
+ const exitCode = await new Promise((resolve, reject) => {
880
+ runCommand.on("close", resolve);
881
+ });
882
+
883
+ return { stdout, stderr, exitCode };
884
+ }
885
+
886
+ function setMeta() {
887
+ const platformMap = {
888
+ win32: "windows",
889
+ darwin: "mac",
890
+ linux: "linux",
891
+ };
892
+
893
+ // Set meta
894
+ const meta =
895
+ process.env["DOC_DETECTIVE_META"] !== undefined
896
+ ? JSON.parse(process.env["DOC_DETECTIVE_META"])
897
+ : {};
898
+ const package = require("../package.json");
899
+ meta.distribution = "doc-detective";
900
+ meta.dist_version = package.version;
901
+ meta.dist_platform = platformMap[os.platform()] || os.platform();
902
+ meta.dist_platform_version = os.release();
903
+ meta.dist_platform_arch = os.arch();
904
+ meta.dist_deployment = meta.dist_deployment || "node";
905
+ meta.dist_deployment_version =
906
+ meta.dist_deployment_version || process.version;
907
+ meta.dist_interface = meta.dist_interface || "cli";
908
+ process.env["DOC_DETECTIVE_META"] = JSON.stringify(meta);
909
+ }
910
+
911
+ // Get version data programmatically (no console output)
912
+ function getVersionData() {
913
+ try {
914
+ // Get main package version
915
+ const mainPackage = require("../package.json");
916
+ const versionData = {
917
+ main: {
918
+ "doc-detective": {
919
+ version: mainPackage.version,
920
+ expected: "main package",
921
+ },
922
+ },
923
+ dependencies: {},
924
+ context: {
925
+ executionMethod: "direct node execution",
926
+ nodeVersion: process.version,
927
+ platform: `${os.platform()} ${os.arch()}`,
928
+ timestamp: new Date().toISOString(),
929
+ },
930
+ locations: {},
931
+ };
932
+
933
+ // Auto-discover all doc-detective-* packages in node_modules
934
+ const nodeModulesPath = path.resolve(process.cwd(), "node_modules");
935
+ const dependenciesToCheck = [];
936
+
937
+ if (fs.existsSync(nodeModulesPath)) {
938
+ const nodeModulesContents = fs.readdirSync(nodeModulesPath);
939
+ nodeModulesContents.forEach((dir) => {
940
+ if (dir.startsWith("doc-detective-") && dir !== "doc-detective") {
941
+ dependenciesToCheck.push(dir);
942
+ }
943
+ });
944
+ }
945
+
946
+ // Detect execution method
947
+ const isNpx =
948
+ process.env.npm_execpath && process.env.npm_execpath.includes("npx");
949
+ const isNpm = process.env.npm_execpath && !isNpx;
950
+
951
+ if (isNpx) {
952
+ versionData.context.executionMethod = "npx";
953
+ } else if (isNpm) {
954
+ versionData.context.executionMethod = "npm";
955
+ }
956
+
957
+ // Check installed versions of dependencies
958
+ dependenciesToCheck.sort().forEach((dep) => {
959
+ try {
960
+ // Try to read the dependency's package.json
961
+ const depPackagePath = path.resolve(
962
+ process.cwd(),
963
+ "node_modules",
964
+ dep,
965
+ "package.json"
966
+ );
967
+ if (fs.existsSync(depPackagePath)) {
968
+ const depPackage = JSON.parse(
969
+ fs.readFileSync(depPackagePath, "utf8")
970
+ );
971
+ const installedVersion = depPackage.version;
972
+
973
+ // Look for expected version in main package dependencies or devDependencies
974
+ const expectedVersion =
975
+ mainPackage.dependencies[dep] ||
976
+ mainPackage.devDependencies[dep] ||
977
+ "not specified in main package";
978
+
979
+ versionData.dependencies[dep] = {
980
+ installed: installedVersion,
981
+ expected: expectedVersion,
982
+ status:
983
+ expectedVersion !== "not specified in main package" &&
984
+ !expectedVersion.includes(installedVersion) &&
985
+ !installedVersion.includes(expectedVersion.replace(/[\^~]/, ""))
986
+ ? "mismatch"
987
+ : "ok",
988
+ };
989
+
990
+ versionData.locations[dep] = path.resolve(
991
+ process.cwd(),
992
+ "node_modules",
993
+ dep
994
+ );
995
+ } else {
996
+ versionData.dependencies[dep] = {
997
+ installed: null,
998
+ expected:
999
+ mainPackage.dependencies[dep] ||
1000
+ mainPackage.devDependencies[dep] ||
1001
+ "not specified",
1002
+ status: "not found",
1003
+ error: "package.json not found",
1004
+ };
1005
+ }
1006
+ } catch (error) {
1007
+ versionData.dependencies[dep] = {
1008
+ installed: null,
1009
+ expected:
1010
+ mainPackage.dependencies[dep] ||
1011
+ mainPackage.devDependencies[dep] ||
1012
+ "not specified",
1013
+ status: "error",
1014
+ error: error.message,
1015
+ };
1016
+ }
1017
+ });
1018
+
1019
+ return versionData;
1020
+ } catch (error) {
1021
+ return { error: error.message };
1022
+ }
1023
+ }