doc-detective 4.38.0 → 4.41.4
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/README.md +4 -4
- package/dist/agents/adapters/claude-code.d.ts.map +1 -1
- package/dist/agents/adapters/claude-code.js +1 -2
- package/dist/agents/adapters/claude-code.js.map +1 -1
- package/dist/agents/adapters/codex.d.ts.map +1 -1
- package/dist/agents/adapters/codex.js +3 -4
- package/dist/agents/adapters/codex.js.map +1 -1
- package/dist/agents/adapters/copilot-cli.d.ts.map +1 -1
- package/dist/agents/adapters/copilot-cli.js +1 -2
- package/dist/agents/adapters/copilot-cli.js.map +1 -1
- package/dist/agents/adapters/gemini-cli.d.ts.map +1 -1
- package/dist/agents/adapters/gemini-cli.js +1 -2
- package/dist/agents/adapters/gemini-cli.js.map +1 -1
- package/dist/agents/adapters/opencode.d.ts.map +1 -1
- package/dist/agents/adapters/opencode.js +1 -2
- package/dist/agents/adapters/opencode.js.map +1 -1
- package/dist/agents/adapters/qwen-code.d.ts.map +1 -1
- package/dist/agents/adapters/qwen-code.js +1 -2
- package/dist/agents/adapters/qwen-code.js.map +1 -1
- package/dist/agents/fetcher.d.ts.map +1 -1
- package/dist/agents/fetcher.js +1 -2
- package/dist/agents/fetcher.js.map +1 -1
- package/dist/common/src/ajv.d.ts +30 -0
- package/dist/common/src/ajv.d.ts.map +1 -0
- package/dist/common/src/ajv.js +131 -0
- package/dist/common/src/ajv.js.map +1 -0
- package/dist/common/src/detectTests.d.ts +2 -18
- package/dist/common/src/detectTests.d.ts.map +1 -1
- package/dist/common/src/detectTests.js +8 -77
- package/dist/common/src/detectTests.js.map +1 -1
- package/dist/common/src/schemas/referenced.d.ts +12 -0
- package/dist/common/src/schemas/referenced.d.ts.map +1 -0
- package/dist/common/src/schemas/referenced.js +10 -0
- package/dist/common/src/schemas/referenced.js.map +1 -0
- package/dist/common/src/schemas/schemas.json +2 -2
- package/dist/common/src/schemas/schemasReferenced.json +14871 -0
- package/dist/common/src/schemas/validators.cjs +509 -0
- package/dist/common/src/schemas/validatorsCheck.cjs +2 -0
- package/dist/common/src/textUtils.d.ts +25 -0
- package/dist/common/src/textUtils.d.ts.map +1 -0
- package/dist/common/src/textUtils.js +84 -0
- package/dist/common/src/textUtils.js.map +1 -0
- package/dist/common/src/types/generated/annotate_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/annotation_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/click_v3.d.ts +153 -0
- package/dist/common/src/types/generated/click_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/config_v3.d.ts +13753 -207
- package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/dragAndDrop_v3.d.ts +72 -0
- package/dist/common/src/types/generated/dragAndDrop_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/find_v3.d.ts +632 -0
- package/dist/common/src/types/generated/find_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/goTo_v3.d.ts +31 -0
- package/dist/common/src/types/generated/goTo_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/httpRequest_v3.d.ts +181 -0
- package/dist/common/src/types/generated/httpRequest_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/loadCookie_v3.d.ts +30 -0
- package/dist/common/src/types/generated/loadCookie_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/report_v3.d.ts +28234 -321
- package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/resolvedTests_v3.d.ts +42281 -822
- package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/saveCookie_v3.d.ts +35 -0
- package/dist/common/src/types/generated/saveCookie_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/screenshot_v3.d.ts +45 -0
- package/dist/common/src/types/generated/screenshot_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/spec_v3.d.ts +28188 -275
- package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/startSurface_v3.d.ts +63 -1
- package/dist/common/src/types/generated/startSurface_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/step_v3.d.ts +5186 -523
- package/dist/common/src/types/generated/step_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/test_v3.d.ts +13268 -3582
- package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/type_v3.d.ts +62 -0
- package/dist/common/src/types/generated/type_v3.d.ts.map +1 -1
- package/dist/common/src/types/generated/waitUntil_v3.d.ts +62 -0
- package/dist/common/src/types/generated/waitUntil_v3.d.ts.map +1 -1
- package/dist/common/src/validate.d.ts +1 -1
- package/dist/common/src/validate.d.ts.map +1 -1
- package/dist/common/src/validate.js +29 -75
- package/dist/common/src/validate.js.map +1 -1
- package/dist/core/appium.d.ts +3 -2
- package/dist/core/appium.d.ts.map +1 -1
- package/dist/core/appium.js +63 -89
- package/dist/core/appium.js.map +1 -1
- package/dist/core/appiumReuse.d.ts +43 -0
- package/dist/core/appiumReuse.d.ts.map +1 -0
- package/dist/core/appiumReuse.js +226 -0
- package/dist/core/appiumReuse.js.map +1 -0
- package/dist/core/config.d.ts +10 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +37 -7
- package/dist/core/config.js.map +1 -1
- package/dist/core/detectTests.d.ts.map +1 -1
- package/dist/core/detectTests.js +51 -17
- package/dist/core/detectTests.js.map +1 -1
- package/dist/core/files.d.ts.map +1 -1
- package/dist/core/files.js +3 -4
- package/dist/core/files.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +132 -21
- package/dist/core/index.js.map +1 -1
- package/dist/core/integrations/heretto.d.ts +1 -1
- package/dist/core/integrations/heretto.d.ts.map +1 -1
- package/dist/core/integrations/heretto.js +7 -6
- package/dist/core/integrations/heretto.js.map +1 -1
- package/dist/core/lazyYaml.d.ts +2 -0
- package/dist/core/lazyYaml.d.ts.map +1 -0
- package/dist/core/lazyYaml.js +13 -0
- package/dist/core/lazyYaml.js.map +1 -0
- package/dist/core/resolveTests.js +1 -1
- package/dist/core/resolveTests.js.map +1 -1
- package/dist/core/telem.d.ts +1 -1
- package/dist/core/telem.d.ts.map +1 -1
- package/dist/core/telem.js +2 -11
- package/dist/core/telem.js.map +1 -1
- package/dist/core/telemLite.d.ts +10 -0
- package/dist/core/telemLite.d.ts.map +1 -0
- package/dist/core/telemLite.js +101 -0
- package/dist/core/telemLite.js.map +1 -0
- package/dist/core/tests/androidEmulator.d.ts +67 -4
- package/dist/core/tests/androidEmulator.d.ts.map +1 -1
- package/dist/core/tests/androidEmulator.js +366 -37
- package/dist/core/tests/androidEmulator.js.map +1 -1
- package/dist/core/tests/appSurface.d.ts +25 -6
- package/dist/core/tests/appSurface.d.ts.map +1 -1
- package/dist/core/tests/appSurface.js +88 -52
- package/dist/core/tests/appSurface.js.map +1 -1
- package/dist/core/tests/browserSessions.d.ts +2 -1
- package/dist/core/tests/browserSessions.d.ts.map +1 -1
- package/dist/core/tests/browserSessions.js +7 -1
- package/dist/core/tests/browserSessions.js.map +1 -1
- package/dist/core/tests/checkLink.d.ts +1 -0
- package/dist/core/tests/checkLink.d.ts.map +1 -1
- package/dist/core/tests/checkLink.js +14 -5
- package/dist/core/tests/checkLink.js.map +1 -1
- package/dist/core/tests/closeSurface.d.ts.map +1 -1
- package/dist/core/tests/closeSurface.js +4 -3
- package/dist/core/tests/closeSurface.js.map +1 -1
- package/dist/core/tests/findStrategies.d.ts.map +1 -1
- package/dist/core/tests/findStrategies.js +153 -116
- package/dist/core/tests/findStrategies.js.map +1 -1
- package/dist/core/tests/geckoDriver.d.ts +16 -0
- package/dist/core/tests/geckoDriver.d.ts.map +1 -0
- package/dist/core/tests/geckoDriver.js +127 -0
- package/dist/core/tests/geckoDriver.js.map +1 -0
- package/dist/core/tests/goTo.d.ts.map +1 -1
- package/dist/core/tests/goTo.js +12 -33
- package/dist/core/tests/goTo.js.map +1 -1
- package/dist/core/tests/httpRequest.d.ts.map +1 -1
- package/dist/core/tests/httpRequest.js +4 -3
- package/dist/core/tests/httpRequest.js.map +1 -1
- package/dist/core/tests/iosSimulator.d.ts +4 -1
- package/dist/core/tests/iosSimulator.d.ts.map +1 -1
- package/dist/core/tests/iosSimulator.js +49 -1
- package/dist/core/tests/iosSimulator.js.map +1 -1
- package/dist/core/tests/mobileBrowser.d.ts +2 -1
- package/dist/core/tests/mobileBrowser.d.ts.map +1 -1
- package/dist/core/tests/mobileBrowser.js +5 -2
- package/dist/core/tests/mobileBrowser.js.map +1 -1
- package/dist/core/tests/runCode.d.ts +9 -1
- package/dist/core/tests/runCode.d.ts.map +1 -1
- package/dist/core/tests/runCode.js +58 -2
- package/dist/core/tests/runCode.js.map +1 -1
- package/dist/core/tests/saveScreenshot.d.ts +18 -2
- package/dist/core/tests/saveScreenshot.d.ts.map +1 -1
- package/dist/core/tests/saveScreenshot.js +159 -4
- package/dist/core/tests/saveScreenshot.js.map +1 -1
- package/dist/core/tests/startRecording.d.ts.map +1 -1
- package/dist/core/tests/startRecording.js +6 -4
- package/dist/core/tests/startRecording.js.map +1 -1
- package/dist/core/tests/startSurface.d.ts.map +1 -1
- package/dist/core/tests/startSurface.js +8 -2
- package/dist/core/tests/startSurface.js.map +1 -1
- package/dist/core/tests.d.ts +21 -7
- package/dist/core/tests.d.ts.map +1 -1
- package/dist/core/tests.js +372 -121
- package/dist/core/tests.js.map +1 -1
- package/dist/core/utils.d.ts +68 -3
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/core/utils.js +551 -18
- package/dist/core/utils.js.map +1 -1
- package/dist/core/verification.d.ts +17 -0
- package/dist/core/verification.d.ts.map +1 -0
- package/dist/core/verification.js +850 -0
- package/dist/core/verification.js.map +1 -0
- package/dist/debug/appium.js +2 -2
- package/dist/debug/appium.js.map +1 -1
- package/dist/debug/findings.js +2 -2
- package/dist/debug/findings.js.map +1 -1
- package/dist/debug/tools.d.ts +8 -0
- package/dist/debug/tools.d.ts.map +1 -1
- package/dist/debug/tools.js +60 -22
- package/dist/debug/tools.js.map +1 -1
- package/dist/hints/context.d.ts.map +1 -1
- package/dist/hints/context.js +2 -2
- package/dist/hints/context.js.map +1 -1
- package/dist/index.cjs +25777 -4
- package/dist/reporters/htmlReporter.js +1 -1
- package/dist/runtime/androidInstaller.d.ts +45 -0
- package/dist/runtime/androidInstaller.d.ts.map +1 -1
- package/dist/runtime/androidInstaller.js +88 -5
- package/dist/runtime/androidInstaller.js.map +1 -1
- package/dist/runtime/browsers.d.ts +87 -7
- package/dist/runtime/browsers.d.ts.map +1 -1
- package/dist/runtime/browsers.js +308 -41
- package/dist/runtime/browsers.js.map +1 -1
- package/dist/runtime/cacheDir.d.ts +1 -1
- package/dist/runtime/cacheDir.d.ts.map +1 -1
- package/dist/runtime/cacheDir.js +5 -2
- package/dist/runtime/cacheDir.js.map +1 -1
- package/dist/runtime/heavyDeps.d.ts.map +1 -1
- package/dist/runtime/heavyDeps.js.map +1 -1
- package/dist/runtime/installCommand.d.ts.map +1 -1
- package/dist/runtime/installCommand.js +23 -0
- package/dist/runtime/installCommand.js.map +1 -1
- package/dist/runtime/loader.d.ts +13 -0
- package/dist/runtime/loader.d.ts.map +1 -1
- package/dist/runtime/loader.js +50 -0
- package/dist/runtime/loader.js.map +1 -1
- package/dist/runtime/wdaProducts.d.ts +12 -1
- package/dist/runtime/wdaProducts.d.ts.map +1 -1
- package/dist/runtime/wdaProducts.js +27 -2
- package/dist/runtime/wdaProducts.js.map +1 -1
- package/dist/runtime/windowsBash.d.ts +6 -1
- package/dist/runtime/windowsBash.d.ts.map +1 -1
- package/dist/runtime/windowsBash.js +45 -4
- package/dist/runtime/windowsBash.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +19 -5
- package/dist/utils.js.map +1 -1
- package/package.json +43 -41
package/dist/core/utils.js
CHANGED
|
@@ -4,12 +4,20 @@ import path from "node:path";
|
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
import dns from "node:dns/promises";
|
|
6
6
|
import net from "node:net";
|
|
7
|
-
import axios from "axios";
|
|
8
7
|
import { spawn } from "node:child_process";
|
|
9
8
|
import { loadHeavyDep, resolveHeavyDepPath, ensureRuntimeInstalled, } from "../runtime/loader.js";
|
|
10
9
|
import { resolveWindowsBash as defaultResolveWindowsBash } from "../runtime/windowsBash.js";
|
|
11
10
|
import { assertConptyAllocatable, ensurePtyBackendOnDisk, } from "./ptyWatchdog.js";
|
|
12
|
-
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { applyVerification, declarePage, VERIFIED_TOOL } from "./verification.js";
|
|
13
|
+
// axios (~70 modules with its deps) loads on first use rather than at CLI
|
|
14
|
+
// startup (see test/startup-modules.test.js). Cached, so hot paths pay the
|
|
15
|
+
// dynamic import once.
|
|
16
|
+
let axiosModule;
|
|
17
|
+
export function loadAxios() {
|
|
18
|
+
return (axiosModule ??= import("axios").then((m) => m.default));
|
|
19
|
+
}
|
|
20
|
+
export { outputResults, loadEnvs, log, logLevelEnabled, timestamp, getOrInitRunTimestamp, getRunOutputDir, runArchivesArtifacts, replaceEnvs, spawnCommand, spawnBackgroundCommand, spawnPtyBackgroundCommand, resolveShellName, resolveShellExecutable, shellSpawnEnv, waitForReady, waitForPort, waitForHttp, waitForStdio, waitForOutputMatch, inContainer, cleanTemp, calculateFractionalDifference, serializeBrowserResult, matchesExpectedOutput, fetchFile, isRelativeUrl, appendQueryParams, isDeviceWebContext, computeSettleCeiling, redactUrlForOutput, assertUrlHostIsPublic, sanitizeFilesystemName, compileFilter, isRetryableSessionError, isSessionAlive, isPageBroken, isPageUnnavigated, isInitialBlankDocument, issueNavigation, probeStuckOnBlankDocument, guardBlankDocumentNavigation, classifyContextRetry, isTransientProcessInitError, matchesFilter, selectSpecsForRun, shouldFailRun, findFreePort, runConcurrent, createResourceRegistry, runResourceAware, rollUpResults, rollUpAssertions, createAppiumPool, evaluateContextRequirements, verifiedDate, shieldsBadge, resolveVerifiedId, applyVerifiedMarkers, applyBadgeAnchoredTests, };
|
|
13
21
|
// A fixed set of Appium server ports shared by concurrent runners. `acquire()`
|
|
14
22
|
// hands out a free port, waiting if every port is checked out; `release()`
|
|
15
23
|
// returns one, handing it straight to the next waiter. Single-threaded JS
|
|
@@ -495,7 +503,7 @@ async function waitForHttp(url, { deadline }) {
|
|
|
495
503
|
// Cap the per-request timeout to the time left so a single hung request
|
|
496
504
|
// can't block past the overall readiness deadline (e.g. timeout: 600).
|
|
497
505
|
const remaining = deadline - Date.now();
|
|
498
|
-
const resp = await
|
|
506
|
+
const resp = await (await loadAxios()).get(url, {
|
|
499
507
|
validateStatus: () => true,
|
|
500
508
|
timeout: Math.max(1, Math.min(5000, remaining)),
|
|
501
509
|
});
|
|
@@ -754,6 +762,77 @@ async function isPageUnnavigated(driver) {
|
|
|
754
762
|
function isInitialBlankDocument(url) {
|
|
755
763
|
return INITIAL_BLANK_DOCUMENT.test(String(url ?? "").trim());
|
|
756
764
|
}
|
|
765
|
+
// Issue a navigation, then re-issue it once if the browser is still parked on
|
|
766
|
+
// its initial blank document. Chromium's `url()` can resolve with the session
|
|
767
|
+
// never having left `data:,`, so the navigation silently did not take while
|
|
768
|
+
// every later check passes trivially against the blank page (ADR 01084, ADR
|
|
769
|
+
// 01088). Returns whether the retry fired, which is what gates
|
|
770
|
+
// probeStuckOnBlankDocument below: a first attempt that landed on a real page
|
|
771
|
+
// proves nothing by being re-read, so the healthy path pays no extra round trip.
|
|
772
|
+
async function issueNavigation(driver, url) {
|
|
773
|
+
await driver.url(url);
|
|
774
|
+
if (!(await isPageUnnavigated(driver)))
|
|
775
|
+
return false;
|
|
776
|
+
await driver.url(url);
|
|
777
|
+
return true;
|
|
778
|
+
}
|
|
779
|
+
// The companion verdict. Reads the URL ONCE and returns it when the browser is
|
|
780
|
+
// still on the initial blank document, else null, so a caller decides and
|
|
781
|
+
// reports from a single observation. Probing twice can decide on one URL and
|
|
782
|
+
// print another if the page moves in between, which reads as the tool
|
|
783
|
+
// contradicting itself. An unreadable URL is not evidence of anything, so it
|
|
784
|
+
// reads as not stuck, matching isPageUnnavigated's own swallow.
|
|
785
|
+
async function probeStuckOnBlankDocument(driver, retried) {
|
|
786
|
+
if (!retried)
|
|
787
|
+
return null;
|
|
788
|
+
let observedUrl;
|
|
789
|
+
try {
|
|
790
|
+
observedUrl = String((await driver.getUrl()) ?? "").trim();
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
return isInitialBlankDocument(observedUrl) ? observedUrl : null;
|
|
796
|
+
}
|
|
797
|
+
// Wrap a WebdriverIO session's `url` command with the guard above, so a caller
|
|
798
|
+
// holding a raw runner gets the same protection a `goTo` step has. getRunner
|
|
799
|
+
// hands out such a runner and its documented usage is `await runner.url(...)`,
|
|
800
|
+
// which bypassed the guard entirely: a session parked on `data:,` returned an
|
|
801
|
+
// empty title and the failure surfaced with nothing pointing at the navigation.
|
|
802
|
+
// See adrs/01102-getrunner-verifies-it-left-the-blank-document.md.
|
|
803
|
+
//
|
|
804
|
+
// Uses WebdriverIO's own `overwriteCommand` rather than assigning to `url`,
|
|
805
|
+
// because the browser object is a proxy. A runner without it (a stub in a unit
|
|
806
|
+
// test) is left alone rather than failing.
|
|
807
|
+
function guardBlankDocumentNavigation(runner, config) {
|
|
808
|
+
if (typeof runner?.overwriteCommand !== "function")
|
|
809
|
+
return;
|
|
810
|
+
runner.overwriteCommand("url", async function (originalUrl, url, ...rest) {
|
|
811
|
+
// Nothing to verify: WebdriverIO owns rejecting a missing or non-string
|
|
812
|
+
// target, and a caller deliberately asking for the blank document itself
|
|
813
|
+
// would otherwise read as stuck forever.
|
|
814
|
+
if (typeof url !== "string" ||
|
|
815
|
+
url.length === 0 ||
|
|
816
|
+
isInitialBlankDocument(url)) {
|
|
817
|
+
return originalUrl.call(this, url, ...rest);
|
|
818
|
+
}
|
|
819
|
+
// Re-implements issueNavigation's two steps rather than calling it, for
|
|
820
|
+
// two reasons. It would recurse: `this.url` IS this wrapper, and
|
|
821
|
+
// issueNavigation navigates through the driver it is handed. And the
|
|
822
|
+
// warning has to sit BETWEEN the two navigations, so a reader of the log
|
|
823
|
+
// can see the re-issue was a response to the blank page.
|
|
824
|
+
const result = await originalUrl.call(this, url, ...rest);
|
|
825
|
+
if (!(await isPageUnnavigated(this)))
|
|
826
|
+
return result;
|
|
827
|
+
log(config, "warning", `Browser was still on its initial blank document after navigating to ${redactUrlForOutput(url)}. Re-issuing the navigation.`);
|
|
828
|
+
const retriedResult = await originalUrl.call(this, url, ...rest);
|
|
829
|
+
const stuckOn = await probeStuckOnBlankDocument(this, true);
|
|
830
|
+
if (stuckOn !== null) {
|
|
831
|
+
throw new Error(`The browser never left its initial blank document (${redactUrlForOutput(stuckOn)}): navigation to ${redactUrlForOutput(url)} didn't take, even after a retry. The session is alive; the page was never loaded.`);
|
|
832
|
+
}
|
|
833
|
+
return retriedResult;
|
|
834
|
+
});
|
|
835
|
+
}
|
|
757
836
|
// The retry DECISION for a FAILed context, composed from the three probes above.
|
|
758
837
|
// Returns the reason to retry on a fresh session, or null to let the FAIL stand.
|
|
759
838
|
// Split out from runContext so the rules that the individual probes can't
|
|
@@ -770,16 +849,21 @@ function isInitialBlankDocument(url) {
|
|
|
770
849
|
// comes from a Map's values(), whose iteration order is insertion order per
|
|
771
850
|
// the language spec, not an implementation detail.
|
|
772
851
|
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
|
|
852
|
+
// • ...and only when the context actually tried to navigate. A context that
|
|
853
|
+
// ran no goTo (process-surface steps only) is legitimately on `data:,`, so
|
|
854
|
+
// its FAIL is real; retrying it re-runs steps that aren't idempotent.
|
|
855
|
+
//
|
|
856
|
+
// See adrs/01082-retries-mid-session-context-retry.md,
|
|
857
|
+
// adrs/01084-retry-unnavigated-context.md and
|
|
858
|
+
// adrs/01119-no-unnavigated-retry-without-navigation.md.
|
|
859
|
+
async function classifyContextRetry(probeDrivers, { navigated = true } = {}) {
|
|
776
860
|
for (const probeDriver of probeDrivers) {
|
|
777
861
|
if (!(await isSessionAlive(probeDriver)))
|
|
778
862
|
return "session-died";
|
|
779
863
|
if (await isPageBroken(probeDriver))
|
|
780
864
|
return "page-broken";
|
|
781
865
|
}
|
|
782
|
-
if (await isPageUnnavigated(probeDrivers[0]))
|
|
866
|
+
if (navigated && (await isPageUnnavigated(probeDrivers[0])))
|
|
783
867
|
return "unnavigated";
|
|
784
868
|
return null;
|
|
785
869
|
}
|
|
@@ -953,11 +1037,10 @@ function appendQueryParams(url, params) {
|
|
|
953
1037
|
return pathAndAuthority + (query ? "?" + query : "") + fragment;
|
|
954
1038
|
}
|
|
955
1039
|
// Delete transient scratch files from the doc-detective temp directory.
|
|
956
|
-
// PRESERVED entries:
|
|
957
|
-
//
|
|
958
|
-
// `<os.tmpdir()>/doc-detective
|
|
959
|
-
//
|
|
960
|
-
// the very chromedriver/firefox binaries the next runTests call needs
|
|
1040
|
+
// PRESERVED entries: every lazy-install cache entry. The default cacheDir is
|
|
1041
|
+
// `~/.doc-detective/` (ADR 01105), but a cacheDir explicitly pointed at
|
|
1042
|
+
// `<os.tmpdir()>/doc-detective/` shares this root with scratch files, and a
|
|
1043
|
+
// blanket wipe would delete the very binaries the next runTests call needs
|
|
961
1044
|
// (this regression broke sequential `runTests` calls in CI on every
|
|
962
1045
|
// platform — see test/core-core.test.js's getRunner suite).
|
|
963
1046
|
const PRESERVED_TEMP_ENTRIES = new Set([
|
|
@@ -967,6 +1050,10 @@ const PRESERVED_TEMP_ENTRIES = new Set([
|
|
|
967
1050
|
// Tool downloads (git-bash — the MinGit backing runShell's Windows `bash`
|
|
968
1051
|
// shell). Wiping it would force a ~40 MB re-download on every run.
|
|
969
1052
|
"tools",
|
|
1053
|
+
// `install android` (SDK + its JRE) and `install ios` (WDA prebuilds).
|
|
1054
|
+
"android-sdk",
|
|
1055
|
+
"jre",
|
|
1056
|
+
"ios",
|
|
970
1057
|
]);
|
|
971
1058
|
function cleanTemp() {
|
|
972
1059
|
const tempDir = path.join(os.tmpdir(), "doc-detective");
|
|
@@ -1046,10 +1133,20 @@ function redactUrlForOutput(value) {
|
|
|
1046
1133
|
const url = new URL(value);
|
|
1047
1134
|
url.search = "";
|
|
1048
1135
|
url.hash = "";
|
|
1136
|
+
// `toString()` re-serializes userinfo, so a basic-auth URL would publish
|
|
1137
|
+
// its password alongside the host. These strings land in step descriptions,
|
|
1138
|
+
// warnings, reports and CI logs, so clear both before serializing.
|
|
1139
|
+
url.password = "";
|
|
1140
|
+
url.username = "";
|
|
1049
1141
|
return url.toString();
|
|
1050
1142
|
}
|
|
1051
1143
|
catch {
|
|
1052
|
-
|
|
1144
|
+
const stripped = value.split("?")[0].split("#")[0];
|
|
1145
|
+
// Nothing parsed this, so match the authority textually: userinfo sits
|
|
1146
|
+
// between `//` and the first `/`. `[^/]*` cannot cross that boundary, so it
|
|
1147
|
+
// stops at the last `@` of the authority and leaves a later `@` in the path
|
|
1148
|
+
// alone.
|
|
1149
|
+
return stripped.replace(/^((?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/)[^/]*@/, "$1");
|
|
1053
1150
|
}
|
|
1054
1151
|
}
|
|
1055
1152
|
// Private/loopback/link-local IP ranges that binary URL fetches refuse to
|
|
@@ -1177,7 +1274,7 @@ async function fetchFile(fileURL, opts = {}) {
|
|
|
1177
1274
|
// change; expanding SSRF coverage there belongs in its own PR.
|
|
1178
1275
|
await assertUrlHostIsPublic(fileURL);
|
|
1179
1276
|
}
|
|
1180
|
-
const response = await
|
|
1277
|
+
const response = await (await loadAxios()).get(fileURL, opts.binary ? FETCH_BINARY_DEFAULTS : undefined);
|
|
1181
1278
|
let data;
|
|
1182
1279
|
if (opts.binary) {
|
|
1183
1280
|
data = Buffer.from(response.data);
|
|
@@ -1280,6 +1377,8 @@ function logLevelEnabled(config, level) {
|
|
|
1280
1377
|
* width clamped up by a hundred-plus pixels) is flagged.
|
|
1281
1378
|
*/
|
|
1282
1379
|
export const VIEWPORT_TOLERANCE_PX = 16;
|
|
1380
|
+
/** How long realizeViewport waits for a resize to show up in the page. */
|
|
1381
|
+
const VIEWPORT_SETTLE_MS = 2000;
|
|
1283
1382
|
/**
|
|
1284
1383
|
* Compare a requested browser viewport against the viewport the page actually
|
|
1285
1384
|
* rendered (window.innerWidth/innerHeight read back after a resize) and produce
|
|
@@ -1314,7 +1413,7 @@ export function viewportMismatchWarning(requested, actual, tolerance = 0) {
|
|
|
1314
1413
|
}
|
|
1315
1414
|
if (parts.length === 0)
|
|
1316
1415
|
return null;
|
|
1317
|
-
return `Requested viewport not fully realized — the browser or OS
|
|
1416
|
+
return `Requested viewport not fully realized — the browser or OS limits the window size: ${parts.join("; ")}. Screenshots and measurements reflect the rendered size, not the requested size.`;
|
|
1318
1417
|
}
|
|
1319
1418
|
/**
|
|
1320
1419
|
* True when the browser FLOORED a requested viewport — the realized size came
|
|
@@ -1372,7 +1471,7 @@ async function readViewport(driver) {
|
|
|
1372
1471
|
*
|
|
1373
1472
|
* Only meaningful when at least one dimension was requested; callers guard that.
|
|
1374
1473
|
*/
|
|
1375
|
-
export async function realizeViewport(driver, requested, config = {}, label) {
|
|
1474
|
+
export async function realizeViewport(driver, requested, config = {}, label, level = "warning") {
|
|
1376
1475
|
const current = await readViewport(driver);
|
|
1377
1476
|
const target = resolveViewportTarget(requested, current);
|
|
1378
1477
|
const windowSize = await driver.getWindowSize();
|
|
@@ -1386,13 +1485,88 @@ export async function realizeViewport(driver, requested, config = {}, label) {
|
|
|
1386
1485
|
? target.height - current.height
|
|
1387
1486
|
: 0;
|
|
1388
1487
|
await driver.setWindowSize(Math.round(windowSize.width + deltaWidth), Math.round(windowSize.height + deltaHeight));
|
|
1389
|
-
|
|
1488
|
+
// Some engines report the old viewport for a moment after a resize (headless
|
|
1489
|
+
// Chrome on Windows CI). Poll until the target lands or the size stops
|
|
1490
|
+
// changing; a stale read would otherwise skew the next resize's delta and
|
|
1491
|
+
// raise a false floor warning.
|
|
1492
|
+
let actual = await readViewport(driver);
|
|
1493
|
+
let previous;
|
|
1494
|
+
const deadline = Date.now() + VIEWPORT_SETTLE_MS;
|
|
1495
|
+
while (viewportMismatchWarning(target, actual, VIEWPORT_TOLERANCE_PX) &&
|
|
1496
|
+
!(previous &&
|
|
1497
|
+
previous.width === actual.width &&
|
|
1498
|
+
previous.height === actual.height &&
|
|
1499
|
+
(actual.width !== current.width || actual.height !== current.height)) &&
|
|
1500
|
+
Date.now() < deadline) {
|
|
1501
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1502
|
+
previous = actual;
|
|
1503
|
+
actual = await readViewport(driver);
|
|
1504
|
+
}
|
|
1390
1505
|
const warning = viewportMismatchWarning(requested, actual, VIEWPORT_TOLERANCE_PX);
|
|
1391
1506
|
if (warning) {
|
|
1392
|
-
await log(config,
|
|
1507
|
+
await log(config, level, label ? `${label}: ${warning}` : warning);
|
|
1393
1508
|
}
|
|
1394
1509
|
return actual;
|
|
1395
1510
|
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Viewport for a desktop browser session nobody sized. Without it the size is
|
|
1513
|
+
* whatever the engine and host pick (headless Chrome renders 758×426 on
|
|
1514
|
+
* Windows, headless Firefox 1366×683), which drifts between runner images.
|
|
1515
|
+
* A viewport rather than a window: the window-to-viewport offset is the part
|
|
1516
|
+
* that drifts. See ADR 01144.
|
|
1517
|
+
*/
|
|
1518
|
+
export const DEFAULT_VIEWPORT = { width: 1200, height: 800 };
|
|
1519
|
+
// Only positive dimensions count: the schema doesn't floor these, and a
|
|
1520
|
+
// 0/negative/NaN value must not reach setWindowSize.
|
|
1521
|
+
function positiveDimensions(dims) {
|
|
1522
|
+
const width = Number(dims?.width);
|
|
1523
|
+
const height = Number(dims?.height);
|
|
1524
|
+
if (!(width > 0) && !(height > 0))
|
|
1525
|
+
return undefined;
|
|
1526
|
+
return {
|
|
1527
|
+
...(width > 0 ? { width } : {}),
|
|
1528
|
+
...(height > 0 ? { height } : {}),
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Size a freshly opened desktop browser session: an authored viewport wins,
|
|
1533
|
+
* then an authored window, then DEFAULT_VIEWPORT. Records the choice as
|
|
1534
|
+
* `driver.state.sizeTarget` and returns it, plus the viewport that rendered
|
|
1535
|
+
* when one was realized.
|
|
1536
|
+
*/
|
|
1537
|
+
export async function applyBrowserSize(driver, authored, config = {}, label) {
|
|
1538
|
+
const viewport = positiveDimensions(authored?.viewport);
|
|
1539
|
+
const windowDims = positiveDimensions(authored?.window);
|
|
1540
|
+
let target;
|
|
1541
|
+
let realized;
|
|
1542
|
+
if (viewport) {
|
|
1543
|
+
target = { kind: "viewport", ...viewport };
|
|
1544
|
+
realized = await realizeViewport(driver, viewport, config, label);
|
|
1545
|
+
}
|
|
1546
|
+
else if (windowDims) {
|
|
1547
|
+
target = { kind: "window", ...windowDims };
|
|
1548
|
+
const current = await driver.getWindowSize();
|
|
1549
|
+
await driver.setWindowSize(windowDims.width ?? current.width, windowDims.height ?? current.height);
|
|
1550
|
+
}
|
|
1551
|
+
else {
|
|
1552
|
+
target = { kind: "default", ...DEFAULT_VIEWPORT };
|
|
1553
|
+
// Nobody asked for this size, so a host too small to fit it (a headed
|
|
1554
|
+
// desktop on a small display) isn't worth a warning, and a session that
|
|
1555
|
+
// can't be resized at all still runs at the size it opened with.
|
|
1556
|
+
try {
|
|
1557
|
+
realized = await realizeViewport(driver, DEFAULT_VIEWPORT, config, label, "debug");
|
|
1558
|
+
}
|
|
1559
|
+
catch (error) {
|
|
1560
|
+
await log(config, "debug", `Couldn't apply the default ${DEFAULT_VIEWPORT.width}×${DEFAULT_VIEWPORT.height} viewport: ${error?.message ?? error}`);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
driver.state = driver.state ?? {};
|
|
1564
|
+
driver.state.sizeTarget = target;
|
|
1565
|
+
// The target belongs to the window that was sized, not to popups the page
|
|
1566
|
+
// opens later in the same session.
|
|
1567
|
+
driver.state.sizeTargetHandle = await driver.getWindowHandle?.();
|
|
1568
|
+
return { target, realized };
|
|
1569
|
+
}
|
|
1396
1570
|
async function log(config, level, message) {
|
|
1397
1571
|
if (message === undefined) {
|
|
1398
1572
|
// 2-arg form: log(message, level)
|
|
@@ -1558,6 +1732,365 @@ function replaceEnvs(stringOrObject) {
|
|
|
1558
1732
|
}
|
|
1559
1733
|
return stringOrObject;
|
|
1560
1734
|
}
|
|
1735
|
+
// ===========================================================================
|
|
1736
|
+
// Verification metadata write-back. After a run, every page that lists a
|
|
1737
|
+
// `verified-by` entry for Doc Detective gets that entry's result written back
|
|
1738
|
+
// when its check passed. src/core/verification.ts holds the rules, and
|
|
1739
|
+
// adrs/01140-last-verified-on-marker-writeback.md records them.
|
|
1740
|
+
// ===========================================================================
|
|
1741
|
+
// Map a file extension to the doc format whose comment syntax we use. `.mdx`
|
|
1742
|
+
// shares the markdown grammar (and uses the JSX-comment variant).
|
|
1743
|
+
const VERIFIED_FORMAT_BY_EXT = {
|
|
1744
|
+
md: "markdown",
|
|
1745
|
+
markdown: "markdown",
|
|
1746
|
+
mdx: "markdown",
|
|
1747
|
+
adoc: "asciidoc",
|
|
1748
|
+
asciidoc: "asciidoc",
|
|
1749
|
+
asc: "asciidoc",
|
|
1750
|
+
html: "html",
|
|
1751
|
+
htm: "html",
|
|
1752
|
+
dita: "dita",
|
|
1753
|
+
ditamap: "dita",
|
|
1754
|
+
// `.xml` is intentionally omitted: it's a generic extension (POMs, SVGs, feeds,
|
|
1755
|
+
// Spring configs), and treating every .xml as DITA would put unrelated files on
|
|
1756
|
+
// the write-back scan path. DITA authored as `.xml` is uncommon; use `.dita`.
|
|
1757
|
+
};
|
|
1758
|
+
// Body of an existing DD-managed shields.io image, per format, so a badge
|
|
1759
|
+
// writer can find and replace its own image line on re-runs. Anchored to the
|
|
1760
|
+
// `Last_verified-` label shieldsBadge writes, so an author's other shields.io
|
|
1761
|
+
// badge on that line is never taken for ours.
|
|
1762
|
+
const VERIFIED_IMG_BODY = {
|
|
1763
|
+
markdown: "!\\[[^\\]]*\\]\\([^)]*img\\.shields\\.io/badge/Last_verified-[^)]*\\)",
|
|
1764
|
+
html: "<img[^>]*img\\.shields\\.io/badge/Last_verified-[^>]*>",
|
|
1765
|
+
asciidoc: "image:\\S*img\\.shields\\.io/badge/Last_verified-\\S*\\[[^\\]]*\\]",
|
|
1766
|
+
dita: "<image[^>]*img\\.shields\\.io/badge/Last_verified-[\\s\\S]*?</image>",
|
|
1767
|
+
};
|
|
1768
|
+
// Filesystem-/badge-friendly calendar date, `YYYY-MM-DD` in UTC (deterministic
|
|
1769
|
+
// across runners/timezones), e.g. `2026-06-26`.
|
|
1770
|
+
function verifiedDate(d = new Date()) {
|
|
1771
|
+
const y = d.getUTCFullYear();
|
|
1772
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
1773
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
1774
|
+
return `${y}-${m}-${day}`;
|
|
1775
|
+
}
|
|
1776
|
+
// Escape one shields.io URL path segment: `_`→`__`, `-`→`--`, ` `→`_`. The
|
|
1777
|
+
// dash-doubling is the easy-to-get-wrong bit (a literal `-` is otherwise the
|
|
1778
|
+
// label/message/color separator), so `2026-06-26` → `2026--06--26`.
|
|
1779
|
+
function shieldsEscape(segment) {
|
|
1780
|
+
return segment.replace(/_/g, "__").replace(/-/g, "--").replace(/ /g, "_");
|
|
1781
|
+
}
|
|
1782
|
+
// Build a shields.io static badge, wrapped in the format's native image syntax.
|
|
1783
|
+
// DD only writes the URL; shields.io renders it client-side (no network call).
|
|
1784
|
+
function shieldsBadge(format, date, { label = "Last verified", color = "brightgreen" } = {}) {
|
|
1785
|
+
const url = `https://img.shields.io/badge/${shieldsEscape(label)}-${shieldsEscape(date)}-${shieldsEscape(color)}`;
|
|
1786
|
+
const alt = `${label} ${date}`;
|
|
1787
|
+
switch (format) {
|
|
1788
|
+
case "asciidoc":
|
|
1789
|
+
return `image:${url}[${alt}]`;
|
|
1790
|
+
case "html":
|
|
1791
|
+
return `<img src="${url}" alt="${alt}">`;
|
|
1792
|
+
case "dita":
|
|
1793
|
+
return `<image href="${url}"><alt>${alt}</alt></image>`;
|
|
1794
|
+
default:
|
|
1795
|
+
return ``;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
// Replace a source document in one step: write a sibling temp file, then rename
|
|
1799
|
+
// it over the real target, so an interrupted write never leaves the document
|
|
1800
|
+
// half-written. The target is resolved first, so a symlink stays a symlink,
|
|
1801
|
+
// and its permission bits carry over. On any failure the temp file goes and
|
|
1802
|
+
// the document is untouched.
|
|
1803
|
+
function writeFileAtomic(file, content) {
|
|
1804
|
+
const target = fs.realpathSync(file);
|
|
1805
|
+
const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
1806
|
+
// Created exclusively first, so an EEXIST throws before we own the file and
|
|
1807
|
+
// cleanup can never remove someone else's.
|
|
1808
|
+
const fd = fs.openSync(tmp, "wx", 0o600); // private until it takes the target's mode
|
|
1809
|
+
let keepTmp = false;
|
|
1810
|
+
try {
|
|
1811
|
+
try {
|
|
1812
|
+
fs.writeFileSync(fd, content, "utf8");
|
|
1813
|
+
}
|
|
1814
|
+
finally {
|
|
1815
|
+
fs.closeSync(fd);
|
|
1816
|
+
}
|
|
1817
|
+
fs.chmodSync(tmp, fs.statSync(target).mode & 0o7777);
|
|
1818
|
+
try {
|
|
1819
|
+
fs.renameSync(tmp, target);
|
|
1820
|
+
}
|
|
1821
|
+
catch (error) {
|
|
1822
|
+
// Windows refuses to replace a file another process holds open (an
|
|
1823
|
+
// editor, a preview). Fall back to an in-place copy, which is no worse
|
|
1824
|
+
// than a plain write, rather than skip the document.
|
|
1825
|
+
if (!["EPERM", "EBUSY", "EACCES"].includes(error?.code))
|
|
1826
|
+
throw error;
|
|
1827
|
+
try {
|
|
1828
|
+
fs.copyFileSync(tmp, target);
|
|
1829
|
+
}
|
|
1830
|
+
catch (copyError) {
|
|
1831
|
+
// The copy truncates the document first, so a failure partway can
|
|
1832
|
+
// leave it incomplete. Keep the temp file, the one complete copy.
|
|
1833
|
+
keepTmp = true;
|
|
1834
|
+
throw new Error(`${copyError?.message ?? copyError} (the complete content is kept in ${tmp})`);
|
|
1835
|
+
}
|
|
1836
|
+
fs.rmSync(tmp, { force: true });
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
catch (error) {
|
|
1840
|
+
if (!keepTmp)
|
|
1841
|
+
fs.rmSync(tmp, { force: true });
|
|
1842
|
+
throw error;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
// Resolve a check against a run report: specs with a matching specId, else
|
|
1846
|
+
// tests with a matching testId. A routed revisit (goToTest) reports a unit
|
|
1847
|
+
// more than once, so it resolves to PASS only if every match passed, and to
|
|
1848
|
+
// the first other result otherwise. Null when nothing matches.
|
|
1849
|
+
function resolveVerifiedId(results, id) {
|
|
1850
|
+
if (!results || !Array.isArray(results.specs))
|
|
1851
|
+
return null;
|
|
1852
|
+
const specs = results.specs.filter((spec) => spec?.specId === id);
|
|
1853
|
+
const matches = specs.length
|
|
1854
|
+
? specs
|
|
1855
|
+
: results.specs.flatMap((spec) => (spec?.tests || []).filter((test) => test?.testId === id));
|
|
1856
|
+
if (matches.length === 0)
|
|
1857
|
+
return null;
|
|
1858
|
+
const failed = matches.find((unit) => unit.result !== "PASS");
|
|
1859
|
+
return failed ? (failed.result ?? null) : "PASS";
|
|
1860
|
+
}
|
|
1861
|
+
// Post-run write-back: for every candidate source file, fill Doc Detective's
|
|
1862
|
+
// `verified-by` entries whose check PASSED, in front matter and in inline
|
|
1863
|
+
// `meta` statements. Unknown checks warn and are skipped; other results write
|
|
1864
|
+
// nothing. Per-file failures warn and continue. Never throws.
|
|
1865
|
+
//
|
|
1866
|
+
// The candidate set is the union of the report's content paths and the full
|
|
1867
|
+
// detected input set (`files`), so a prose-only page that names a check but
|
|
1868
|
+
// contributes no spec is still updated.
|
|
1869
|
+
//
|
|
1870
|
+
// With `init` (`verification.write: true`), a Markdown or MDX page whose spec
|
|
1871
|
+
// PASSED is first given a `verified-by` entry naming that spec, if it has no
|
|
1872
|
+
// Doc Detective entry yet (declarePage), so the fill completes it.
|
|
1873
|
+
function applyVerifiedMarkers({ config, results, files: detectedFiles = [], init = false, }) {
|
|
1874
|
+
if (!results || !Array.isArray(results.specs))
|
|
1875
|
+
return;
|
|
1876
|
+
// contentPath -> the passing spec a page declares under `init`.
|
|
1877
|
+
const passingSpecs = new Map();
|
|
1878
|
+
if (init) {
|
|
1879
|
+
for (const spec of results.specs) {
|
|
1880
|
+
const ext = (String(spec?.contentPath ?? "").split(".").pop() || "").toLowerCase();
|
|
1881
|
+
if (VERIFIED_FORMAT_BY_EXT[ext] !== "markdown" || typeof spec.specId !== "string")
|
|
1882
|
+
continue;
|
|
1883
|
+
if (resolveVerifiedId(results, spec.specId) === "PASS")
|
|
1884
|
+
passingSpecs.set(spec.contentPath, spec.specId);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
const files = new Set();
|
|
1888
|
+
for (const file of detectedFiles) {
|
|
1889
|
+
if (file)
|
|
1890
|
+
files.add(file);
|
|
1891
|
+
}
|
|
1892
|
+
for (const spec of results.specs) {
|
|
1893
|
+
if (spec?.contentPath)
|
|
1894
|
+
files.add(spec.contentPath);
|
|
1895
|
+
for (const test of spec?.tests || []) {
|
|
1896
|
+
if (test?.contentPath)
|
|
1897
|
+
files.add(test.contentPath);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
const today = verifiedDate();
|
|
1901
|
+
const version = createRequire(import.meta.url)("../../package.json").version;
|
|
1902
|
+
for (const file of files) {
|
|
1903
|
+
try {
|
|
1904
|
+
if (!fs.existsSync(file))
|
|
1905
|
+
continue;
|
|
1906
|
+
const ext = (file.split(".").pop() || "").toLowerCase();
|
|
1907
|
+
const format = VERIFIED_FORMAT_BY_EXT[ext];
|
|
1908
|
+
if (!format)
|
|
1909
|
+
continue;
|
|
1910
|
+
const original = fs.readFileSync(file, "utf8");
|
|
1911
|
+
const specId = passingSpecs.get(file);
|
|
1912
|
+
const declared = specId === undefined ? original : declarePage(original, specId);
|
|
1913
|
+
// Cheap fast-path: every entry Doc Detective fills names it, so a file
|
|
1914
|
+
// without the literal "doc-detective" has nothing to update.
|
|
1915
|
+
if (!declared.includes(VERIFIED_TOOL))
|
|
1916
|
+
continue;
|
|
1917
|
+
const content = applyVerification(declared, format, {
|
|
1918
|
+
today,
|
|
1919
|
+
version,
|
|
1920
|
+
resolve: (check) => resolveVerifiedId(results, check),
|
|
1921
|
+
onUnknown: (check) => log(config, "warning", `verified-by check '${check}' in ${file} matches no test or spec; skipping.`),
|
|
1922
|
+
onSkipped: (line, reason) => log(config, "warning", `Skipping the meta statement at ${file}:${line}: ${reason}.`),
|
|
1923
|
+
});
|
|
1924
|
+
if (content !== original)
|
|
1925
|
+
writeFileAtomic(file, content);
|
|
1926
|
+
}
|
|
1927
|
+
catch (error) {
|
|
1928
|
+
log(config, "warning", `Failed to update verified-by entries in ${file}: ${error?.message ?? error}`);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
// A `test` statement with `testId` added to its options, in its own comment
|
|
1933
|
+
// form, or undefined when its options aren't absent or one JSON object, the
|
|
1934
|
+
// only shapes it can edit without re-serializing the author's statement.
|
|
1935
|
+
function withTestId(statement, id) {
|
|
1936
|
+
const m = /^([\s\S]*?\btest\b)([\s\S]*?)(\s*(?:-->|\*\/\}|\)|\?>))$/.exec(statement);
|
|
1937
|
+
if (!m)
|
|
1938
|
+
return undefined;
|
|
1939
|
+
const [, head, options, close] = m;
|
|
1940
|
+
const field = `"testId": ${JSON.stringify(id)}`;
|
|
1941
|
+
if (options.trim() === "")
|
|
1942
|
+
return `${head} {${field}}${close}`;
|
|
1943
|
+
try {
|
|
1944
|
+
const parsed = JSON.parse(options);
|
|
1945
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || "testId" in parsed)
|
|
1946
|
+
return undefined;
|
|
1947
|
+
}
|
|
1948
|
+
catch {
|
|
1949
|
+
return undefined;
|
|
1950
|
+
}
|
|
1951
|
+
const open = options.indexOf("{");
|
|
1952
|
+
const rest = options.slice(open + 1).trimStart();
|
|
1953
|
+
return `${head}${options.slice(0, open + 1)}${field}${rest.startsWith("}") ? "" : ", "}${rest}${close}`;
|
|
1954
|
+
}
|
|
1955
|
+
// Whether `statement` sets `id` as its testId (`"testId": "id"`, `testId: id`,
|
|
1956
|
+
// or `testId="id"`), not merely mentions it, or a longer id that starts with it.
|
|
1957
|
+
function setsTestId(statement, id) {
|
|
1958
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1959
|
+
return new RegExp(`testId["']?\\s*[:=]\\s*["']?${escaped}(?=["'\\s,}>)]|$)`).test(statement);
|
|
1960
|
+
}
|
|
1961
|
+
// A `meta` statement declaring `checks` for Doc Detective, in the format's
|
|
1962
|
+
// comment form, or undefined when the payload can't be written in that form.
|
|
1963
|
+
function declaringStatement(format, ext, checks) {
|
|
1964
|
+
const payload = JSON.stringify({ "verified-by": checks.map((check) => ({ tool: "doc-detective", check })) });
|
|
1965
|
+
if (format === "dita")
|
|
1966
|
+
return `<?manni meta ${payload}?>`;
|
|
1967
|
+
if (format === "asciidoc")
|
|
1968
|
+
return payload.includes(")") ? undefined : `// (meta ${payload})`;
|
|
1969
|
+
return ext === "mdx" ? `{/* meta ${payload} */}` : `<!-- meta ${payload} -->`;
|
|
1970
|
+
}
|
|
1971
|
+
// Badge-anchored test verification: `badge: true` on an inline `test` statement, resolved by report-carried (contentPath, location.line) — no authored testId. See ADR 01141.
|
|
1972
|
+
// With `init` (`verification.write: true`), a passing inline test whose testId
|
|
1973
|
+
// is authored (written in its own test statement; a generated one never is) gets a `meta`
|
|
1974
|
+
// statement declaring it above its statement and badge, unless one already
|
|
1975
|
+
// does. The verification write-back then fills and pins it. See ADR 01140.
|
|
1976
|
+
// A statement is independent of the badge: a test without `badge: true`
|
|
1977
|
+
// still gets one.
|
|
1978
|
+
function applyBadgeAnchoredTests({ config, results, init = false }) {
|
|
1979
|
+
if (!results || !Array.isArray(results.specs))
|
|
1980
|
+
return;
|
|
1981
|
+
const byFile = new Map();
|
|
1982
|
+
const merge = (prev, next) => (prev === undefined || prev === "PASS" ? next : prev);
|
|
1983
|
+
for (const spec of results.specs) {
|
|
1984
|
+
for (const test of spec?.tests || []) {
|
|
1985
|
+
if (test?.badge !== true && !init)
|
|
1986
|
+
continue;
|
|
1987
|
+
if (!test?.contentPath)
|
|
1988
|
+
continue;
|
|
1989
|
+
if (!test?.location?.line) {
|
|
1990
|
+
if (test.badge === true) {
|
|
1991
|
+
log(config, "warning", `Test '${test.testId ?? "(unnamed)"}' in ${test.contentPath} has \`badge: true\` but no location — badges only apply to tests detected from an inline test statement, not JSON/YAML spec tests; skipping.`);
|
|
1992
|
+
}
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1995
|
+
const byLine = byFile.get(test.contentPath) || new Map();
|
|
1996
|
+
const work = byLine.get(test.location.line) || { checks: new Map() };
|
|
1997
|
+
if (test.badge === true)
|
|
1998
|
+
work.badge = merge(work.badge, test.result);
|
|
1999
|
+
if (init && typeof test.testId === "string") {
|
|
2000
|
+
const { startIndex: start = 0, endIndex: end = 0 } = test.location;
|
|
2001
|
+
const result = merge(work.checks.get(test.testId)?.result, test.result);
|
|
2002
|
+
work.checks.set(test.testId, { result, start, end });
|
|
2003
|
+
}
|
|
2004
|
+
byLine.set(test.location.line, work);
|
|
2005
|
+
byFile.set(test.contentPath, byLine);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
const today = verifiedDate();
|
|
2009
|
+
for (const [file, byLine] of byFile) {
|
|
2010
|
+
try {
|
|
2011
|
+
if (!fs.existsSync(file))
|
|
2012
|
+
continue;
|
|
2013
|
+
const ext = (file.split(".").pop() || "").toLowerCase();
|
|
2014
|
+
const format = VERIFIED_FORMAT_BY_EXT[ext];
|
|
2015
|
+
if (!format)
|
|
2016
|
+
continue;
|
|
2017
|
+
const original = fs.readFileSync(file, "utf8");
|
|
2018
|
+
const eol = original.includes("\r\n") ? "\r\n" : "\n";
|
|
2019
|
+
const lines = original.split(/\r?\n/);
|
|
2020
|
+
const body = VERIFIED_IMG_BODY[format] || VERIFIED_IMG_BODY.markdown;
|
|
2021
|
+
const existingImgRe = new RegExp(`^([ \\t]*)(?:${body})[ \\t]*$`);
|
|
2022
|
+
// A check some statement in the file already declares. Reading `original`,
|
|
2023
|
+
// not `lines`, is deliberate: a goToTest revisit reruns the same test and
|
|
2024
|
+
// reports its location, so one id reaches one line. Two authored tests
|
|
2025
|
+
// sharing an id on different lines each get their own statement.
|
|
2026
|
+
const declared = (id) => new RegExp(`"check"\\s*:\\s*${JSON.stringify(id).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(original);
|
|
2027
|
+
let changed = false;
|
|
2028
|
+
// Bottom-to-top so an insertion doesn't shift indices still to process.
|
|
2029
|
+
for (const [line, work] of [...byLine].sort((a, b) => b[0] - a[0])) {
|
|
2030
|
+
let li = line - 1; // 0-indexed into `lines`: the test statement
|
|
2031
|
+
if (li < 0 || li >= lines.length)
|
|
2032
|
+
continue; // stale location vs. current file
|
|
2033
|
+
const indent = (lines[li].match(/^[ \t]*/) || [""])[0];
|
|
2034
|
+
// Freeze a passing test's generated id: write it into the test's own
|
|
2035
|
+
// statement, where it counts as authored from then on. A generated id
|
|
2036
|
+
// changes whenever its test does, so a statement naming it would break.
|
|
2037
|
+
// Right to left, so two statements on one line keep their positions. A
|
|
2038
|
+
// statement can span lines; adding the id never adds one, so every
|
|
2039
|
+
// line index this pass still uses stays valid.
|
|
2040
|
+
const frozen = new Set();
|
|
2041
|
+
for (const [id, c] of [...work.checks].sort((a, b) => b[1].start - a[1].start)) {
|
|
2042
|
+
const text = original.slice(c.start, c.end).replace(/\r\n/g, "\n");
|
|
2043
|
+
if (c.result !== "PASS" || setsTestId(text, id))
|
|
2044
|
+
continue;
|
|
2045
|
+
const span = text.split("\n").length;
|
|
2046
|
+
const block = lines.slice(li, li + span).join("\n");
|
|
2047
|
+
const at = block.indexOf(text);
|
|
2048
|
+
const next = at === -1 ? undefined : withTestId(text, id);
|
|
2049
|
+
if (next === undefined) {
|
|
2050
|
+
log(config, "warning", `Can't freeze the generated testId '${id}' in ${file}: its test statement's options aren't absent or one JSON object. Add a testId to the statement by hand.`);
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
lines.splice(li, span, ...(block.slice(0, at) + next + block.slice(at + text.length)).split("\n"));
|
|
2054
|
+
frozen.add(id);
|
|
2055
|
+
changed = true;
|
|
2056
|
+
}
|
|
2057
|
+
// Non-pass: leave the existing badge to age.
|
|
2058
|
+
if (work.badge === "PASS") {
|
|
2059
|
+
const img = shieldsBadge(format, today);
|
|
2060
|
+
const existing = li > 0 ? lines[li - 1].match(existingImgRe) : null;
|
|
2061
|
+
if (existing) {
|
|
2062
|
+
if (lines[li - 1] !== `${existing[1]}${img}`) {
|
|
2063
|
+
lines[li - 1] = `${existing[1]}${img}`;
|
|
2064
|
+
changed = true;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
else {
|
|
2068
|
+
lines.splice(li, 0, `${indent}${img}`);
|
|
2069
|
+
li++;
|
|
2070
|
+
changed = true;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
const checks = [...work.checks]
|
|
2074
|
+
.filter(([id, c]) => c.result === "PASS" && (frozen.has(id) || setsTestId(original.slice(c.start, c.end), id)) && !declared(id))
|
|
2075
|
+
.map(([id]) => id);
|
|
2076
|
+
const statement = checks.length > 0 ? declaringStatement(format, ext, checks) : undefined;
|
|
2077
|
+
if (checks.length > 0 && !statement) {
|
|
2078
|
+
log(config, "warning", `Can't declare ${checks.map((c) => `'${c}'`).join(", ")} in ${file}: an AsciiDoc \`// ( )\` statement ends at the first ")", so no statement can name it. Rename the testId without ")".`);
|
|
2079
|
+
}
|
|
2080
|
+
if (statement) {
|
|
2081
|
+
const top = li > 0 && existingImgRe.test(lines[li - 1]) ? li - 1 : li;
|
|
2082
|
+
lines.splice(top, 0, `${format === "asciidoc" ? "" : indent}${statement}`);
|
|
2083
|
+
changed = true;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
if (changed)
|
|
2087
|
+
writeFileAtomic(file, lines.join(eol));
|
|
2088
|
+
}
|
|
2089
|
+
catch (error) {
|
|
2090
|
+
log(config, "warning", `Failed to update badge-anchored tests in ${file}: ${error?.message ?? error}`);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
1561
2094
|
// Filesystem-safe instant token, matching the `doc-detective debug` dump's
|
|
1562
2095
|
// file naming (`debug-<timestamp>`): an ISO-8601 string with the `:` and `.`
|
|
1563
2096
|
// (illegal/awkward in filenames, notably on Windows) replaced by `-`, e.g.
|