doc-detective 3.4.0-dev.2 → 3.4.0-dev.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc-detective",
3
- "version": "3.4.0-dev.2",
3
+ "version": "3.4.0-dev.3",
4
4
  "description": "Treat doc content as testable assertions to validate doc accuracy and product UX.",
5
5
  "bin": {
6
6
  "doc-detective": "src/index.js"
@@ -33,6 +33,7 @@
33
33
  "homepage": "https://github.com/doc-detective/doc-detective#readme",
34
34
  "dependencies": {
35
35
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
36
+ "axios": "^1.12.2",
36
37
  "doc-detective-common": "^3.4.0",
37
38
  "doc-detective-core": "^3.4.0",
38
39
  "yargs": "^17.7.2"
package/src/index.js CHANGED
@@ -1,7 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { runTests, runCoverage } = require("doc-detective-core");
4
- const { setArgs, setConfig, outputResults, setMeta, getVersionData, log } = require("./utils");
3
+ const { runTests } = require("doc-detective-core");
4
+ const {
5
+ setArgs,
6
+ setConfig,
7
+ outputResults,
8
+ setMeta,
9
+ getVersionData,
10
+ log,
11
+ getResolvedTestsFromEnv,
12
+ reportResults,
13
+ } = require("./utils");
5
14
  const { argv } = require("node:process");
6
15
  const path = require("path");
7
16
  const fs = require("fs");
@@ -36,16 +45,28 @@ async function main(argv) {
36
45
  // Set config
37
46
  const config = await setConfig({ configPath: configPath, args: argv });
38
47
 
39
- if (config.logLevel === "debug") {
40
- console.log(`CLI:VERSION INFO:\n${JSON.stringify(getVersionData(), null, 2)}`);
41
- console.log(`CLI:CONFIG:\n${JSON.stringify(config, null, 2)}`);
42
- }
48
+ log(
49
+ `CLI:VERSION INFO:\n${JSON.stringify(getVersionData(), null, 2)}`,
50
+ "debug",
51
+ config
52
+ );
53
+ log(`CLI:CONFIG:\n${JSON.stringify(config, null, 2)}`, "debug", config);
54
+
55
+ // Check for DOC_DETECTIVE_API environment variable
56
+ let api = await getResolvedTestsFromEnv(config);
57
+ let resolvedTests = api?.resolvedTests || null;
58
+ let apiConfig = api?.apiConfig || null;
43
59
 
44
60
  // Run tests
45
61
  const output = config.output;
46
- const results = await runTests(config);
62
+ const results = resolvedTests
63
+ ? await runTests(config, { resolvedTests })
64
+ : await runTests(config);
47
65
 
48
- // Output results
49
- await outputResults(config, output, results, { command: "runTests" });
50
-
51
- }
66
+ if (apiConfig) {
67
+ await reportResults({ apiConfig, results });
68
+ } else {
69
+ // Output results
70
+ await outputResults(config, output, results, { command: "runTests" });
71
+ }
72
+ }
package/src/utils.js CHANGED
@@ -5,6 +5,7 @@ const path = require("path");
5
5
  const fs = require("fs");
6
6
  const { spawn } = require("child_process");
7
7
  const os = require("os");
8
+ const axios = require("axios");
8
9
 
9
10
  exports.setArgs = setArgs;
10
11
  exports.setConfig = setConfig;
@@ -12,6 +13,26 @@ exports.outputResults = outputResults;
12
13
  exports.spawnCommand = spawnCommand;
13
14
  exports.setMeta = setMeta;
14
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
+ }
15
36
 
16
37
  // Define args
