doc-detective 3.4.0-dev.1 → 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.1",
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,8 +33,9 @@
33
33
  "homepage": "https://github.com/doc-detective/doc-detective#readme",
34
34
  "dependencies": {
35
35
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
36
- "doc-detective-common": "^3.4.0-dita.0-dev.1",
37
- "doc-detective-core": "^3.4.0-dita.0-dev.1",
36
+ "axios": "^1.12.2",
37
+ "doc-detective-common": "^3.4.0",
38
+ "doc-detective-core": "^3.4.0",
38
39
  "yargs": "^17.7.2"
39
40
  },
40
41
  "devDependencies": {
package/reference.png CHANGED
Binary file
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) {
@@ -67,6 +198,13 @@ async function setConfig({ configPath, args }) {
67
198
  }
68
199
  }
69
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
+
70
208
  // Validate config
71
209
  const validation = validate({
72
210
  schemaKey: "config_v3",
@@ -588,6 +726,70 @@ function registerReporter(name, reporterFunction) {
588
726
  // Export the registerReporter function
589
727
  exports.registerReporter = registerReporter;
590
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
+
591
793
  async function outputResults(config = {}, outputPath, results, options = {}) {
592
794
  // Default to using both built-in reporters if none specified
593
795
  const defaultReporters = ["terminal", "json"];
@@ -653,7 +855,9 @@ async function spawnCommand(cmd, args) {
653
855
  }
654
856
  }
655
857
 
656
- const runCommand = spawn(cmd, args);
858
+ const runCommand = spawn(cmd, args, {
859
+ env: process.env, // Explicitly pass environment variables
860
+ });
657
861
 
658
862
  // Capture stdout
659
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
@@ -204,6 +204,59 @@ describe("Util tests", function () {
204
204
  // Clean up
205
205
  fs.unlinkSync(outputResultsPath);
206
206
  });
207
+
208
+ // Test environment variable config detection
209
+ it("Config from DOC_DETECTIVE_CONFIG environment variable is loaded and merged", async function () {
210
+ this.timeout(5000);
211
+
212
+ // Save the original environment variable value
213
+ const originalEnvConfig = process.env.DOC_DETECTIVE_CONFIG;
214
+
215
+ try {
216
+ // Test 1: Valid environment variable config without file config
217
+ process.env.DOC_DETECTIVE_CONFIG = JSON.stringify({
218
+ logLevel: "debug"
219
+ });
220
+
221
+ const config1 = await setConfig({ args: setArgs(["node", "runTests.js"]) });
222
+ expect(config1.logLevel).to.equal("debug");
223
+
224
+ // Test 2: Environment variable config merged with file config (env var takes precedence)
225
+ process.env.DOC_DETECTIVE_CONFIG = JSON.stringify({
226
+ logLevel: "error"
227
+ });
228
+
229
+ const config2 = await setConfig({
230
+ configPath: "./test/test-config.json",
231
+ args: setArgs(["node", "runTests.js", "--config", "./test/test-config.json"])
232
+ });
233
+ // Environment variable should override file config
234
+ expect(config2.logLevel).to.equal("error");
235
+ // Check that other values from file config are preserved
236
+ expect(config2.telemetry.send).to.equal(false);
237
+
238
+ // Test 3: Environment variable config with command line args (args take precedence)
239
+ process.env.DOC_DETECTIVE_CONFIG = JSON.stringify({
240
+ logLevel: "warning",
241
+ input: "env-input.json"
242
+ });
243
+
244
+ const config3 = await setConfig({
245
+ args: setArgs(["node", "runTests.js", "--input", "cli-input.json", "--logLevel", "debug"])
246
+ });
247
+ // Command line args should override environment variable
248
+ expect(config3.logLevel).to.equal("debug");
249
+ expect(config3.input).to.deep.equal([path.resolve(process.cwd(), "cli-input.json")]);
250
+
251
+ } finally {
252
+ // Restore the original environment variable value
253
+ if (originalEnvConfig !== undefined) {
254
+ process.env.DOC_DETECTIVE_CONFIG = originalEnvConfig;
255
+ } else {
256
+ delete process.env.DOC_DETECTIVE_CONFIG;
257
+ }
258
+ }
259
+ });
207
260
  });
208
261
 
209
262
  // Deeply compares two objects