17
38
  function setArgs(args) {
@@ -50,6 +71,116 @@ function setArgs(args) {
50
71
  return argv;
51
72
  }
52
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}`, "debug", config);
97
+
98
+ // Make GET request to the specified URL with token in header
99
+ const response = await axios.get(apiConfig.url, {
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
+
53
184
  // Override config values based on args and validate the config
54
185
  async function setConfig({ configPath, args }) {
55
186
  if (args.config && !configPath) {
@@ -68,28 +199,10 @@ async function setConfig({ configPath, args }) {
68
199
  }
69
200
 
70
201
  // Check for DOC_DETECTIVE_CONFIG environment variable
71
- if (process.env.DOC_DETECTIVE_CONFIG) {
72
- try {
73
- // Parse the environment variable as JSON
74
- const envConfig = JSON.parse(process.env.DOC_DETECTIVE_CONFIG);
75
-
76
- // Validate the environment variable config
77
- const envValidation = validate({
78
- schemaKey: "config_v3",
79
- object: envConfig,
80
- });
81
-
82
- if (!envValidation.valid) {
83
- console.error("Invalid config from DOC_DETECTIVE_CONFIG environment variable.", envValidation.errors);
84
- process.exit(1);
85
- }
86
-
87
- // Merge with file config, preferring environment variable config (use raw envConfig, not validated with defaults)
88
- config = { ...config, ...envConfig };
89
- } catch (error) {
90
- console.error(`Error parsing DOC_DETECTIVE_CONFIG environment variable: ${error.message}`);
91
- process.exit(1);
92
- }
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 };
93
206
  }
94
207
 
95
208
  // Validate config
@@ -613,6 +726,70 @@ function registerReporter(name, reporterFunction) {
613
726
  // Export the registerReporter function
614
727
  exports.registerReporter = registerReporter;
615
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
+ context.id ||
744
+ `${spec.specId}-${test.testId}-${
745
+ context.platform || "unknown"
746
+ }`;
747
+
748
+ // Convert result status to lowercase (PASS -> passed, FAIL -> failed, etc.)
749
+ let status;
750
+ if (context.result === "PASS") {
751
+ status = "passed";
752
+ } else if (context.result === "FAIL") {
753
+ status = "failed";
754
+ } else if (context.result === "WARNING") {
755
+ status = "warning";
756
+ } else if (context.result === "SKIPPED") {
757
+ status = "skipped";
758
+ } else {
759
+ status = "unknown";
760
+ }
761
+
762
+ // Build the context payload with the entire context object embedded
763
+ contexts.push({
764
+ contextId: contextId,
765
+ status: status,
766
+ result: context,
767
+ });
768
+ });
769
+ }
770
+ });
771
+ }
772
+ });
773
+ }
774
+
775
+ // POST to the /contexts endpoint
776
+ try {
777
+ const url = `${apiConfig.url}/contexts`;
778
+ const payload = { contexts };
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
+
616
793
  async function outputResults(config = {}, outputPath, results, options = {}) {
617
794
  // Default to using both built-in reporters if none specified
618
795
  const defaultReporters = ["terminal", "json"];
@@ -678,7 +855,9 @@ async function spawnCommand(cmd, args) {
678
855
  }
679
856
  }
680
857
 
681
- const runCommand = spawn(cmd, args);
858
+ const runCommand = spawn(cmd, args, {
859
+ env: process.env, // Explicitly pass environment variables
860
+ });
682
861
 
683
862
  // Capture stdout
684
863
  let stdout = "";
@@ -0,0 +1,193 @@
1
+ const { createServer } = require("./server");
2
+ const path = require("path");
3
+ const { spawnCommand } = require("../src/utils");
4
+ const assert = require("assert").strict;
5
+ const fs = require("fs");
6
+ const artifactPath = path.resolve(__dirname, "./artifacts");
7
+ const outputFile = path.resolve(`${artifactPath}/resolvedTestsResults.json`);
8
+
9
+ // Create a server with custom options
10
+ const server = createServer({
11
+ port: 8093,
12
+ staticDir: "./test/server/public",
13
+ });
14
+
15
+ // Start the server before tests
16
+ before(async () => {
17
+ try {
18
+ await server.start();
19
+ } catch (error) {
20
+ console.error(`Failed to start test server: ${error.message}`);
21
+ throw error;
22
+ }
23
+ });
24
+
25
+ // Stop the server after tests
26
+ after(async () => {
27
+ try {
28
+ await server.stop();
29
+ } catch (error) {
30
+ console.error(`Failed to stop test server: ${error.message}`);
31
+ }
32
+ });
33
+
34
+ describe("DOC_DETECTIVE_API environment variable", function () {
35
+ // Set indefinite timeout
36
+ this.timeout(0);
37
+
38
+ it("Should fetch and run resolved tests from API", async () => {
39
+ const apiConfig = {
40
+ accountId: "test-account",
41
+ url: "http://localhost:8093/api/resolved-tests",
42
+ token: "test-token-123",
43
+ contextIds: "test-context",
44
+ };
45
+
46
+ // Set environment variable
47
+ const originalEnv = process.env.DOC_DETECTIVE_API;
48
+ process.env.DOC_DETECTIVE_API = JSON.stringify(apiConfig);
49
+
50
+ try {
51
+ const result = await spawnCommand(
52
+ `node ./src/index.js -o ${outputFile}`
53
+ );
54
+
55
+ // Wait until the file is written
56
+ let waitCount = 0;
57
+ while (!fs.existsSync(outputFile) && waitCount < 50) {
58
+ await new Promise((resolve) => setTimeout(resolve, 100));
59
+ waitCount++;
60
+ }
61
+
62
+ if (fs.existsSync(outputFile)) {
63
+ const testResult = require(outputFile);
64
+ console.log(
65
+ "API Result summary:",
66
+ JSON.stringify(testResult.summary, null, 2)
67
+ );
68
+ // Clean up the require cache
69
+ delete require.cache[require.resolve(outputFile)];
70
+ fs.unlinkSync(outputFile);
71
+
72
+ // Check that tests were run
73
+ assert.ok(testResult.summary);
74
+ assert.ok(testResult.specs);
75
+ }
76
+ } finally {
77
+ // Restore original env
78
+ if (originalEnv !== undefined) {
79
+ process.env.DOC_DETECTIVE_API = originalEnv;
80
+ } else {
81
+ delete process.env.DOC_DETECTIVE_API;
82
+ }
83
+ }
84
+ });
85
+
86
+ it("Should reject API config without required fields", async () => {
87
+ const invalidApiConfig = {
88
+ accountId: "test-account",
89
+ // Missing url and token
90
+ };
91
+
92
+ const originalEnv = process.env.DOC_DETECTIVE_API;
93
+ process.env.DOC_DETECTIVE_API = JSON.stringify(invalidApiConfig);
94
+
95
+ try {
96
+ const result = await spawnCommand(
97
+ `node ./src/index.js -o ${outputFile}`
98
+ );
99
+
100
+ // Should exit with non-zero code
101
+ assert.notEqual(result.exitCode, 0);
102
+ } finally {
103
+ // Restore original env
104
+ if (originalEnv !== undefined) {
105
+ process.env.DOC_DETECTIVE_API = originalEnv;
106
+ } else {
107
+ delete process.env.DOC_DETECTIVE_API;
108
+ }
109
+ }
110
+ });
111
+
112
+ it("Should reject unauthorized API requests", async () => {
113
+ const apiConfigBadToken = {
114
+ accountId: "test-account",
115
+ url: "http://localhost:8093/api/resolved-tests",
116
+ token: "wrong-token",
117
+ contextIds: "test-context",
118
+ };
119
+
120
+ const originalEnv = process.env.DOC_DETECTIVE_API;
121
+ process.env.DOC_DETECTIVE_API = JSON.stringify(apiConfigBadToken);
122
+
123
+ try {
124
+ const result = await spawnCommand(
125
+ `node ./src/index.js -o ${outputFile}`
126
+ );
127
+
128
+ // Should exit with non-zero code due to 401 response
129
+ assert.notEqual(result.exitCode, 0);
130
+ } finally {
131
+ // Restore original env
132
+ if (originalEnv !== undefined) {
133
+ process.env.DOC_DETECTIVE_API = originalEnv;
134
+ } else {
135
+ delete process.env.DOC_DETECTIVE_API;
136
+ }
137
+ }
138
+ });
139
+
140
+ it("Should apply config overrides from DOC_DETECTIVE_CONFIG to API-fetched tests", async () => {
141
+ const apiConfig = {
142
+ accountId: "test-account",
143
+ url: "http://localhost:8093/api/resolved-tests",
144
+ token: "test-token-123",
145
+ contextIds: "test-context",
146
+ };
147
+
148
+ const configOverride = {
149
+ logLevel: "debug",
150
+ };
151
+
152
+ const originalApiEnv = process.env.DOC_DETECTIVE_API;
153
+ const originalConfigEnv = process.env.DOC_DETECTIVE_CONFIG;
154
+ process.env.DOC_DETECTIVE_API = JSON.stringify(apiConfig);
155
+ process.env.DOC_DETECTIVE_CONFIG = JSON.stringify(configOverride);
156
+
157
+ try {
158
+ await spawnCommand(
159
+ `node ./src/index.js -o ${outputFile}`
160
+ );
161
+
162
+ // Wait until the file is written
163
+ let waitCount = 0;
164
+ while (!fs.existsSync(outputFile) && waitCount < 50) {
165
+ await new Promise((resolve) => setTimeout(resolve, 100));
166
+ waitCount++;
167
+ }
168
+
169
+ if (fs.existsSync(outputFile)) {
170
+ const testResult = require(outputFile);
171
+ // Clean up the require cache
172
+ delete require.cache[require.resolve(outputFile)];
173
+ fs.unlinkSync(outputFile);
174
+
175
+ // Check that tests were run
176
+ assert.ok(testResult.summary);
177
+ assert.ok(testResult.specs);
178
+ }
179
+ } finally {
180
+ // Restore original env
181
+ if (originalApiEnv !== undefined) {
182
+ process.env.DOC_DETECTIVE_API = originalApiEnv;
183
+ } else {
184
+ delete process.env.DOC_DETECTIVE_API;
185
+ }
186
+ if (originalConfigEnv !== undefined) {
187
+ process.env.DOC_DETECTIVE_CONFIG = originalConfigEnv;
188
+ } else {
189
+ delete process.env.DOC_DETECTIVE_CONFIG;
190
+ }
191
+ }
192
+ });
193
+ });
@@ -54,6 +54,52 @@ function createServer(options = {}) {
54
54
  }
55
55
  });
56
56
 
57
+ // Endpoint for testing DOC_DETECTIVE_API - returns resolved tests
58
+ app.get("/api/resolved-tests", (req, res) => {
59
+ try {
60
+ // Check for x-runner-token header
61
+ const token = req.headers['x-runner-token'];
62
+
63
+ if (!token || token !== 'test-token-123') {
64
+ return res.status(401).json({ error: "Unauthorized" });
65
+ }
66
+
67
+ // Return a valid resolvedTests object
68
+ const resolvedTests = {
69
+ "resolvedTestsId": "api-resolved-tests-id",
70
+ "config": {
71
+ "logLevel": "info"
72
+ },
73
+ "specs": [
74
+ {
75
+ "specId": "api-spec",
76
+ "tests": [
77
+ {
78
+ "testId": "api-test",
79
+ "contexts": [
80
+ {
81
+ "contextId": "api-context",
82
+ "steps": [
83
+ {
84
+ "stepId": "step-1",
85
+ "checkLink": `http://localhost:${port}`
86
+ }
87
+ ]
88
+ }
89
+ ]
90
+ }
91
+ ]
92
+ }
93
+ ]
94
+ };
95
+
96
+ res.json(resolvedTests);
97
+ } catch (error) {
98
+ console.error("Error processing resolved tests request:", error);
99
+ res.status(500).json({ error: "Internal server error" });
100
+ }
101
+ });
102
+
57
103
  return {
58
104
  /**
59
105
  * Start the server