@stacksjs/defaults 0.75.361 → 0.75.362
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/ai/skills/stacks-ai/SKILL.md +22 -0
- package/ai/skills/stacks-native/CATALOG.md +2 -2
- package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
- package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
- package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
- package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
- package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
- package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
- package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
- package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
- package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
- package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
- package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
- package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
- package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
- package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
- package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
- package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
- package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
- package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
- package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
- package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
- package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
- package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
- package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
- package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
- package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
- package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
- package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
- package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
- package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
- package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
- package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
- package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
- package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
- package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
- package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
- package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
- package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
- package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
- package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
- package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
- package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
- package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
- package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
- package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
- package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
- package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
- package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
- package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
- package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
- package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
- package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
- package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
- package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { softwareNoticesHtml } from '../renderers/shared/software-notices.mjs';
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import { isExplainerHtml } from '../explainer/check.mjs';
|
|
4
|
+
import { EXPLAINER_VIEWPORTS, inspectExplainerBrowser, explainerObservation, explainerObservationDiagnostics } from '../explainer/browser-check.mjs';
|
|
5
|
+
import { createOwnedTempDirectory } from '../renderers/shared/owned-temp-directory.mjs';
|
|
2
6
|
import { createHash } from 'node:crypto';
|
|
3
7
|
import fs from 'node:fs';
|
|
4
8
|
import os from 'node:os';
|
|
@@ -8,6 +12,27 @@ import {
|
|
|
8
12
|
DESKTOP_READABILITY_VIEWPORT,
|
|
9
13
|
MIN_PROJECTED_NODE_TEXT_PX,
|
|
10
14
|
} from '../renderers/shared/desktop-readability.mjs';
|
|
15
|
+
import {
|
|
16
|
+
backupPublicRegularFileBinding,
|
|
17
|
+
captureAtomicOutput,
|
|
18
|
+
captureRegularFileBinding,
|
|
19
|
+
quarantineRemoveLinkedRegularFileAlias,
|
|
20
|
+
quarantineRemoveRegularFileBinding,
|
|
21
|
+
releaseRegularFileBinding,
|
|
22
|
+
removeEmptyDirectoryWithRetry,
|
|
23
|
+
verifyAtomicOutput,
|
|
24
|
+
verifyRegularFileBinding,
|
|
25
|
+
} from '../renderers/shared/atomic-output.mjs';
|
|
26
|
+
import {
|
|
27
|
+
sameEntry,
|
|
28
|
+
sameLocation,
|
|
29
|
+
sameParent,
|
|
30
|
+
sidecarNamespaceComponentKey,
|
|
31
|
+
} from '../renderers/shared/path-semantics.mjs';
|
|
32
|
+
import {
|
|
33
|
+
boundedSidecarStem,
|
|
34
|
+
sidecarStemFromComponent,
|
|
35
|
+
} from '../renderers/shared/sidecar-path.mjs';
|
|
11
36
|
|
|
12
37
|
export const VISUAL_CHECK_VIEWPORTS = Object.freeze([
|
|
13
38
|
DESKTOP_READABILITY_VIEWPORT,
|
|
@@ -16,13 +41,22 @@ export const VISUAL_CHECK_VIEWPORTS = Object.freeze([
|
|
|
16
41
|
Object.freeze({ width: 2048, height: 1320 }),
|
|
17
42
|
]);
|
|
18
43
|
|
|
19
|
-
const CAPTURE_VIEWPORTS = Object.freeze([
|
|
44
|
+
export const CAPTURE_VIEWPORTS = Object.freeze([
|
|
20
45
|
VISUAL_CHECK_VIEWPORTS[0],
|
|
21
46
|
VISUAL_CHECK_VIEWPORTS[VISUAL_CHECK_VIEWPORTS.length - 1],
|
|
22
47
|
]);
|
|
23
|
-
const THEMES = Object.freeze(['light', 'dark']);
|
|
48
|
+
export const THEMES = Object.freeze(['light', 'dark']);
|
|
24
49
|
const EXIT = Object.freeze({ pass: 0, fail: 1, skipped: 2 });
|
|
25
50
|
export const CHROME_NO_SANDBOX_ENV = 'ARCHIFY_CHROME_NO_SANDBOX';
|
|
51
|
+
const VISUAL_SIDECAR_SUFFIXES = Object.freeze([
|
|
52
|
+
'.visual-check.json',
|
|
53
|
+
'.visual-check.html',
|
|
54
|
+
...CAPTURE_VIEWPORTS.flatMap(({ width, height }) => THEMES.map(
|
|
55
|
+
(theme) => `.visual-check.${width}x${height}.${theme}.png`,
|
|
56
|
+
)),
|
|
57
|
+
]);
|
|
58
|
+
const visualEvidenceOwnerships = new WeakSet();
|
|
59
|
+
export const CHROME_STARTUP_TIMEOUT_MS = 90000;
|
|
26
60
|
|
|
27
61
|
function sha256(buffer) {
|
|
28
62
|
return createHash('sha256').update(buffer).digest('hex');
|
|
@@ -34,49 +68,1220 @@ function htmlEscape(value) {
|
|
|
34
68
|
})[char]);
|
|
35
69
|
}
|
|
36
70
|
|
|
37
|
-
function
|
|
71
|
+
function pathEntry(file) {
|
|
38
72
|
try {
|
|
39
|
-
|
|
40
|
-
} catch {
|
|
41
|
-
|
|
73
|
+
return { exists: true, stat: fs.lstatSync(file) };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code === 'ENOENT') return { exists: false };
|
|
76
|
+
return { exists: null, error };
|
|
42
77
|
}
|
|
43
78
|
}
|
|
44
79
|
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
function visualEvidencePaths(outputs) {
|
|
81
|
+
return outputs.capture === false ? [] : [outputs.contactSheet, ...outputs.screenshots.map((entry) => entry.path)];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function regularFileEvidence(file, { expectedLinks = 1 } = {}) {
|
|
85
|
+
const captured = captureRegularFileBinding(file, {
|
|
86
|
+
subject: 'evidence-file',
|
|
87
|
+
expectedLinks,
|
|
88
|
+
});
|
|
89
|
+
if (captured.status !== 'captured') {
|
|
90
|
+
const systemCode = captured.reason?.systemCode;
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: systemCode === 'ENOENT'
|
|
94
|
+
? { code: 'missing-evidence-file' }
|
|
95
|
+
: captured.reason?.code === 'evidence-file-not-regular-file'
|
|
96
|
+
? { code: 'evidence-path-not-regular-file' }
|
|
97
|
+
: {
|
|
98
|
+
code: 'evidence-file-unreadable',
|
|
99
|
+
...(systemCode ? { systemCode } : {}),
|
|
100
|
+
...(captured.reason?.code ? { bindingCode: captured.reason.code } : {}),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
52
103
|
}
|
|
104
|
+
const released = releaseRegularFileBinding(captured.binding);
|
|
105
|
+
if (released.status !== 'released') {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: {
|
|
109
|
+
code: 'evidence-file-unreadable',
|
|
110
|
+
...(released.reason?.systemCode ? { systemCode: released.reason.systemCode } : {}),
|
|
111
|
+
...(released.reason?.code ? { bindingCode: released.reason.code } : {}),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
evidence: {
|
|
118
|
+
file: path.basename(file),
|
|
119
|
+
sha256: captured.content.sha256,
|
|
120
|
+
bytes: captured.content.bytes,
|
|
121
|
+
},
|
|
122
|
+
identity: captured.identity,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readRegularFileContents(file, { subject = 'file' } = {}) {
|
|
127
|
+
const captured = captureRegularFileBinding(file, {
|
|
128
|
+
subject,
|
|
129
|
+
expectedLinks: 1,
|
|
130
|
+
includeContent: true,
|
|
131
|
+
});
|
|
132
|
+
if (captured.status !== 'captured') return { ok: false, reason: captured.reason };
|
|
133
|
+
const released = releaseRegularFileBinding(captured.binding);
|
|
134
|
+
if (released.status !== 'released') return { ok: false, reason: released.reason };
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
bytes: captured.content.buffer,
|
|
138
|
+
evidence: {
|
|
139
|
+
sha256: captured.content.sha256,
|
|
140
|
+
bytes: captured.content.bytes,
|
|
141
|
+
},
|
|
142
|
+
identity: captured.identity,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function regularFileCaptureError(label, captured) {
|
|
147
|
+
const reason = captured?.reason || { code: 'regular-file-capture-failed' };
|
|
148
|
+
const error = new Error(`${label} could not be read safely (${reason.code}).`);
|
|
149
|
+
if (reason.systemCode) error.code = reason.systemCode;
|
|
150
|
+
error.evidenceReason = reason;
|
|
151
|
+
return error;
|
|
53
152
|
}
|
|
54
153
|
|
|
55
154
|
function screenshotKey(width, height, theme) {
|
|
56
155
|
return `${width}x${height}:${theme}`;
|
|
57
156
|
}
|
|
58
157
|
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
158
|
+
function resolvedSidecarNamespace(artifact, component) {
|
|
159
|
+
const namespace = sidecarNamespaceComponentKey(path.dirname(artifact), component);
|
|
160
|
+
if (namespace.status === 'resolved') return namespace;
|
|
161
|
+
const error = new Error(
|
|
162
|
+
`Could not establish a stable sidecar namespace (${namespace.reason?.code || 'unknown'}).`,
|
|
163
|
+
);
|
|
164
|
+
error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE';
|
|
165
|
+
error.sidecarNamespaceReason = namespace.reason;
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function portableSidecarComponentKey(component) {
|
|
170
|
+
return component
|
|
171
|
+
.normalize('NFC')
|
|
172
|
+
.toLocaleUpperCase('en-US')
|
|
173
|
+
.toLocaleLowerCase('en-US')
|
|
174
|
+
.normalize('NFC');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function targetSidecarNamespace(directory, component) {
|
|
178
|
+
const namespace = sidecarNamespaceComponentKey(directory, component);
|
|
179
|
+
if (namespace.status === 'resolved') return namespace.componentKey;
|
|
180
|
+
if (namespace.reason?.code === 'sidecar-directory-missing') {
|
|
181
|
+
// A pure path calculation must not create --out-dir. Use the portable
|
|
182
|
+
// collision envelope until the directory exists and can be probed.
|
|
183
|
+
return portableSidecarComponentKey(component);
|
|
184
|
+
}
|
|
185
|
+
const error = new Error(
|
|
186
|
+
`Could not establish target sidecar semantics (${namespace.reason?.code || 'unknown'}).`,
|
|
187
|
+
);
|
|
188
|
+
error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE';
|
|
189
|
+
error.sidecarNamespaceReason = namespace.reason;
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function evidenceSidecarPaths(artifactPath, { outDir, command = 'visual-check', capture = true, captureViewports = CAPTURE_VIEWPORTS } = {}) {
|
|
194
|
+
let artifact = path.resolve(artifactPath);
|
|
195
|
+
try {
|
|
196
|
+
// Existing aliases, including Windows 8.3 spellings, must share the
|
|
197
|
+
// physical artifact's evidence namespace even when the stem is short.
|
|
198
|
+
artifact = fs.realpathSync.native(artifact);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (!['ENOENT', 'ENOTDIR', 'ENAMETOOLONG'].includes(error?.code)) throw error;
|
|
201
|
+
// Missing artifacts still need deterministic failure sidecar paths.
|
|
202
|
+
}
|
|
203
|
+
const namespace = resolvedSidecarNamespace(artifact, path.basename(artifact));
|
|
204
|
+
artifact = path.join(namespace.directoryPath, path.basename(artifact));
|
|
205
|
+
const namespaceStem = sidecarStemFromComponent(namespace.componentKey);
|
|
206
|
+
const directory = outDir ? path.resolve(outDir) : path.dirname(artifact);
|
|
207
|
+
const targetComponentKey = outDir
|
|
208
|
+
? targetSidecarNamespace(directory, namespace.componentKey)
|
|
209
|
+
: namespace.componentKey;
|
|
210
|
+
const portableTargetKey = outDir
|
|
211
|
+
? portableSidecarComponentKey(namespace.componentKey)
|
|
212
|
+
: namespace.componentKey;
|
|
213
|
+
const targetAliasesSourceSpelling = targetComponentKey !== namespace.componentKey
|
|
214
|
+
|| portableTargetKey !== namespace.componentKey;
|
|
215
|
+
const stem = boundedSidecarStem(
|
|
216
|
+
namespaceStem.stem,
|
|
217
|
+
capture ? VISUAL_SIDECAR_SUFFIXES : ['.browser-check.json'],
|
|
218
|
+
targetAliasesSourceSpelling ? {
|
|
219
|
+
force: true,
|
|
220
|
+
hashDomain: `${command}-artifact\0${artifact}\0target-component\0${portableTargetKey}`,
|
|
221
|
+
} : namespaceStem.options,
|
|
222
|
+
);
|
|
223
|
+
const base = path.join(directory, `${stem}.${command}`);
|
|
224
|
+
const screenshots = (capture ? captureViewports : []).flatMap(({ width, height }) => THEMES.map((theme) => ({
|
|
64
225
|
width,
|
|
65
226
|
height,
|
|
66
227
|
theme,
|
|
67
228
|
path: `${base}.${width}x${height}.${theme}.png`,
|
|
68
229
|
})));
|
|
69
230
|
return {
|
|
70
|
-
base,
|
|
231
|
+
base, command, capture,
|
|
71
232
|
receipt: `${base}.json`,
|
|
72
233
|
contactSheet: `${base}.html`,
|
|
73
234
|
screenshots,
|
|
74
235
|
};
|
|
75
236
|
}
|
|
76
237
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
238
|
+
export function sidecarPaths(artifactPath, options = {}) {
|
|
239
|
+
return evidenceSidecarPaths(artifactPath, options);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function browserCheckSidecarPaths(artifactPath, options = {}) {
|
|
243
|
+
return evidenceSidecarPaths(artifactPath, { ...options, command: 'browser-check', capture: false });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function evidencePathConflict(artifactPath, outputs, file, reason) {
|
|
247
|
+
const target = file || outputs.receipt;
|
|
248
|
+
const error = `${outputs.command} will not replace the existing unowned evidence path "${target}".`;
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
error,
|
|
252
|
+
diagnostic: failureDiagnostic({
|
|
253
|
+
code: 'viewer/evidence-path-conflict',
|
|
254
|
+
message: error,
|
|
255
|
+
subject: { artifact: path.resolve(artifactPath), evidencePath: target },
|
|
256
|
+
evidence: { reason },
|
|
257
|
+
supportedFixes: [
|
|
258
|
+
`move the conflicting file aside, or choose a separate --out-dir, then rerun ${outputs.command}`,
|
|
259
|
+
],
|
|
260
|
+
}),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function visualEvidenceOwnershipMatches(ownership, artifactPath, outputs) {
|
|
265
|
+
return visualEvidenceOwnerships.has(ownership)
|
|
266
|
+
&& ownership.active
|
|
267
|
+
&& sameEntry(ownership.artifact, artifactPath).status === 'match'
|
|
268
|
+
&& sameLocation(ownership.receipt, outputs.receipt).status === 'match';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function visualEvidenceTargets(outputs) {
|
|
272
|
+
return [
|
|
273
|
+
{ kind: 'receipt', path: outputs.receipt },
|
|
274
|
+
...(outputs.capture === false ? [] : [{ kind: 'contact-sheet', path: outputs.contactSheet }]),
|
|
275
|
+
...outputs.screenshots.map((entry, index) => ({
|
|
276
|
+
kind: 'screenshot',
|
|
277
|
+
index,
|
|
278
|
+
path: entry.path,
|
|
279
|
+
})),
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function evidenceWriteFailure(artifactPath, message, errors) {
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
error: message,
|
|
287
|
+
diagnostic: failureDiagnostic({
|
|
288
|
+
code: 'viewer/evidence-write',
|
|
289
|
+
message,
|
|
290
|
+
subject: { artifact: path.resolve(artifactPath) },
|
|
291
|
+
evidence: { errors },
|
|
292
|
+
supportedFixes: ['restore write access to the evidence directory and rerun visual-check'],
|
|
293
|
+
}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function fileIdentity(stat) {
|
|
298
|
+
return {
|
|
299
|
+
device: stat.dev,
|
|
300
|
+
inode: stat.ino,
|
|
301
|
+
mode: Number(stat.mode & 0o777n),
|
|
302
|
+
links: stat.nlink,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function identityMatches(stat, identity, { directory = false } = {}) {
|
|
307
|
+
return stat.dev === identity.device
|
|
308
|
+
&& stat.ino === identity.inode
|
|
309
|
+
&& Number(stat.mode & 0o777n) === identity.mode
|
|
310
|
+
&& (directory ? stat.isDirectory() : stat.isFile())
|
|
311
|
+
&& !stat.isSymbolicLink();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function evidenceIdentityMatches(actual, expected, expectedLinks = expected?.links ?? 1) {
|
|
315
|
+
return actual?.device === expected?.device
|
|
316
|
+
&& actual?.inode === expected?.inode
|
|
317
|
+
&& actual?.mode === expected?.mode
|
|
318
|
+
&& BigInt(actual?.links ?? 0) === BigInt(expectedLinks);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function stagedOutputs(outputs, stagingDirectory) {
|
|
322
|
+
return {
|
|
323
|
+
receipt: path.join(stagingDirectory, 'receipt.json'),
|
|
324
|
+
contactSheet: path.join(stagingDirectory, 'contact-sheet.html'),
|
|
325
|
+
screenshots: outputs.screenshots.map((entry, index) => ({
|
|
326
|
+
...entry,
|
|
327
|
+
path: path.join(stagingDirectory, `capture-${index}.png`),
|
|
328
|
+
})),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function stagedPathFor(ownership, finalPath) {
|
|
333
|
+
return ownership.stagingPaths.get(finalPath);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function registerStagedEvidence(ownership, finalPath) {
|
|
337
|
+
const stagedPath = stagedPathFor(ownership, finalPath);
|
|
338
|
+
let stat;
|
|
339
|
+
try {
|
|
340
|
+
stat = fs.lstatSync(stagedPath, { bigint: true });
|
|
341
|
+
} catch (error) {
|
|
342
|
+
throw new Error(`visual-check could not inspect staged evidence "${stagedPath}": ${error.message}`);
|
|
343
|
+
}
|
|
344
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.ino === 0n) {
|
|
345
|
+
throw new Error(`visual-check staged evidence is not a uniquely owned regular file: "${stagedPath}".`);
|
|
346
|
+
}
|
|
347
|
+
if (stat.nlink !== 1n) {
|
|
348
|
+
ownership.stagedEntries.set(finalPath, {
|
|
349
|
+
path: stagedPath,
|
|
350
|
+
identity: fileIdentity(stat),
|
|
351
|
+
});
|
|
352
|
+
throw new Error(`visual-check staged evidence has an external hard link: "${stagedPath}".`);
|
|
353
|
+
}
|
|
354
|
+
const targetMode = ownership.targets.get(finalPath)?.mode;
|
|
355
|
+
if (targetMode !== null && targetMode !== undefined
|
|
356
|
+
&& Number(stat.mode & 0o777n) !== targetMode) {
|
|
357
|
+
fs.chmodSync(stagedPath, targetMode);
|
|
358
|
+
const adjusted = fs.lstatSync(stagedPath, { bigint: true });
|
|
359
|
+
if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile()) {
|
|
360
|
+
throw new Error(`visual-check staged evidence changed while preserving its mode: "${stagedPath}".`);
|
|
361
|
+
}
|
|
362
|
+
stat = adjusted;
|
|
363
|
+
}
|
|
364
|
+
const entry = {
|
|
365
|
+
path: stagedPath,
|
|
366
|
+
identity: fileIdentity(stat),
|
|
367
|
+
};
|
|
368
|
+
ownership.stagedEntries.set(finalPath, entry);
|
|
369
|
+
const evidence = regularFileEvidence(stagedPath);
|
|
370
|
+
if (!evidence.ok || !evidenceIdentityMatches(evidence.identity, entry.identity)) {
|
|
371
|
+
throw new Error(`visual-check could not bind staged evidence "${stagedPath}" to its contents.`);
|
|
372
|
+
}
|
|
373
|
+
entry.evidence = {
|
|
374
|
+
sha256: evidence.evidence.sha256,
|
|
375
|
+
bytes: evidence.evidence.bytes,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function writeStagedEvidence(ownership, finalPath, contents) {
|
|
380
|
+
const stagedPath = stagedPathFor(ownership, finalPath);
|
|
381
|
+
const target = ownership.targets.get(finalPath);
|
|
382
|
+
const mode = target?.mode ?? 0o666;
|
|
383
|
+
let descriptor;
|
|
384
|
+
try {
|
|
385
|
+
descriptor = fs.openSync(stagedPath, 'wx', mode);
|
|
386
|
+
let stat = fs.fstatSync(descriptor, { bigint: true });
|
|
387
|
+
if (!stat.isFile() || stat.ino === 0n) {
|
|
388
|
+
throw new Error('the newly created staging entry has no unique regular-file identity');
|
|
389
|
+
}
|
|
390
|
+
if (target?.mode !== null && target?.mode !== undefined
|
|
391
|
+
&& Number(stat.mode & 0o777n) !== target.mode) {
|
|
392
|
+
fs.fchmodSync(descriptor, target.mode);
|
|
393
|
+
const adjusted = fs.fstatSync(descriptor, { bigint: true });
|
|
394
|
+
if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile()
|
|
395
|
+
|| Number(adjusted.mode & 0o777n) !== target.mode) {
|
|
396
|
+
throw new Error('the staged evidence mode could not be preserved');
|
|
397
|
+
}
|
|
398
|
+
stat = adjusted;
|
|
399
|
+
}
|
|
400
|
+
const entry = {
|
|
401
|
+
path: stagedPath,
|
|
402
|
+
identity: fileIdentity(stat),
|
|
403
|
+
};
|
|
404
|
+
ownership.stagedEntries.set(finalPath, entry);
|
|
405
|
+
if (stat.nlink !== 1n) {
|
|
406
|
+
throw new Error('the newly created staging entry has an external hard link');
|
|
407
|
+
}
|
|
408
|
+
fs.writeFileSync(descriptor, contents);
|
|
409
|
+
const bytes = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
|
410
|
+
entry.evidence = { sha256: sha256(bytes), bytes: bytes.byteLength };
|
|
411
|
+
} finally {
|
|
412
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function unlinkOwnedEntry(file, identity, {
|
|
417
|
+
evidence,
|
|
418
|
+
expectedLinks,
|
|
419
|
+
} = {}) {
|
|
420
|
+
const verifiedLinks = Number(expectedLinks ?? identity.links ?? 1);
|
|
421
|
+
let captured = captureRegularFileBinding(file, {
|
|
422
|
+
subject: 'visual-cleanup',
|
|
423
|
+
expectedIdentity: identity,
|
|
424
|
+
expectedMode: identity.mode,
|
|
425
|
+
expectedLinks: verifiedLinks,
|
|
426
|
+
...(evidence ? {
|
|
427
|
+
expectedSha256: evidence.sha256,
|
|
428
|
+
expectedBytes: evidence.bytes,
|
|
429
|
+
} : {}),
|
|
430
|
+
});
|
|
431
|
+
const reportedLinks = Number(captured.reason?.links);
|
|
432
|
+
if (captured.status === 'unsupported'
|
|
433
|
+
&& captured.reason?.code === 'visual-cleanup-hardlinked'
|
|
434
|
+
&& Number.isSafeInteger(reportedLinks)
|
|
435
|
+
&& reportedLinks > verifiedLinks) {
|
|
436
|
+
captured = captureRegularFileBinding(file, {
|
|
437
|
+
subject: 'visual-cleanup',
|
|
438
|
+
expectedIdentity: identity,
|
|
439
|
+
expectedMode: identity.mode,
|
|
440
|
+
expectedLinks: reportedLinks,
|
|
441
|
+
...(evidence ? {
|
|
442
|
+
expectedSha256: evidence.sha256,
|
|
443
|
+
expectedBytes: evidence.bytes,
|
|
444
|
+
} : {}),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (captured.status !== 'captured') {
|
|
448
|
+
if (captured.reason?.systemCode === 'ENOENT') return { status: 'absent' };
|
|
449
|
+
return {
|
|
450
|
+
status: captured.status === 'different' || captured.status === 'unsupported'
|
|
451
|
+
? 'different'
|
|
452
|
+
: 'unknown',
|
|
453
|
+
reason: captured.reason?.code?.includes('content-changed') ? 'content' : 'identity',
|
|
454
|
+
error: new Error(captured.reason?.code || 'visual cleanup binding failed'),
|
|
455
|
+
bindingState: captured.reason,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
let removal;
|
|
459
|
+
let released;
|
|
460
|
+
try {
|
|
461
|
+
removal = quarantineRemoveRegularFileBinding(captured.binding, file, {
|
|
462
|
+
subject: 'visual-cleanup',
|
|
463
|
+
expectedLinks: captured.identity?.links ? Number(captured.identity.links) : verifiedLinks,
|
|
464
|
+
});
|
|
465
|
+
} finally {
|
|
466
|
+
released = releaseRegularFileBinding(captured.binding);
|
|
467
|
+
}
|
|
468
|
+
if (released.status !== 'released') {
|
|
469
|
+
return {
|
|
470
|
+
status: 'unknown',
|
|
471
|
+
reason: 'identity',
|
|
472
|
+
error: new Error(released.reason?.code || 'visual cleanup binding release failed'),
|
|
473
|
+
bindingState: released.reason,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (removal.status === 'removed' || removal.status === 'absent') return removal;
|
|
477
|
+
return {
|
|
478
|
+
status: removal.status === 'preserved' || removal.status === 'different'
|
|
479
|
+
|| removal.status === 'unsupported' ? 'different' : 'unknown',
|
|
480
|
+
reason: 'identity',
|
|
481
|
+
error: new Error(removal.reason?.code || 'visual cleanup quarantine failed'),
|
|
482
|
+
bindingState: removal.reason,
|
|
483
|
+
...(removal.recoveryDirectory ? {
|
|
484
|
+
recoveryDirectory: removal.recoveryDirectory,
|
|
485
|
+
recoveryFile: removal.recoveryFile,
|
|
486
|
+
} : {}),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function backupOwnedPublishedEntry(file, backupPath, identity, evidence) {
|
|
491
|
+
const captured = captureRegularFileBinding(file, {
|
|
492
|
+
subject: 'previous-evidence',
|
|
493
|
+
expectedIdentity: identity,
|
|
494
|
+
expectedMode: identity.mode,
|
|
495
|
+
expectedSha256: evidence.sha256,
|
|
496
|
+
expectedBytes: evidence.bytes,
|
|
497
|
+
expectedLinks: 1,
|
|
498
|
+
});
|
|
499
|
+
if (captured.status !== 'captured') {
|
|
500
|
+
return { ...captured, backupCreated: false, backupVerified: false };
|
|
501
|
+
}
|
|
502
|
+
let backup;
|
|
503
|
+
let released;
|
|
504
|
+
try {
|
|
505
|
+
backup = backupPublicRegularFileBinding(captured.binding, file, backupPath, {
|
|
506
|
+
subject: 'previous-evidence',
|
|
507
|
+
});
|
|
508
|
+
} finally {
|
|
509
|
+
released = releaseRegularFileBinding(captured.binding);
|
|
510
|
+
}
|
|
511
|
+
if (released.status !== 'released') {
|
|
512
|
+
return {
|
|
513
|
+
status: 'recovery-required',
|
|
514
|
+
reason: {
|
|
515
|
+
code: 'previous-evidence-binding-release-failed',
|
|
516
|
+
bindingState: released.reason,
|
|
517
|
+
},
|
|
518
|
+
recoveryDirectory: backup?.recoveryDirectory || path.dirname(backupPath),
|
|
519
|
+
recoveryFile: backup?.recoveryFile || (backup?.backupCreated ? backupPath : undefined),
|
|
520
|
+
target: backup?.target || file,
|
|
521
|
+
backupCreated: backup?.backupCreated === true,
|
|
522
|
+
backupVerified: backup?.backupVerified === true,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return backup;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function publishedRemovalError(file, result) {
|
|
529
|
+
const code = result.reason?.code;
|
|
530
|
+
let reason;
|
|
531
|
+
if (result.status === 'preserved') {
|
|
532
|
+
reason = 'published entry changed during retirement; its successor was restored and preserved';
|
|
533
|
+
} else if (result.status === 'recovery-required') {
|
|
534
|
+
reason = 'published entry changed during retirement; recovery material was preserved';
|
|
535
|
+
} else if (code?.includes('content-changed')) {
|
|
536
|
+
reason = 'published entry content changed; it was preserved';
|
|
537
|
+
} else if (code?.includes('mode-changed')) {
|
|
538
|
+
reason = 'published entry mode changed; it was preserved';
|
|
539
|
+
} else if (result.status === 'different' || result.status === 'unsupported') {
|
|
540
|
+
reason = 'published entry identity changed; it was preserved';
|
|
541
|
+
} else {
|
|
542
|
+
reason = code || 'published entry could not be safely retired; it was preserved';
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
file,
|
|
546
|
+
reason,
|
|
547
|
+
...(code ? { code } : {}),
|
|
548
|
+
...(result.recoveryDirectory ? { recoveryDirectory: result.recoveryDirectory } : {}),
|
|
549
|
+
...(result.recoveryFile ? { recoveryFile: result.recoveryFile } : {}),
|
|
550
|
+
...(result.target ? { target: result.target } : {}),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function cleanupStagedEvidence(ownership, { removeDirectory = false } = {}) {
|
|
555
|
+
const errors = [];
|
|
556
|
+
for (const [finalPath, entry] of [...ownership.stagedEntries]) {
|
|
557
|
+
const result = unlinkOwnedEntry(entry.path, entry.identity, { evidence: entry.evidence });
|
|
558
|
+
if (result.status === 'removed' || result.status === 'absent') {
|
|
559
|
+
ownership.stagedEntries.delete(finalPath);
|
|
560
|
+
} else {
|
|
561
|
+
errors.push({
|
|
562
|
+
file: entry.path,
|
|
563
|
+
reason: result.status === 'different' && result.reason === 'content'
|
|
564
|
+
? 'staging entry content changed; it was preserved'
|
|
565
|
+
: result.status === 'different'
|
|
566
|
+
? 'staging entry identity changed; it was preserved'
|
|
567
|
+
: result.error?.message,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const directories = [
|
|
572
|
+
ownership.inspection,
|
|
573
|
+
...(removeDirectory ? [{
|
|
574
|
+
directory: ownership.stagingDirectory,
|
|
575
|
+
identity: ownership.stagingIdentity,
|
|
576
|
+
}] : []),
|
|
577
|
+
].filter(Boolean);
|
|
578
|
+
for (const { directory, identity } of directories) {
|
|
579
|
+
try {
|
|
580
|
+
const stat = fs.lstatSync(directory, { bigint: true });
|
|
581
|
+
if (!identity || !identityMatches(stat, identity, { directory: true })) {
|
|
582
|
+
errors.push({
|
|
583
|
+
file: directory,
|
|
584
|
+
recoveryDirectory: directory,
|
|
585
|
+
reason: 'staging directory identity changed; it was preserved',
|
|
586
|
+
});
|
|
587
|
+
} else {
|
|
588
|
+
removeEmptyDirectoryWithRetry(directory);
|
|
589
|
+
}
|
|
590
|
+
} catch (error) {
|
|
591
|
+
if (error?.code !== 'ENOENT') {
|
|
592
|
+
errors.push({ file: directory, recoveryDirectory: directory, reason: error.message });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return errors;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function verifyVisualEvidenceTargets(artifactPath, outputs, ownership) {
|
|
600
|
+
for (const target of visualEvidenceTargets(outputs)) {
|
|
601
|
+
const captured = ownership.targets.get(target.path);
|
|
602
|
+
const verification = captured ? verifyAtomicOutput(captured.snapshot) : {
|
|
603
|
+
status: 'unknown',
|
|
604
|
+
reason: { code: 'evidence-snapshot-missing' },
|
|
605
|
+
};
|
|
606
|
+
if (verification.status !== 'match') {
|
|
607
|
+
return evidencePathConflict(artifactPath, outputs, target.path, verification.reason);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return { ok: true };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs) {
|
|
614
|
+
const targets = visualEvidenceTargets(outputs);
|
|
615
|
+
const candidates = targets.map((target) => target.path);
|
|
616
|
+
const previousEvidence = new Map();
|
|
617
|
+
try {
|
|
618
|
+
fs.mkdirSync(path.dirname(outputs.receipt), { recursive: true });
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return evidenceWriteFailure(
|
|
621
|
+
artifactPath,
|
|
622
|
+
'visual-check could not create its evidence directory.',
|
|
623
|
+
[{ file: path.dirname(outputs.receipt), reason: error.message }],
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const capturedTargets = new Map();
|
|
628
|
+
for (const target of targets) {
|
|
629
|
+
const captured = captureAtomicOutput(target.path, {
|
|
630
|
+
requestedEntryPolicy: 'regular-or-absent',
|
|
631
|
+
});
|
|
632
|
+
if (captured.status !== 'captured') {
|
|
633
|
+
return evidencePathConflict(artifactPath, outputs, target.path, captured.reason);
|
|
634
|
+
}
|
|
635
|
+
capturedTargets.set(target.path, captured);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const parent = capturedTargets.get(outputs.receipt).snapshot.slot;
|
|
639
|
+
for (const target of targets.slice(1)) {
|
|
640
|
+
const slot = capturedTargets.get(target.path).snapshot.slot;
|
|
641
|
+
if (slot.parentDevice !== parent.parentDevice || slot.parentInode !== parent.parentInode) {
|
|
642
|
+
return evidencePathConflict(artifactPath, outputs, target.path, {
|
|
643
|
+
code: 'evidence-parent-mismatch',
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const existing = candidates.filter((file) => (
|
|
649
|
+
capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing'
|
|
650
|
+
));
|
|
651
|
+
if (existing.length) {
|
|
652
|
+
const receiptEntry = capturedTargets.get(outputs.receipt).snapshot.requestedEntry;
|
|
653
|
+
if (receiptEntry.kind !== 'existing') {
|
|
654
|
+
return evidencePathConflict(artifactPath, outputs, existing[0], {
|
|
655
|
+
code: 'ownership-receipt-missing',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const previousReceipt = readRegularFileContents(outputs.receipt, {
|
|
660
|
+
subject: 'ownership-receipt',
|
|
661
|
+
});
|
|
662
|
+
const capturedReceipt = capturedTargets.get(outputs.receipt).snapshot.target;
|
|
663
|
+
if (!previousReceipt.ok || !evidenceIdentityMatches(previousReceipt.identity, {
|
|
664
|
+
device: capturedReceipt.device,
|
|
665
|
+
inode: capturedReceipt.inode,
|
|
666
|
+
mode: capturedReceipt.mode,
|
|
667
|
+
links: 1,
|
|
668
|
+
})) {
|
|
669
|
+
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
|
|
670
|
+
code: 'ownership-receipt-invalid',
|
|
671
|
+
...(previousReceipt.reason?.code ? { bindingCode: previousReceipt.reason.code } : {}),
|
|
672
|
+
...(previousReceipt.reason?.systemCode
|
|
673
|
+
? { systemCode: previousReceipt.reason.systemCode } : {}),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
const previousBytes = previousReceipt.bytes;
|
|
677
|
+
let previous;
|
|
678
|
+
try {
|
|
679
|
+
previous = JSON.parse(previousBytes.toString('utf8'));
|
|
680
|
+
} catch (error) {
|
|
681
|
+
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
|
|
682
|
+
code: 'ownership-receipt-invalid',
|
|
683
|
+
...(error?.code ? { systemCode: error.code } : {}),
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
previousEvidence.set(outputs.receipt, {
|
|
687
|
+
...previousReceipt.evidence,
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
if (previous?.schemaVersion !== 1 || previous?.command !== outputs.command) {
|
|
691
|
+
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
|
|
692
|
+
code: 'ownership-receipt-schema-mismatch',
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
const recordedReceiptName = previous?.sidecars?.receipt;
|
|
696
|
+
const recordedContactName = previous?.sidecars?.contactSheet;
|
|
697
|
+
const recordedDirectory = previous?.sidecars?.directory || path.dirname(previous?.artifact?.path || '');
|
|
698
|
+
const receiptNameSafe = typeof recordedReceiptName === 'string'
|
|
699
|
+
// path-contract-allow: portable-logical-path -- receipt filenames are serialized logical basenames.
|
|
700
|
+
&& path.basename(recordedReceiptName) === recordedReceiptName;
|
|
701
|
+
const contactNameSafe = outputs.capture === false || typeof recordedContactName === 'string'
|
|
702
|
+
// path-contract-allow: portable-logical-path -- contact-sheet filenames are serialized logical basenames.
|
|
703
|
+
&& path.basename(recordedContactName) === recordedContactName;
|
|
704
|
+
if (!path.isAbsolute(recordedDirectory) || !receiptNameSafe || !contactNameSafe
|
|
705
|
+
|| sameLocation(path.join(recordedDirectory, recordedReceiptName), outputs.receipt).status !== 'match'
|
|
706
|
+
|| (outputs.capture !== false && sameLocation(path.join(recordedDirectory, recordedContactName), outputs.contactSheet).status !== 'match')) {
|
|
707
|
+
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
|
|
708
|
+
code: 'ownership-sidecar-path-mismatch',
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
if (typeof previous?.artifact?.path !== 'string'
|
|
712
|
+
|| !path.isAbsolute(previous.artifact.path)
|
|
713
|
+
|| sameEntry(previous.artifact.path, artifactPath).status !== 'match'
|
|
714
|
+
|| previous.artifact.sha256 !== sha256(artifactBytes)
|
|
715
|
+
|| previous.artifact.bytes !== artifactBytes.byteLength) {
|
|
716
|
+
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
|
|
717
|
+
code: 'ownership-artifact-mismatch',
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const existingEvidence = visualEvidencePaths(outputs).filter((file) => (
|
|
722
|
+
capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing'
|
|
723
|
+
));
|
|
724
|
+
const recordedEvidence = previous?.sidecars?.files;
|
|
725
|
+
if (existingEvidence.length && !Array.isArray(recordedEvidence)) {
|
|
726
|
+
return evidencePathConflict(artifactPath, outputs, existingEvidence[0], {
|
|
727
|
+
code: 'ownership-evidence-digests-missing',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
if (Array.isArray(recordedEvidence)) {
|
|
731
|
+
const allowed = new Map(visualEvidencePaths(outputs).map((file) => [path.basename(file), file]));
|
|
732
|
+
const recorded = new Map();
|
|
733
|
+
for (const item of recordedEvidence) {
|
|
734
|
+
if (!item || typeof item.file !== 'string' || recorded.has(item.file) || !allowed.has(item.file)) {
|
|
735
|
+
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
|
|
736
|
+
code: 'ownership-evidence-list-invalid',
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
recorded.set(item.file, item);
|
|
740
|
+
}
|
|
741
|
+
if (recorded.size !== existingEvidence.length
|
|
742
|
+
|| existingEvidence.some((file) => !recorded.has(path.basename(file)))) {
|
|
743
|
+
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
|
|
744
|
+
code: 'ownership-evidence-list-mismatch',
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
for (const file of existingEvidence) {
|
|
748
|
+
const actual = regularFileEvidence(file);
|
|
749
|
+
const expected = recorded.get(path.basename(file));
|
|
750
|
+
if (!actual.ok
|
|
751
|
+
|| actual.evidence.sha256 !== expected.sha256
|
|
752
|
+
|| actual.evidence.bytes !== expected.bytes) {
|
|
753
|
+
return evidencePathConflict(artifactPath, outputs, file, {
|
|
754
|
+
code: actual.ok ? 'ownership-evidence-digest-mismatch' : actual.reason.code,
|
|
755
|
+
...(!actual.ok && actual.reason.systemCode
|
|
756
|
+
? { systemCode: actual.reason.systemCode } : {}),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
previousEvidence.set(file, {
|
|
760
|
+
sha256: actual.evidence.sha256,
|
|
761
|
+
bytes: actual.evidence.bytes,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
for (const target of targets) {
|
|
769
|
+
const verification = verifyAtomicOutput(capturedTargets.get(target.path).snapshot);
|
|
770
|
+
if (verification.status !== 'match') {
|
|
771
|
+
return evidencePathConflict(artifactPath, outputs, target.path, verification.reason);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
let stagingDirectory;
|
|
776
|
+
let stagingStat;
|
|
777
|
+
try {
|
|
778
|
+
stagingDirectory = fs.mkdtempSync(path.toNamespacedPath(
|
|
779
|
+
path.join(parent.parentPath, '.archify-visual-check-'),
|
|
780
|
+
));
|
|
781
|
+
stagingStat = fs.lstatSync(stagingDirectory, { bigint: true });
|
|
782
|
+
if (!stagingStat.isDirectory() || stagingStat.isSymbolicLink() || stagingStat.ino === 0n) {
|
|
783
|
+
throw new Error('the newly created staging directory has no stable directory identity');
|
|
784
|
+
}
|
|
785
|
+
} catch (error) {
|
|
786
|
+
return evidenceWriteFailure(
|
|
787
|
+
artifactPath,
|
|
788
|
+
'visual-check could not create a private evidence staging directory.',
|
|
789
|
+
[{ file: parent.parentPath, reason: error.message }],
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const staged = stagedOutputs(outputs, stagingDirectory);
|
|
794
|
+
const ownership = {
|
|
795
|
+
active: true,
|
|
796
|
+
artifact: path.resolve(artifactPath),
|
|
797
|
+
receipt: outputs.receipt,
|
|
798
|
+
targets: capturedTargets,
|
|
799
|
+
stagingDirectory,
|
|
800
|
+
stagingIdentity: fileIdentity(stagingStat),
|
|
801
|
+
stagedOutputs: staged,
|
|
802
|
+
stagingPaths: new Map([
|
|
803
|
+
[outputs.receipt, staged.receipt],
|
|
804
|
+
[outputs.contactSheet, staged.contactSheet],
|
|
805
|
+
...outputs.screenshots.map((entry, index) => [entry.path, staged.screenshots[index].path]),
|
|
806
|
+
]),
|
|
807
|
+
stagedEntries: new Map(),
|
|
808
|
+
previousEvidence,
|
|
809
|
+
};
|
|
810
|
+
visualEvidenceOwnerships.add(ownership);
|
|
811
|
+
return { ok: true, ownership };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function recordVisualEvidenceFiles(receipt, outputs, ownership) {
|
|
815
|
+
const files = [];
|
|
816
|
+
for (const file of visualEvidencePaths(outputs)) {
|
|
817
|
+
const staged = ownership.stagedEntries.get(file);
|
|
818
|
+
if (!staged || !currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) {
|
|
819
|
+
const error = new Error(`visual-check could not bind evidence file "${file}" to its receipt.`);
|
|
820
|
+
error.evidenceReason = { code: 'staged-evidence-content-mismatch' };
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
files.push({ ...staged.evidence, file: path.basename(file) });
|
|
824
|
+
}
|
|
825
|
+
receipt.sidecars.files = files;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function currentEvidenceMatches(file, identity, evidence, {
|
|
829
|
+
expectedLinks = identity.links ?? 1,
|
|
830
|
+
} = {}) {
|
|
831
|
+
if (!evidence) return false;
|
|
832
|
+
const current = regularFileEvidence(file, { expectedLinks: Number(expectedLinks) });
|
|
833
|
+
return current.ok
|
|
834
|
+
&& evidenceIdentityMatches(current.identity, identity, expectedLinks)
|
|
835
|
+
&& current.evidence.sha256 === evidence.sha256
|
|
836
|
+
&& current.evidence.bytes === evidence.bytes;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function restoreOwnedBackup(backup, errors) {
|
|
840
|
+
const expectedLinks = backup.identity.links ?? 1n;
|
|
841
|
+
const expectedLinkCount = Number(expectedLinks);
|
|
842
|
+
let backupStat;
|
|
843
|
+
try {
|
|
844
|
+
backupStat = fs.lstatSync(backup.path, { bigint: true });
|
|
845
|
+
} catch {
|
|
846
|
+
errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (!identityMatches(backupStat, backup.identity) || backupStat.nlink !== expectedLinks) {
|
|
850
|
+
errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' });
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
let captured = backup.evidence && Number.isSafeInteger(expectedLinkCount)
|
|
854
|
+
? captureRegularFileBinding(backup.path, {
|
|
855
|
+
subject: 'restore-backup',
|
|
856
|
+
expectedIdentity: backup.identity,
|
|
857
|
+
expectedMode: backup.identity.mode,
|
|
858
|
+
expectedSha256: backup.evidence.sha256,
|
|
859
|
+
expectedBytes: backup.evidence.bytes,
|
|
860
|
+
expectedLinks: expectedLinkCount,
|
|
861
|
+
})
|
|
862
|
+
: { status: 'unknown', reason: { code: 'restore-backup-evidence-unavailable' } };
|
|
863
|
+
let contentChanged = false;
|
|
864
|
+
if (captured.status !== 'captured'
|
|
865
|
+
&& captured.reason?.code === 'restore-backup-content-changed') {
|
|
866
|
+
captured = captureRegularFileBinding(backup.path, {
|
|
867
|
+
subject: 'restore-backup-claimant',
|
|
868
|
+
expectedIdentity: backup.identity,
|
|
869
|
+
expectedMode: backup.identity.mode,
|
|
870
|
+
expectedLinks: expectedLinkCount,
|
|
871
|
+
});
|
|
872
|
+
contentChanged = captured.status === 'captured';
|
|
873
|
+
}
|
|
874
|
+
if (captured.status !== 'captured') {
|
|
875
|
+
errors.push({
|
|
876
|
+
file: backup.path,
|
|
877
|
+
reason: 'backup bytes, mode, or identity changed; it was preserved',
|
|
878
|
+
...(captured.reason?.code ? { code: captured.reason.code } : {}),
|
|
879
|
+
});
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
let released;
|
|
883
|
+
try {
|
|
884
|
+
const finalEntry = pathEntry(backup.finalPath);
|
|
885
|
+
if (finalEntry.exists === true) {
|
|
886
|
+
errors.push({
|
|
887
|
+
file: backup.path,
|
|
888
|
+
reason: 'final path is occupied; the owned backup was preserved',
|
|
889
|
+
});
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (finalEntry.exists === null) {
|
|
893
|
+
errors.push({ file: backup.finalPath, reason: finalEntry.error?.message });
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
fs.linkSync(backup.path, backup.finalPath);
|
|
898
|
+
const linkedCount = expectedLinkCount + 1;
|
|
899
|
+
const staged = verifyRegularFileBinding(captured.binding, {
|
|
900
|
+
filePath: backup.path,
|
|
901
|
+
expectedLinks: linkedCount,
|
|
902
|
+
});
|
|
903
|
+
const published = verifyRegularFileBinding(captured.binding, {
|
|
904
|
+
filePath: backup.finalPath,
|
|
905
|
+
expectedLinks: linkedCount,
|
|
906
|
+
});
|
|
907
|
+
if (staged.status !== 'match' || published.status !== 'match') {
|
|
908
|
+
const removed = quarantineRemoveRegularFileBinding(
|
|
909
|
+
captured.binding,
|
|
910
|
+
backup.finalPath,
|
|
911
|
+
{ subject: 'restored-evidence', expectedLinks: linkedCount },
|
|
912
|
+
);
|
|
913
|
+
errors.push({
|
|
914
|
+
file: backup.path,
|
|
915
|
+
reason: removed.status === 'removed'
|
|
916
|
+
? 'restored entry identity could not be verified; the owned backup was preserved'
|
|
917
|
+
: removed.status === 'preserved'
|
|
918
|
+
? 'restored entry changed during cleanup; its successor and the owned backup were preserved'
|
|
919
|
+
: 'restored entry changed before rollback; both entries were preserved',
|
|
920
|
+
...(removed.reason?.code ? { code: removed.reason.code } : {}),
|
|
921
|
+
...(removed.recoveryDirectory ? { recoveryDirectory: removed.recoveryDirectory } : {}),
|
|
922
|
+
...(removed.recoveryFile ? { recoveryFile: removed.recoveryFile } : {}),
|
|
923
|
+
});
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const removed = unlinkOwnedEntry(backup.path, backup.identity, {
|
|
927
|
+
evidence: captured.content,
|
|
928
|
+
expectedLinks: linkedCount,
|
|
929
|
+
});
|
|
930
|
+
const finalMatches = verifyRegularFileBinding(captured.binding, {
|
|
931
|
+
filePath: backup.finalPath,
|
|
932
|
+
expectedLinks: expectedLinkCount,
|
|
933
|
+
}).status === 'match';
|
|
934
|
+
if (removed.status !== 'removed' || !finalMatches) {
|
|
935
|
+
errors.push({ file: backup.path, reason: 'restored entry link count could not be finalized' });
|
|
936
|
+
} else if (contentChanged) {
|
|
937
|
+
errors.push({
|
|
938
|
+
file: backup.finalPath,
|
|
939
|
+
reason: 'the backup content changed externally; its current bytes were restored and preserved',
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
} catch (error) {
|
|
943
|
+
errors.push({
|
|
944
|
+
file: backup.path,
|
|
945
|
+
reason: error?.code === 'EEXIST'
|
|
946
|
+
? 'final path was claimed during restore; the owned backup was preserved'
|
|
947
|
+
: error.message,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
} finally {
|
|
951
|
+
released = releaseRegularFileBinding(captured.binding);
|
|
952
|
+
if (released.status !== 'released') {
|
|
953
|
+
errors.push({
|
|
954
|
+
file: backup.path,
|
|
955
|
+
reason: 'restored backup binding could not be released cleanly',
|
|
956
|
+
...(released.reason?.code ? { code: released.reason.code } : {}),
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function commitVisualEvidence(artifactPath, outputs, ownership, desiredPaths) {
|
|
963
|
+
const verification = verifyVisualEvidenceTargets(artifactPath, outputs, ownership);
|
|
964
|
+
if (!verification.ok) {
|
|
965
|
+
const cleanupErrors = cleanupStagedEvidence(ownership, { removeDirectory: true });
|
|
966
|
+
if (cleanupErrors.length) verification.diagnostic.evidence.rollbackErrors = cleanupErrors;
|
|
967
|
+
ownership.active = false;
|
|
968
|
+
return verification;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const desired = new Set(desiredPaths);
|
|
972
|
+
const stagedBindings = new Map();
|
|
973
|
+
const releaseStagedBindings = () => {
|
|
974
|
+
const failures = [];
|
|
975
|
+
for (const [finalPath, binding] of stagedBindings) {
|
|
976
|
+
const released = releaseRegularFileBinding(binding);
|
|
977
|
+
if (released.status !== 'released') {
|
|
978
|
+
failures.push({
|
|
979
|
+
file: finalPath,
|
|
980
|
+
reason: released.reason?.code,
|
|
981
|
+
bindingState: released.reason,
|
|
982
|
+
...(released.reason?.recoveryDirectory
|
|
983
|
+
? { recoveryDirectory: released.reason.recoveryDirectory } : {}),
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
stagedBindings.clear();
|
|
988
|
+
return failures;
|
|
989
|
+
};
|
|
990
|
+
for (const finalPath of desired) {
|
|
991
|
+
const staged = ownership.stagedEntries.get(finalPath);
|
|
992
|
+
const captured = staged ? captureRegularFileBinding(staged.path, {
|
|
993
|
+
subject: 'staged-evidence',
|
|
994
|
+
expectedIdentity: staged.identity,
|
|
995
|
+
expectedMode: staged.identity.mode,
|
|
996
|
+
expectedSha256: staged.evidence?.sha256,
|
|
997
|
+
expectedBytes: staged.evidence?.bytes,
|
|
998
|
+
expectedLinks: 1,
|
|
999
|
+
}) : null;
|
|
1000
|
+
if (!staged || captured?.status !== 'captured') {
|
|
1001
|
+
const failure = evidenceWriteFailure(
|
|
1002
|
+
artifactPath,
|
|
1003
|
+
'visual-check could not verify its staged evidence before publication.',
|
|
1004
|
+
[{
|
|
1005
|
+
file: staged?.path || stagedPathFor(ownership, finalPath),
|
|
1006
|
+
reason: captured?.reason?.code || 'staged entry missing or changed',
|
|
1007
|
+
}],
|
|
1008
|
+
);
|
|
1009
|
+
failure.diagnostic.evidence.errors.push(
|
|
1010
|
+
...releaseStagedBindings(),
|
|
1011
|
+
...cleanupStagedEvidence(ownership, { removeDirectory: true }),
|
|
1012
|
+
);
|
|
1013
|
+
ownership.active = false;
|
|
1014
|
+
return failure;
|
|
1015
|
+
}
|
|
1016
|
+
stagedBindings.set(finalPath, captured.binding);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const backups = [];
|
|
1020
|
+
const published = [];
|
|
1021
|
+
let conflict = null;
|
|
1022
|
+
try {
|
|
1023
|
+
for (const [index, target] of visualEvidenceTargets(outputs).entries()) {
|
|
1024
|
+
const captured = ownership.targets.get(target.path);
|
|
1025
|
+
if (captured.snapshot.target.kind !== 'file') continue;
|
|
1026
|
+
const backupPath = path.join(ownership.stagingDirectory, `previous-${index}`);
|
|
1027
|
+
const identity = {
|
|
1028
|
+
device: captured.snapshot.target.device,
|
|
1029
|
+
inode: captured.snapshot.target.inode,
|
|
1030
|
+
mode: captured.snapshot.target.mode,
|
|
1031
|
+
links: 1n,
|
|
1032
|
+
};
|
|
1033
|
+
const evidence = ownership.previousEvidence.get(target.path);
|
|
1034
|
+
if (!evidence) {
|
|
1035
|
+
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
|
|
1036
|
+
code: 'target-evidence-missing-during-commit',
|
|
1037
|
+
});
|
|
1038
|
+
throw new Error(conflict.error);
|
|
1039
|
+
}
|
|
1040
|
+
const backup = {
|
|
1041
|
+
targetPath: target.path,
|
|
1042
|
+
finalPath: captured.commitPath,
|
|
1043
|
+
path: backupPath,
|
|
1044
|
+
identity,
|
|
1045
|
+
evidence,
|
|
1046
|
+
};
|
|
1047
|
+
const backupResult = backupOwnedPublishedEntry(
|
|
1048
|
+
captured.commitPath,
|
|
1049
|
+
backupPath,
|
|
1050
|
+
identity,
|
|
1051
|
+
evidence,
|
|
1052
|
+
);
|
|
1053
|
+
if (backupResult.backupCreated) backups.push(backup);
|
|
1054
|
+
if (backupResult.status !== 'backed-up'
|
|
1055
|
+
|| !backupResult.backupCreated
|
|
1056
|
+
|| !backupResult.backupVerified) {
|
|
1057
|
+
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
|
|
1058
|
+
code: backupResult.reason?.code || 'target-backup-failed-during-commit',
|
|
1059
|
+
backupCreated: backupResult.backupCreated === true,
|
|
1060
|
+
backupVerified: backupResult.backupVerified === true,
|
|
1061
|
+
...(backupResult.recoveryDirectory
|
|
1062
|
+
? { recoveryDirectory: backupResult.recoveryDirectory } : {}),
|
|
1063
|
+
...(backupResult.recoveryFile ? { recoveryFile: backupResult.recoveryFile } : {}),
|
|
1064
|
+
...(backupResult.target ? { target: backupResult.target } : {}),
|
|
1065
|
+
...(backupResult.reason ? { bindingState: backupResult.reason } : {}),
|
|
1066
|
+
});
|
|
1067
|
+
throw new Error(conflict.error);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const publicationOrder = visualEvidenceTargets(outputs)
|
|
1072
|
+
.filter((target) => desired.has(target.path))
|
|
1073
|
+
.sort((left, right) => Number(left.kind === 'receipt') - Number(right.kind === 'receipt'));
|
|
1074
|
+
for (const target of publicationOrder) {
|
|
1075
|
+
const captured = ownership.targets.get(target.path);
|
|
1076
|
+
const staged = ownership.stagedEntries.get(target.path);
|
|
1077
|
+
const stagedBinding = stagedBindings.get(target.path);
|
|
1078
|
+
const beforeLink = verifyRegularFileBinding(stagedBinding, {
|
|
1079
|
+
filePath: staged.path,
|
|
1080
|
+
expectedLinks: 1,
|
|
1081
|
+
});
|
|
1082
|
+
if (beforeLink.status !== 'match') {
|
|
1083
|
+
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
|
|
1084
|
+
code: beforeLink.reason?.code?.includes('content-changed')
|
|
1085
|
+
? 'staged-evidence-content-mismatch'
|
|
1086
|
+
: beforeLink.reason?.code || 'staged-evidence-content-mismatch',
|
|
1087
|
+
});
|
|
1088
|
+
throw new Error(conflict.error);
|
|
1089
|
+
}
|
|
1090
|
+
try {
|
|
1091
|
+
fs.linkSync(staged.path, captured.commitPath);
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (error?.code === 'EEXIST') {
|
|
1094
|
+
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
|
|
1095
|
+
code: 'target-created-during-commit',
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
throw error;
|
|
1099
|
+
}
|
|
1100
|
+
published.push({
|
|
1101
|
+
finalPath: captured.commitPath,
|
|
1102
|
+
stagedPath: staged.path,
|
|
1103
|
+
identity: staged.identity,
|
|
1104
|
+
evidence: staged.evidence,
|
|
1105
|
+
binding: stagedBinding,
|
|
1106
|
+
});
|
|
1107
|
+
const stagedLinked = verifyRegularFileBinding(stagedBinding, {
|
|
1108
|
+
filePath: staged.path,
|
|
1109
|
+
expectedLinks: 2,
|
|
1110
|
+
});
|
|
1111
|
+
const publishedLinked = verifyRegularFileBinding(stagedBinding, {
|
|
1112
|
+
filePath: captured.commitPath,
|
|
1113
|
+
expectedLinks: 2,
|
|
1114
|
+
});
|
|
1115
|
+
if (stagedLinked.status !== 'match' || publishedLinked.status !== 'match') {
|
|
1116
|
+
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
|
|
1117
|
+
code: 'published-entry-identity-mismatch',
|
|
1118
|
+
stagedState: stagedLinked.reason,
|
|
1119
|
+
publishedState: publishedLinked.reason,
|
|
1120
|
+
});
|
|
1121
|
+
throw new Error(conflict.error);
|
|
1122
|
+
}
|
|
1123
|
+
const removed = quarantineRemoveRegularFileBinding(stagedBinding, staged.path, {
|
|
1124
|
+
subject: 'staged-evidence',
|
|
1125
|
+
expectedLinks: 2,
|
|
1126
|
+
});
|
|
1127
|
+
if (removed.status !== 'removed' && removed.status !== 'preserved') {
|
|
1128
|
+
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
|
|
1129
|
+
code: removed.reason?.code || 'published-entry-identity-mismatch',
|
|
1130
|
+
});
|
|
1131
|
+
throw new Error(`visual-check could not retire staged evidence "${staged.path}".`);
|
|
1132
|
+
}
|
|
1133
|
+
if (removed.status === 'removed') ownership.stagedEntries.delete(target.path);
|
|
1134
|
+
const finalized = verifyRegularFileBinding(stagedBinding, {
|
|
1135
|
+
filePath: captured.commitPath,
|
|
1136
|
+
expectedLinks: 1,
|
|
1137
|
+
});
|
|
1138
|
+
if (finalized.status !== 'match') {
|
|
1139
|
+
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
|
|
1140
|
+
code: 'published-entry-link-count-mismatch',
|
|
1141
|
+
bindingState: finalized.reason,
|
|
1142
|
+
});
|
|
1143
|
+
throw new Error(conflict.error);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Receipt publication is the linearization point for the evidence set.
|
|
1148
|
+
// Recheck every earlier member afterwards so a writer that replaces a PNG
|
|
1149
|
+
// or the contact sheet while later members publish cannot yield success.
|
|
1150
|
+
for (const entry of published) {
|
|
1151
|
+
const finalState = verifyRegularFileBinding(entry.binding, {
|
|
1152
|
+
filePath: entry.finalPath,
|
|
1153
|
+
expectedLinks: 1,
|
|
1154
|
+
});
|
|
1155
|
+
if (finalState.status !== 'match') {
|
|
1156
|
+
conflict = evidencePathConflict(artifactPath, outputs, entry.finalPath, {
|
|
1157
|
+
code: 'published-set-identity-mismatch',
|
|
1158
|
+
bindingState: finalState.reason,
|
|
1159
|
+
});
|
|
1160
|
+
throw new Error(conflict.error);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
for (const backup of backups) {
|
|
1165
|
+
if (!currentEvidenceMatches(
|
|
1166
|
+
backup.path,
|
|
1167
|
+
backup.identity,
|
|
1168
|
+
ownership.previousEvidence.get(backup.targetPath),
|
|
1169
|
+
)) {
|
|
1170
|
+
conflict = evidencePathConflict(artifactPath, outputs, backup.targetPath, {
|
|
1171
|
+
code: 'backup-content-changed-before-cleanup',
|
|
1172
|
+
});
|
|
1173
|
+
throw new Error(conflict.error);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// SMB keeps removed staging names pending until every bound handle closes.
|
|
1178
|
+
// Publication and backup verification are complete before directory cleanup.
|
|
1179
|
+
const cleanupErrors = releaseStagedBindings();
|
|
1180
|
+
for (const backup of backups) {
|
|
1181
|
+
const removed = unlinkOwnedEntry(backup.path, backup.identity, {
|
|
1182
|
+
evidence: backup.evidence,
|
|
1183
|
+
});
|
|
1184
|
+
if (removed.status !== 'removed' && removed.status !== 'absent') {
|
|
1185
|
+
cleanupErrors.push({
|
|
1186
|
+
file: backup.path,
|
|
1187
|
+
reason: removed.status === 'different' && removed.reason === 'content'
|
|
1188
|
+
? 'backup content changed; it was preserved'
|
|
1189
|
+
: removed.status === 'different'
|
|
1190
|
+
? 'backup identity changed; it was preserved'
|
|
1191
|
+
: removed.error?.message,
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
cleanupErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true }));
|
|
1196
|
+
ownership.active = false;
|
|
1197
|
+
let recoveryDirectory;
|
|
1198
|
+
if (cleanupErrors.length) {
|
|
1199
|
+
try {
|
|
1200
|
+
const retained = fs.lstatSync(ownership.stagingDirectory, { bigint: true });
|
|
1201
|
+
if (identityMatches(retained, ownership.stagingIdentity, { directory: true })) {
|
|
1202
|
+
recoveryDirectory = ownership.stagingDirectory;
|
|
1203
|
+
}
|
|
1204
|
+
} catch {}
|
|
1205
|
+
recoveryDirectory ||= cleanupErrors.find((entry) => entry.recoveryDirectory)?.recoveryDirectory;
|
|
1206
|
+
}
|
|
1207
|
+
return {
|
|
1208
|
+
ok: true,
|
|
1209
|
+
status: cleanupErrors.length ? 'committed-with-warning' : 'committed',
|
|
1210
|
+
cleanupErrors,
|
|
1211
|
+
...(recoveryDirectory ? { recoveryDirectory } : {}),
|
|
1212
|
+
committedReceipt: outputs.receipt,
|
|
1213
|
+
};
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
const rollbackErrors = [];
|
|
1216
|
+
for (const entry of [...published].reverse()) {
|
|
1217
|
+
let removed;
|
|
1218
|
+
let ownedMismatch;
|
|
1219
|
+
for (const expectedLinks of [2, 1]) {
|
|
1220
|
+
const matches = verifyRegularFileBinding(entry.binding, {
|
|
1221
|
+
filePath: entry.finalPath,
|
|
1222
|
+
expectedLinks,
|
|
1223
|
+
});
|
|
1224
|
+
if (matches.status === 'match') {
|
|
1225
|
+
removed = quarantineRemoveRegularFileBinding(
|
|
1226
|
+
entry.binding,
|
|
1227
|
+
entry.finalPath,
|
|
1228
|
+
{ subject: 'published-evidence', expectedLinks },
|
|
1229
|
+
);
|
|
1230
|
+
break;
|
|
1231
|
+
}
|
|
1232
|
+
if (matches.reason?.code?.includes('content-changed')
|
|
1233
|
+
|| matches.reason?.code?.includes('mode-changed')) {
|
|
1234
|
+
ownedMismatch = matches;
|
|
1235
|
+
} else {
|
|
1236
|
+
ownedMismatch ||= matches;
|
|
1237
|
+
}
|
|
1238
|
+
const reportedLinks = Number(matches.reason?.links);
|
|
1239
|
+
if (matches.status === 'unsupported'
|
|
1240
|
+
&& matches.reason?.code === 'staged-evidence-hardlinked'
|
|
1241
|
+
&& Number.isSafeInteger(reportedLinks)
|
|
1242
|
+
&& reportedLinks > expectedLinks) {
|
|
1243
|
+
const current = verifyRegularFileBinding(entry.binding, {
|
|
1244
|
+
filePath: entry.finalPath,
|
|
1245
|
+
expectedLinks: reportedLinks,
|
|
1246
|
+
});
|
|
1247
|
+
if (current.status === 'match') {
|
|
1248
|
+
removed = quarantineRemoveRegularFileBinding(
|
|
1249
|
+
entry.binding,
|
|
1250
|
+
entry.finalPath,
|
|
1251
|
+
{ subject: 'published-evidence', expectedLinks: reportedLinks },
|
|
1252
|
+
);
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (!removed) {
|
|
1258
|
+
const claimantRemoval = quarantineRemoveLinkedRegularFileAlias(
|
|
1259
|
+
entry.stagedPath,
|
|
1260
|
+
entry.finalPath,
|
|
1261
|
+
{ subject: 'published-evidence-claimant' },
|
|
1262
|
+
);
|
|
1263
|
+
removed = claimantRemoval.status === 'removed' ? claimantRemoval : ownedMismatch;
|
|
1264
|
+
}
|
|
1265
|
+
if (removed.status !== 'removed' && removed.status !== 'absent') {
|
|
1266
|
+
rollbackErrors.push(publishedRemovalError(entry.finalPath, removed));
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
for (const backup of [...backups].reverse()) restoreOwnedBackup(backup, rollbackErrors);
|
|
1270
|
+
rollbackErrors.push(...releaseStagedBindings());
|
|
1271
|
+
rollbackErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true }));
|
|
1272
|
+
ownership.active = false;
|
|
1273
|
+
if (conflict) {
|
|
1274
|
+
if (rollbackErrors.length) conflict.diagnostic.evidence.rollbackErrors = rollbackErrors;
|
|
1275
|
+
return conflict;
|
|
1276
|
+
}
|
|
1277
|
+
return evidenceWriteFailure(
|
|
1278
|
+
artifactPath,
|
|
1279
|
+
'visual-check could not atomically publish its evidence set.',
|
|
1280
|
+
[{ reason: error.message }, ...rollbackErrors],
|
|
1281
|
+
);
|
|
1282
|
+
} finally {
|
|
1283
|
+
releaseStagedBindings();
|
|
1284
|
+
}
|
|
80
1285
|
}
|
|
81
1286
|
|
|
82
1287
|
function executable(file, platform = process.platform) {
|
|
@@ -89,24 +1294,38 @@ function executable(file, platform = process.platform) {
|
|
|
89
1294
|
}
|
|
90
1295
|
}
|
|
91
1296
|
|
|
92
|
-
function findOnPath(command, env, platform) {
|
|
93
|
-
|
|
1297
|
+
function findOnPath(command, env, platform, resolveExecutable) {
|
|
1298
|
+
// Use the target platform's path grammar, not the host's: these candidates
|
|
1299
|
+
// are checked through an injected platform (see the findChrome tests), and
|
|
1300
|
+
// the host `path` object on Windows would join POSIX directories with
|
|
1301
|
+
// backslashes and split PATH on ';'.
|
|
1302
|
+
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
|
1303
|
+
const directories = String(env.PATH || '').split(pathApi.delimiter).filter(Boolean);
|
|
94
1304
|
const extensions = platform === 'win32'
|
|
95
1305
|
? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
|
|
96
1306
|
: [''];
|
|
97
1307
|
for (const directory of directories) {
|
|
98
1308
|
for (const extension of extensions) {
|
|
99
|
-
const candidate =
|
|
100
|
-
const resolved =
|
|
1309
|
+
const candidate = pathApi.join(directory, `${command}${extension}`);
|
|
1310
|
+
const resolved = resolveExecutable(candidate, platform);
|
|
101
1311
|
if (resolved) return resolved;
|
|
102
1312
|
}
|
|
103
1313
|
}
|
|
104
1314
|
return null;
|
|
105
1315
|
}
|
|
106
1316
|
|
|
107
|
-
export function findChrome({
|
|
1317
|
+
export function findChrome({
|
|
1318
|
+
env = process.env,
|
|
1319
|
+
platform = process.platform,
|
|
1320
|
+
resolveExecutable = executable,
|
|
1321
|
+
} = {}) {
|
|
1322
|
+
// Windows environment names are case-insensitive, including when callers
|
|
1323
|
+
// provide a plain object instead of the special process.env object.
|
|
1324
|
+
if (platform === 'win32') {
|
|
1325
|
+
env = Object.fromEntries(Object.entries(env).map(([name, value]) => [name.toUpperCase(), value]));
|
|
1326
|
+
}
|
|
108
1327
|
if (Object.prototype.hasOwnProperty.call(env, 'ARCHIFY_CHROME')) {
|
|
109
|
-
return
|
|
1328
|
+
return resolveExecutable(env.ARCHIFY_CHROME, platform);
|
|
110
1329
|
}
|
|
111
1330
|
|
|
112
1331
|
const fixed = [];
|
|
@@ -117,41 +1336,91 @@ export function findChrome({ env = process.env, platform = process.platform } =
|
|
|
117
1336
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
118
1337
|
);
|
|
119
1338
|
} else if (platform === 'win32') {
|
|
1339
|
+
const pathApi = path.win32;
|
|
120
1340
|
for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) {
|
|
121
1341
|
fixed.push(
|
|
122
|
-
|
|
123
|
-
|
|
1342
|
+
pathApi.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
1343
|
+
pathApi.join(root, 'Chromium', 'Application', 'chrome.exe'),
|
|
124
1344
|
);
|
|
125
1345
|
}
|
|
1346
|
+
commands.push('chrome', 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser');
|
|
126
1347
|
} else {
|
|
127
1348
|
commands.push('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser');
|
|
128
1349
|
}
|
|
129
1350
|
|
|
130
1351
|
for (const candidate of fixed) {
|
|
131
|
-
const resolved =
|
|
1352
|
+
const resolved = resolveExecutable(candidate, platform);
|
|
132
1353
|
if (resolved) return resolved;
|
|
133
1354
|
}
|
|
134
1355
|
for (const command of commands) {
|
|
135
|
-
const resolved = findOnPath(command, env, platform);
|
|
1356
|
+
const resolved = findOnPath(command, env, platform, resolveExecutable);
|
|
1357
|
+
if (resolved) return resolved;
|
|
1358
|
+
}
|
|
1359
|
+
// Preserve every Chrome/Chromium candidate's priority, including portable
|
|
1360
|
+
// PATH installations, before falling back to Windows' bundled browser.
|
|
1361
|
+
if (platform === 'win32') {
|
|
1362
|
+
for (const root of [env['PROGRAMFILES(X86)'], env.PROGRAMFILES, env.LOCALAPPDATA].filter(Boolean)) {
|
|
1363
|
+
const candidate = path.win32.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe');
|
|
1364
|
+
const resolved = resolveExecutable(candidate, platform);
|
|
1365
|
+
if (resolved) return resolved;
|
|
1366
|
+
}
|
|
1367
|
+
return findOnPath('msedge', env, platform, resolveExecutable);
|
|
1368
|
+
}
|
|
1369
|
+
// macOS and Linux: probe Edge behind every Chrome/Chromium candidate, the
|
|
1370
|
+
// same way Windows does. #920 asked for `microsoft-edge`,
|
|
1371
|
+
// `microsoft-edge-stable`, and the macOS application bundle.
|
|
1372
|
+
if (platform === 'darwin') {
|
|
1373
|
+
const edgeApp = '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge';
|
|
1374
|
+
const resolved = resolveExecutable(edgeApp, platform);
|
|
1375
|
+
if (resolved) return resolved;
|
|
1376
|
+
}
|
|
1377
|
+
for (const command of ['microsoft-edge', 'microsoft-edge-stable']) {
|
|
1378
|
+
const resolved = findOnPath(command, env, platform, resolveExecutable);
|
|
136
1379
|
if (resolved) return resolved;
|
|
137
1380
|
}
|
|
138
1381
|
return null;
|
|
139
1382
|
}
|
|
140
1383
|
|
|
141
1384
|
class PipeCdp {
|
|
142
|
-
constructor(child, { failureDetails = () => '' } = {}) {
|
|
1385
|
+
constructor(child, { failureDetails = () => '', socketUrl = null } = {}) {
|
|
143
1386
|
this.child = child;
|
|
144
1387
|
this.failureDetails = failureDetails;
|
|
145
1388
|
this.nextId = 1;
|
|
146
1389
|
this.buffer = '';
|
|
1390
|
+
this.receivedBytes = 0;
|
|
1391
|
+
this.receivedMessages = 0;
|
|
1392
|
+
this.completedWrites = 0;
|
|
1393
|
+
this.writtenBytes = 0;
|
|
147
1394
|
this.pending = new Map();
|
|
148
1395
|
this.waiters = [];
|
|
1396
|
+
this.subscribers = new Set();
|
|
1397
|
+
if (socketUrl) {
|
|
1398
|
+
// Bun does not preserve Chrome's extra inherited pipe descriptors on macOS.
|
|
1399
|
+
// Keep the CDP protocol and receipts; use a private loopback WebSocket instead.
|
|
1400
|
+
this.readPipe = { readable: true, readableEnded: false, destroyed: false };
|
|
1401
|
+
this.writePipe = { writable: true, writableEnded: false, destroyed: false, writableLength: 0 };
|
|
1402
|
+
this.socketPromise = Promise.resolve(socketUrl).then(url => new Promise((resolve, reject) => {
|
|
1403
|
+
const socket = new WebSocket(url);
|
|
1404
|
+
socket.addEventListener('open', () => resolve(socket), { once: true });
|
|
1405
|
+
socket.addEventListener('message', event => this.consume(String(event.data) + '\0'));
|
|
1406
|
+
socket.addEventListener('error', () => { const error = new Error('Chrome WebSocket failed'); reject(error); this.failAll(error); });
|
|
1407
|
+
socket.addEventListener('close', () => this.failAll(new Error('Chrome WebSocket closed')));
|
|
1408
|
+
}));
|
|
1409
|
+
this.socketPromise.catch(error => this.failAll(error));
|
|
1410
|
+
this.writePipe.write = (message, callback) => {
|
|
1411
|
+
this.socketPromise.then(socket => { socket.send(message.slice(0, -1)); callback(); }, callback);
|
|
1412
|
+
};
|
|
1413
|
+
} else {
|
|
149
1414
|
this.writePipe = child.stdio[3];
|
|
150
1415
|
this.readPipe = child.stdio[4];
|
|
151
1416
|
this.readPipe.setEncoding('utf8');
|
|
152
1417
|
this.readPipe.on('data', (chunk) => this.consume(chunk));
|
|
153
1418
|
this.writePipe.on('error', (error) => this.failAll(this.failure('write pipe', error)));
|
|
154
1419
|
this.readPipe.on('error', (error) => this.failAll(this.failure('read pipe', error)));
|
|
1420
|
+
this.readPipe.once('end', () => this.failAll(this.failure('read pipe', new Error('stream ended'))));
|
|
1421
|
+
this.readPipe.once('close', () => this.failAll(this.failure('read pipe', new Error('stream closed'))));
|
|
1422
|
+
this.writePipe.once('close', () => this.failAll(this.failure('write pipe', new Error('stream closed'))));
|
|
1423
|
+
}
|
|
155
1424
|
child.once('error', (error) => this.failAll(this.failure('process launch', error)));
|
|
156
1425
|
child.once('close', (code, signal) => {
|
|
157
1426
|
const ending = signal ? `signal ${signal}` : `exit code ${code}`;
|
|
@@ -162,13 +1431,20 @@ class PipeCdp {
|
|
|
162
1431
|
failure(stage, error) {
|
|
163
1432
|
const code = error?.code ? ` [${error.code}]` : '';
|
|
164
1433
|
const details = this.failureDetails();
|
|
165
|
-
|
|
1434
|
+
const failure = new Error([
|
|
166
1435
|
`Chrome DevTools ${stage} failed: ${error?.message || String(error)}${code}`,
|
|
167
1436
|
details,
|
|
1437
|
+
`Chrome transport: Node ${process.version}, libuv ${process.versions.uv}, ${process.platform}; pid=${this.child.pid ?? 'unavailable'}, exitCode=${this.child.exitCode}, signalCode=${this.child.signalCode}.`,
|
|
1438
|
+
`Chrome read pipe: readable=${this.readPipe.readable}, ended=${this.readPipe.readableEnded}, destroyed=${this.readPipe.destroyed}, receivedBytes=${this.receivedBytes}, messages=${this.receivedMessages}, bufferedBytes=${Buffer.byteLength(this.buffer)}.`,
|
|
1439
|
+
`Chrome write pipe: writable=${this.writePipe.writable}, ended=${this.writePipe.writableEnded}, destroyed=${this.writePipe.destroyed}, completedWrites=${this.completedWrites}, writtenBytes=${this.writtenBytes}, bufferedBytes=${this.writePipe.writableLength}.`,
|
|
168
1440
|
].filter(Boolean).join('\n'));
|
|
1441
|
+
if (error?.code) failure.code = error.code;
|
|
1442
|
+
if (error?.method) failure.method = error.method;
|
|
1443
|
+
return failure;
|
|
169
1444
|
}
|
|
170
1445
|
|
|
171
1446
|
consume(chunk) {
|
|
1447
|
+
this.receivedBytes += Buffer.byteLength(chunk);
|
|
172
1448
|
this.buffer += chunk;
|
|
173
1449
|
let boundary;
|
|
174
1450
|
while ((boundary = this.buffer.indexOf('\0')) >= 0) {
|
|
@@ -182,6 +1458,7 @@ class PipeCdp {
|
|
|
182
1458
|
this.failAll(new Error(`Chrome DevTools returned invalid JSON: ${error.message}`));
|
|
183
1459
|
continue;
|
|
184
1460
|
}
|
|
1461
|
+
this.receivedMessages += 1;
|
|
185
1462
|
if (message.id) {
|
|
186
1463
|
const pending = this.pending.get(message.id);
|
|
187
1464
|
if (!pending) continue;
|
|
@@ -191,6 +1468,7 @@ class PipeCdp {
|
|
|
191
1468
|
else pending.resolve(message.result || {});
|
|
192
1469
|
continue;
|
|
193
1470
|
}
|
|
1471
|
+
for (const subscriber of this.subscribers) subscriber(message);
|
|
194
1472
|
for (const waiter of [...this.waiters]) {
|
|
195
1473
|
if (waiter.method !== message.method) continue;
|
|
196
1474
|
if (waiter.sessionId && waiter.sessionId !== message.sessionId) continue;
|
|
@@ -201,6 +1479,11 @@ class PipeCdp {
|
|
|
201
1479
|
}
|
|
202
1480
|
}
|
|
203
1481
|
|
|
1482
|
+
subscribe(callback) {
|
|
1483
|
+
this.subscribers.add(callback);
|
|
1484
|
+
return () => this.subscribers.delete(callback);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
204
1487
|
send(method, params = {}, sessionId = undefined, timeoutMs = 15000) {
|
|
205
1488
|
const id = this.nextId++;
|
|
206
1489
|
const message = { id, method, params };
|
|
@@ -208,12 +1491,21 @@ class PipeCdp {
|
|
|
208
1491
|
return new Promise((resolve, reject) => {
|
|
209
1492
|
const timer = setTimeout(() => {
|
|
210
1493
|
this.pending.delete(id);
|
|
211
|
-
|
|
1494
|
+
const error = new Error(`${method}: timed out after ${timeoutMs}ms`);
|
|
1495
|
+
error.code = 'ERR_CHROME_CDP_TIMEOUT';
|
|
1496
|
+
error.method = method;
|
|
1497
|
+
reject(this.failure('protocol timeout', error));
|
|
212
1498
|
}, timeoutMs);
|
|
213
1499
|
this.pending.set(id, { method, resolve, reject, timer });
|
|
214
1500
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
1501
|
+
const serialized = `${JSON.stringify(message)}\0`;
|
|
1502
|
+
this.writePipe.write(serialized, (error) => {
|
|
1503
|
+
if (error) {
|
|
1504
|
+
this.failAll(this.failure('write pipe', error));
|
|
1505
|
+
} else {
|
|
1506
|
+
this.completedWrites += 1;
|
|
1507
|
+
this.writtenBytes += Buffer.byteLength(serialized);
|
|
1508
|
+
}
|
|
217
1509
|
});
|
|
218
1510
|
} catch (error) {
|
|
219
1511
|
this.failAll(this.failure('write pipe', error));
|
|
@@ -226,7 +1518,7 @@ class PipeCdp {
|
|
|
226
1518
|
const waiter = { method, sessionId, resolve, reject, timer: null };
|
|
227
1519
|
waiter.timer = setTimeout(() => {
|
|
228
1520
|
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
|
229
|
-
reject(new Error(`${method}: event timed out after ${timeoutMs}ms`));
|
|
1521
|
+
reject(this.failure('event timeout', new Error(`${method}: event timed out after ${timeoutMs}ms`)));
|
|
230
1522
|
}, timeoutMs);
|
|
231
1523
|
this.waiters.push(waiter);
|
|
232
1524
|
});
|
|
@@ -289,16 +1581,37 @@ async function evaluate(cdp, sessionId, expression, awaitPromise = false) {
|
|
|
289
1581
|
return response.result?.value;
|
|
290
1582
|
}
|
|
291
1583
|
|
|
1584
|
+
function browserEnvironment(env, platform) {
|
|
1585
|
+
if (platform !== 'win32') return env;
|
|
1586
|
+
// Agent-launched Git Bash can inherit this app-health shim. Edge then
|
|
1587
|
+
// relaunches and loses the DevTools pipes (#790). Change the browser's copy
|
|
1588
|
+
// only, and leave unknown/combined layers (including privilege settings) alone.
|
|
1589
|
+
return Object.fromEntries(Object.entries(env).filter(([name, value]) =>
|
|
1590
|
+
!(name.toUpperCase() === '__COMPAT_LAYER'
|
|
1591
|
+
&& /^\s*DetectorsAppHealth\s*$/i.test(String(value)))));
|
|
1592
|
+
}
|
|
1593
|
+
|
|
292
1594
|
export class ChromeVisualBrowser {
|
|
1595
|
+
#profile;
|
|
293
1596
|
constructor(chromePath, {
|
|
1597
|
+
platform = process.platform,
|
|
294
1598
|
env = process.env,
|
|
295
1599
|
getuid = typeof process.getuid === 'function' ? () => process.getuid() : null,
|
|
296
1600
|
spawnImpl = spawn,
|
|
1601
|
+
startupTimeoutMs = CHROME_STARTUP_TIMEOUT_MS,
|
|
1602
|
+
shutdownTimeoutMs = 1500,
|
|
297
1603
|
} = {}) {
|
|
298
|
-
this
|
|
1604
|
+
this.#profile = createOwnedTempDirectory('archify-visual-check-profile-');
|
|
1605
|
+
this.profileRoot = this.#profile.path;
|
|
1606
|
+
this.shutdownTimeoutMs = shutdownTimeoutMs;
|
|
299
1607
|
this.stderr = '';
|
|
300
1608
|
const args = chromeVisualBrowserArgs(this.profileRoot, { env, getuid });
|
|
301
|
-
|
|
1609
|
+
const websocket = Boolean(process.versions.bun) && spawnImpl === spawn;
|
|
1610
|
+
if (websocket) args[args.indexOf('--remote-debugging-pipe')] = '--remote-debugging-port=0';
|
|
1611
|
+
this.child = spawnImpl(chromePath, args, {
|
|
1612
|
+
stdio: websocket ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
|
|
1613
|
+
env: browserEnvironment(env, platform),
|
|
1614
|
+
});
|
|
302
1615
|
this.child.stderr.setEncoding('utf8');
|
|
303
1616
|
this.child.stderr.on('data', (chunk) => {
|
|
304
1617
|
this.stderr = `${this.stderr}${chunk}`.slice(-8000);
|
|
@@ -307,6 +1620,7 @@ export class ChromeVisualBrowser {
|
|
|
307
1620
|
this.stderr = `${this.stderr}\nChrome stderr stream failed: ${error.message}`.trim().slice(-8000);
|
|
308
1621
|
});
|
|
309
1622
|
this.cdp = new PipeCdp(this.child, {
|
|
1623
|
+
socketUrl: websocket ? this.websocketUrl(startupTimeoutMs) : null,
|
|
310
1624
|
failureDetails: () => {
|
|
311
1625
|
const exit = this.child.signalCode
|
|
312
1626
|
? `signal ${this.child.signalCode}`
|
|
@@ -315,19 +1629,44 @@ export class ChromeVisualBrowser {
|
|
|
315
1629
|
return [
|
|
316
1630
|
`Chrome process: ${exit}.`,
|
|
317
1631
|
stderr ? `Chrome stderr:\n${stderr}` : '',
|
|
1632
|
+
platform === 'win32' && this.cdp?.receivedBytes === 0
|
|
1633
|
+
? 'Windows browser startup may have relaunched before connecting to the DevTools pipe. '
|
|
1634
|
+
+ 'Retry from a non-elevated PowerShell opened normally, outside an elevated or compatibility-mode parent. '
|
|
1635
|
+
+ 'Archify does not disable browser de-elevation or the sandbox to preserve pipe handles.'
|
|
1636
|
+
: '',
|
|
318
1637
|
].filter(Boolean).join('\n');
|
|
319
1638
|
},
|
|
320
1639
|
});
|
|
1640
|
+
this.startupTimeoutMs = startupTimeoutMs;
|
|
321
1641
|
this.sessionPromise = this.attach();
|
|
322
1642
|
}
|
|
323
1643
|
|
|
1644
|
+
async websocketUrl(timeoutMs) {
|
|
1645
|
+
const started = Date.now();
|
|
1646
|
+
while (Date.now() - started < timeoutMs) {
|
|
1647
|
+
if (this.child.exitCode != null) throw new Error('Chrome exited before exposing DevTools');
|
|
1648
|
+
try {
|
|
1649
|
+
const lines = fs.readFileSync(path.join(this.profileRoot, 'DevToolsActivePort'), 'utf8').trim().split('\n');
|
|
1650
|
+
if (/^\d+$/.test(lines[0]) && lines[1]?.startsWith('/devtools/browser/')) return `ws://127.0.0.1:${lines[0]}${lines[1]}`;
|
|
1651
|
+
} catch {}
|
|
1652
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1653
|
+
}
|
|
1654
|
+
throw new Error('Chrome did not expose its private DevTools endpoint');
|
|
1655
|
+
}
|
|
1656
|
+
|
|
324
1657
|
async attach() {
|
|
325
|
-
|
|
1658
|
+
// Process launch can be delayed substantially on a busy desktop. Keep the
|
|
1659
|
+
// longer allowance inside one gate invocation so an authoring agent does
|
|
1660
|
+
// not turn startup jitter into repeated tool calls or candidate edits.
|
|
1661
|
+
const targets = await this.cdp.send(
|
|
1662
|
+
'Target.getTargets', {}, undefined, this.startupTimeoutMs,
|
|
1663
|
+
);
|
|
326
1664
|
let target = targets.targetInfos?.find((item) => item.type === 'page');
|
|
327
1665
|
if (!target) {
|
|
328
1666
|
const created = await this.cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
329
1667
|
target = { targetId: created.targetId };
|
|
330
1668
|
}
|
|
1669
|
+
this.targetId = target.targetId;
|
|
331
1670
|
const attached = await this.cdp.send('Target.attachToTarget', {
|
|
332
1671
|
targetId: target.targetId,
|
|
333
1672
|
flatten: true,
|
|
@@ -337,7 +1676,21 @@ export class ChromeVisualBrowser {
|
|
|
337
1676
|
return attached.sessionId;
|
|
338
1677
|
}
|
|
339
1678
|
|
|
340
|
-
async
|
|
1679
|
+
async resetPage() {
|
|
1680
|
+
await this.sessionPromise;
|
|
1681
|
+
const previousTarget = this.targetId;
|
|
1682
|
+
const created = await this.cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
1683
|
+
const attached = await this.cdp.send('Target.attachToTarget', { targetId: created.targetId, flatten: true });
|
|
1684
|
+
await this.cdp.send('Page.enable', {}, attached.sessionId);
|
|
1685
|
+
await this.cdp.send('Runtime.enable', {}, attached.sessionId);
|
|
1686
|
+
this.targetId = created.targetId;
|
|
1687
|
+
this.sessionPromise = Promise.resolve(attached.sessionId);
|
|
1688
|
+
if (previousTarget) await this.cdp.send('Target.closeTarget', { targetId: previousTarget });
|
|
1689
|
+
await this.cdp.send('Target.activateTarget', { targetId: this.targetId });
|
|
1690
|
+
await this.cdp.send('Page.bringToFront', {}, attached.sessionId);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
async inspect({ artifactPath, width, height, theme, screenshotPath, writeScreenshot }) {
|
|
341
1694
|
const sessionId = await this.sessionPromise;
|
|
342
1695
|
await this.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
343
1696
|
width,
|
|
@@ -346,9 +1699,13 @@ export class ChromeVisualBrowser {
|
|
|
346
1699
|
mobile: false,
|
|
347
1700
|
}, sessionId);
|
|
348
1701
|
|
|
349
|
-
const url =
|
|
1702
|
+
const url = pathToFileURL(artifactPath);
|
|
350
1703
|
url.searchParams.set('theme', theme);
|
|
351
1704
|
const loaded = this.cdp.waitFor('Page.loadEventFired', sessionId);
|
|
1705
|
+
// Navigation can fail before this waiter is awaited. Attach a rejection
|
|
1706
|
+
// handler immediately so a later load failure never escapes as an
|
|
1707
|
+
// unhandled rejection; awaiting `loaded` below still reports it normally.
|
|
1708
|
+
loaded.catch(() => {});
|
|
352
1709
|
const navigation = await this.cdp.send('Page.navigate', { url: url.href }, sessionId);
|
|
353
1710
|
if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`);
|
|
354
1711
|
await loaded;
|
|
@@ -359,6 +1716,9 @@ export class ChromeVisualBrowser {
|
|
|
359
1716
|
var fontsReady = document.fonts && document.fonts.ready
|
|
360
1717
|
? document.fonts.ready.catch(function () {})
|
|
361
1718
|
: Promise.resolve();
|
|
1719
|
+
if (window.Archify && Archify.layoutStability && typeof Archify.layoutStability.whenStable === 'function') {
|
|
1720
|
+
return fontsReady.then(function () { return Archify.layoutStability.whenStable(); });
|
|
1721
|
+
}
|
|
362
1722
|
return fontsReady.then(function () {
|
|
363
1723
|
if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') {
|
|
364
1724
|
return Archify.readerLayout.whenStable();
|
|
@@ -396,22 +1756,40 @@ export class ChromeVisualBrowser {
|
|
|
396
1756
|
var viewBoxWidth = viewBox ? viewBox.width : 0;
|
|
397
1757
|
var scale = viewBoxWidth > 0 ? Math.min(1, diagramWidth / viewBoxWidth) : 0;
|
|
398
1758
|
var minimum = null;
|
|
1759
|
+
var minimumNonEdge = null;
|
|
1760
|
+
var minimumEdge = null;
|
|
399
1761
|
if (svg && scale > 0) {
|
|
400
|
-
Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"]')).forEach(function (text) {
|
|
1762
|
+
Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"], g[data-detail="context"] text')).forEach(function (text) {
|
|
1763
|
+
if (text.getAttribute('data-detail') === 'fine' || text.closest('[data-detail="fine"]')) return;
|
|
1764
|
+
var edge = text.closest('[data-edge-from][data-edge-to]');
|
|
1765
|
+
var node = text.closest('[data-node-id]');
|
|
1766
|
+
var context = text.getAttribute('data-detail') === 'context' || Boolean(text.closest('g[data-detail="context"]'));
|
|
401
1767
|
var detail = text.hasAttribute('data-node-label')
|
|
402
1768
|
? 'primary'
|
|
403
|
-
: text.hasAttribute('data-boundary-label') ? 'boundary'
|
|
404
|
-
|
|
1769
|
+
: text.hasAttribute('data-boundary-label') ? 'boundary'
|
|
1770
|
+
: context && edge ? 'edge' : 'context';
|
|
1771
|
+
if (detail === 'context' && !node) return;
|
|
405
1772
|
var sourceFontPx = parseFloat(text.getAttribute('font-size') || '');
|
|
406
1773
|
if (!Number.isFinite(sourceFontPx)) return;
|
|
407
1774
|
var projectedFontPx = sourceFontPx * scale;
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
1775
|
+
var entry = {
|
|
1776
|
+
text: (text.textContent || '').trim(),
|
|
1777
|
+
detail: detail,
|
|
1778
|
+
owner: edge ? {
|
|
1779
|
+
kind: 'edge',
|
|
1780
|
+
id: edge.getAttribute('data-edge-id'),
|
|
1781
|
+
from: edge.getAttribute('data-edge-from'),
|
|
1782
|
+
to: edge.getAttribute('data-edge-to')
|
|
1783
|
+
} : node ? { kind: 'node', id: node.getAttribute('data-node-id') }
|
|
1784
|
+
: detail === 'boundary' ? { kind: 'boundary', id: null } : null,
|
|
1785
|
+
sourceFontPx: sourceFontPx,
|
|
1786
|
+
projectedFontPx: projectedFontPx
|
|
1787
|
+
};
|
|
1788
|
+
if (!minimum || projectedFontPx < minimum.projectedFontPx) minimum = entry;
|
|
1789
|
+
if (detail === 'edge') {
|
|
1790
|
+
if (!minimumEdge || projectedFontPx < minimumEdge.projectedFontPx) minimumEdge = entry;
|
|
1791
|
+
} else if (!minimumNonEdge || projectedFontPx < minimumNonEdge.projectedFontPx) {
|
|
1792
|
+
minimumNonEdge = entry;
|
|
415
1793
|
}
|
|
416
1794
|
});
|
|
417
1795
|
}
|
|
@@ -426,24 +1804,96 @@ export class ChromeVisualBrowser {
|
|
|
426
1804
|
&& typeof Archify.viewerChromeLayout.stageRect === 'function'
|
|
427
1805
|
? Archify.viewerChromeLayout.stageRect()
|
|
428
1806
|
: (stage ? stage.getBoundingClientRect() : null);
|
|
429
|
-
|
|
1807
|
+
// The dock may lift to the viewport floor while the page scrolls; the
|
|
1808
|
+
// stage contract is about its resting position.
|
|
1809
|
+
var navigationDockRect = !navigationDock ? null
|
|
1810
|
+
: window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.dockRect === 'function'
|
|
1811
|
+
? Archify.viewerChromeLayout.dockRect()
|
|
1812
|
+
: navigationDock.getBoundingClientRect();
|
|
430
1813
|
var stageDockIntersectionArea = intersectionArea(stageRect, navigationDockRect);
|
|
431
1814
|
var viewerChromeReceipt = window.Archify && Archify.viewerChromeLayout
|
|
432
1815
|
&& typeof Archify.viewerChromeLayout.receipt === 'function'
|
|
433
1816
|
? Archify.viewerChromeLayout.receipt()
|
|
434
1817
|
: null;
|
|
1818
|
+
// Read rendered boxes only; source ownership, offsets and hard pins are unknown.
|
|
1819
|
+
var workflowLanes = svg ? Array.from(svg.querySelectorAll('rect[data-composition-frame-kind="lane"]')).map(function (lane) {
|
|
1820
|
+
var rect = lane.getBoundingClientRect();
|
|
1821
|
+
var members = Array.from(svg.querySelectorAll('g[data-node-id]')).map(function (node) {
|
|
1822
|
+
var box = node.querySelector('rect');
|
|
1823
|
+
if (!box) return null;
|
|
1824
|
+
var bounds = box.getBoundingClientRect();
|
|
1825
|
+
if (bounds.top < rect.top - 1 || bounds.bottom > rect.bottom + 1
|
|
1826
|
+
|| bounds.left < rect.left - 1 || bounds.right > rect.right + 1) return null;
|
|
1827
|
+
return { id: node.getAttribute('data-node-id'), top: bounds.top, bottom: bounds.bottom };
|
|
1828
|
+
}).filter(Boolean);
|
|
1829
|
+
var top = members.length ? members.reduce(function (minimum, node) { return Math.min(minimum, node.top); }, Infinity) : null;
|
|
1830
|
+
var bottom = members.length ? members.reduce(function (maximum, node) { return Math.max(maximum, node.bottom); }, -Infinity) : null;
|
|
1831
|
+
return {
|
|
1832
|
+
frameId: lane.getAttribute('data-composition-frame-id'),
|
|
1833
|
+
heightPx: Math.round(rect.height),
|
|
1834
|
+
nodeCount: members.length,
|
|
1835
|
+
nodeIds: members.slice(0, 12).map(function (node) { return node.id; }),
|
|
1836
|
+
nodeSpanPx: top === null ? null : Math.round(bottom - top),
|
|
1837
|
+
spaceAboveNodesPx: top === null ? null : Math.round(top - rect.top),
|
|
1838
|
+
spaceBelowNodesPx: bottom === null ? null : Math.round(rect.bottom - bottom)
|
|
1839
|
+
};
|
|
1840
|
+
}).sort(function (a, b) { return b.heightPx - a.heightPx; }).slice(0, 6) : [];
|
|
1841
|
+
// Vertical page budget: every block that stacks above or below the SVG,
|
|
1842
|
+
// so an overflow receipt can say which part must give up how many pixels.
|
|
1843
|
+
function outerHeight(element) {
|
|
1844
|
+
if (!element) return 0;
|
|
1845
|
+
var style = window.getComputedStyle(element);
|
|
1846
|
+
if (element.hidden || style.display === 'none') return 0;
|
|
1847
|
+
return element.getBoundingClientRect().height
|
|
1848
|
+
+ (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0);
|
|
1849
|
+
}
|
|
1850
|
+
var bodyStyle = window.getComputedStyle(document.body);
|
|
1851
|
+
var diagramStyle = diagram ? window.getComputedStyle(diagram) : null;
|
|
1852
|
+
var svgHeight = svg ? svg.getBoundingClientRect().height : 0;
|
|
1853
|
+
var notesBelowFold = Boolean(reader) && document.documentElement.getAttribute('data-reader-rail') === 'bottom';
|
|
1854
|
+
var pageComposition = {
|
|
1855
|
+
bodyPaddingPx: Math.round((parseFloat(bodyStyle.paddingTop) || 0) + (parseFloat(bodyStyle.paddingBottom) || 0)),
|
|
1856
|
+
headerPx: Math.round(outerHeight(reader && reader.querySelector('.header'))),
|
|
1857
|
+
diagramChromePx: Math.round(outerHeight(diagram) - svgHeight),
|
|
1858
|
+
svgPx: Math.round(svgHeight),
|
|
1859
|
+
cardsPx: notesBelowFold ? 0 : Math.round(outerHeight(reader && reader.querySelector('.cards'))),
|
|
1860
|
+
viewBoxHeight: viewBox ? viewBox.height : 0
|
|
1861
|
+
};
|
|
1862
|
+
var svgRect = svg && svg.getBoundingClientRect();
|
|
1863
|
+
var diagramRect = diagram && diagram.getBoundingClientRect();
|
|
1864
|
+
var documentScrollUnclipped = Boolean(svgRect && diagramRect
|
|
1865
|
+
&& svgRect.top >= diagramRect.top - 1 && svgRect.left >= diagramRect.left - 1
|
|
1866
|
+
&& svgRect.bottom <= diagramRect.bottom + 1 && svgRect.right <= diagramRect.right + 1
|
|
1867
|
+
&& diagram.scrollHeight <= diagram.clientHeight + 1
|
|
1868
|
+
&& diagram.scrollWidth <= diagram.clientWidth + 1
|
|
1869
|
+
&& [document.documentElement, document.body].every(function (element) {
|
|
1870
|
+
return !['hidden', 'clip'].includes(window.getComputedStyle(element).overflowY);
|
|
1871
|
+
}));
|
|
435
1872
|
return {
|
|
1873
|
+
pageComposition: pageComposition,
|
|
436
1874
|
innerWidth: window.innerWidth,
|
|
437
1875
|
innerHeight: window.innerHeight,
|
|
438
1876
|
scrollWidth: Math.ceil(document.documentElement.scrollWidth),
|
|
439
|
-
|
|
1877
|
+
// Bottom notes and index are reading material below the fold; the
|
|
1878
|
+
// Reader excludes them from the one-screen fit, and so does this check.
|
|
1879
|
+
scrollHeight: Math.max(window.innerHeight, Math.ceil(document.documentElement.scrollHeight - (
|
|
1880
|
+
notesBelowFold ? outerHeight(reader.querySelector('.reader-rail')) : 0))),
|
|
440
1881
|
resolvedTheme: document.documentElement.getAttribute('data-theme') || '',
|
|
1882
|
+
readerLayout: document.documentElement.getAttribute('data-reader-layout') || null,
|
|
1883
|
+
readerOverflow: document.documentElement.getAttribute('data-reader-overflow') || null,
|
|
1884
|
+
readerFit: svg ? svg.getAttribute('data-reader-fit') : null,
|
|
1885
|
+
diagramType: svg ? svg.getAttribute('data-diagram-type') : null,
|
|
1886
|
+
documentScrollUnclipped: documentScrollUnclipped,
|
|
441
1887
|
readerWidth: reader ? reader.getBoundingClientRect().width : 0,
|
|
442
1888
|
diagramWidth: diagramWidth,
|
|
443
1889
|
viewBoxWidth: viewBoxWidth,
|
|
1890
|
+
workflowLanes: workflowLanes,
|
|
444
1891
|
minimumProjectedNodeTextPx: minimum ? minimum.projectedFontPx : null,
|
|
1892
|
+
minimumProjectedNonEdgeTextPx: minimumNonEdge ? minimumNonEdge.projectedFontPx : null,
|
|
1893
|
+
minimumProjectedEdgeTextPx: minimumEdge ? minimumEdge.projectedFontPx : null,
|
|
445
1894
|
minimumProjectedNodeText: minimum ? minimum.text : null,
|
|
446
1895
|
minimumProjectedNodeTextDetail: minimum ? minimum.detail : null,
|
|
1896
|
+
minimumProjectedNodeTextOwner: minimum ? minimum.owner : null,
|
|
447
1897
|
hasLegend: Boolean(legendRect && legendRect.width && legendRect.height),
|
|
448
1898
|
hasNavigationDock: Boolean(navigationDockRect && navigationDockRect.width && navigationDockRect.height),
|
|
449
1899
|
legendDockIntersectionArea: stageDockIntersectionArea > 0
|
|
@@ -467,31 +1917,59 @@ export class ChromeVisualBrowser {
|
|
|
467
1917
|
captureBeyondViewport: false,
|
|
468
1918
|
}, sessionId, 20000);
|
|
469
1919
|
if (!capture.data) throw new Error('Chrome returned an empty screenshot.');
|
|
470
|
-
|
|
1920
|
+
const screenshot = Buffer.from(capture.data, 'base64');
|
|
1921
|
+
if (writeScreenshot) writeScreenshot(screenshot);
|
|
1922
|
+
else fs.writeFileSync(screenshotPath, screenshot, { flag: 'wx' });
|
|
471
1923
|
}
|
|
472
1924
|
return metrics;
|
|
473
1925
|
}
|
|
474
1926
|
|
|
475
|
-
|
|
1927
|
+
close() {
|
|
1928
|
+
if (!this.closePromise) this.closePromise = this.finishClose();
|
|
1929
|
+
return this.closePromise;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async finishClose() {
|
|
476
1933
|
this.cdp.failAll(new Error('visual-check finished'));
|
|
477
|
-
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
478
|
-
this.child.kill('SIGTERM');
|
|
479
|
-
await new Promise((resolve) => {
|
|
480
|
-
const timer = setTimeout(() => {
|
|
481
|
-
if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGKILL');
|
|
482
|
-
resolve();
|
|
483
|
-
}, 1500);
|
|
484
|
-
this.child.once('exit', () => {
|
|
485
|
-
clearTimeout(timer);
|
|
486
|
-
resolve();
|
|
487
|
-
});
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
1934
|
try {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1935
|
+
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
1936
|
+
const exited = () => this.child.exitCode !== null || this.child.signalCode !== null;
|
|
1937
|
+
const signalAndWait = (signal) => new Promise((resolve, reject) => {
|
|
1938
|
+
let timer;
|
|
1939
|
+
const finish = () => {
|
|
1940
|
+
clearTimeout(timer);
|
|
1941
|
+
this.child.removeListener('exit', finish);
|
|
1942
|
+
resolve(true);
|
|
1943
|
+
};
|
|
1944
|
+
this.child.once('exit', finish);
|
|
1945
|
+
timer = setTimeout(() => {
|
|
1946
|
+
this.child.removeListener('exit', finish);
|
|
1947
|
+
resolve(exited());
|
|
1948
|
+
}, this.shutdownTimeoutMs);
|
|
1949
|
+
try {
|
|
1950
|
+
this.child.kill(signal);
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
clearTimeout(timer);
|
|
1953
|
+
this.child.removeListener('exit', finish);
|
|
1954
|
+
reject(error);
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
if (!await signalAndWait('SIGTERM') && !await signalAndWait('SIGKILL')) {
|
|
1958
|
+
const error = new Error(`Chrome did not exit; retained temporary profile "${this.#profile.path}".`);
|
|
1959
|
+
error.code = 'ARCHIFY_TEMP_CLEANUP';
|
|
1960
|
+
error.directory = this.#profile.path;
|
|
1961
|
+
throw error;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
} finally {
|
|
1965
|
+
// `exit` only reports that Chrome's main process is gone. A helper may
|
|
1966
|
+
// still hold an inherited pipe open, so explicitly release every stream
|
|
1967
|
+
// this browser instance owns before its test cleanup hook resolves.
|
|
1968
|
+
for (const stream of [this.child.stderr, this.child.stdio[3], this.child.stdio[4]]) {
|
|
1969
|
+
if (stream && !stream.destroyed) stream.destroy();
|
|
1970
|
+
}
|
|
494
1971
|
}
|
|
1972
|
+
await this.#profile.cleanup();
|
|
495
1973
|
}
|
|
496
1974
|
}
|
|
497
1975
|
|
|
@@ -505,8 +1983,29 @@ function observation({ width, height, theme, metrics }) {
|
|
|
505
1983
|
const minimumProjectedNodeTextPx = metrics.minimumProjectedNodeTextPx == null
|
|
506
1984
|
? null
|
|
507
1985
|
: Number(metrics.minimumProjectedNodeTextPx);
|
|
1986
|
+
const minimumProjectedNonEdgeTextPx = metrics.minimumProjectedNonEdgeTextPx == null
|
|
1987
|
+
? null
|
|
1988
|
+
: Number(metrics.minimumProjectedNonEdgeTextPx);
|
|
1989
|
+
const minimumProjectedEdgeTextPx = metrics.minimumProjectedEdgeTextPx == null
|
|
1990
|
+
? null
|
|
1991
|
+
: Number(metrics.minimumProjectedEdgeTextPx);
|
|
508
1992
|
const readabilityOk = minimumProjectedNodeTextPx == null
|
|
509
1993
|
|| minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX;
|
|
1994
|
+
const readerLayout = metrics.readerLayout || null;
|
|
1995
|
+
const readerOverflow = metrics.readerOverflow || null;
|
|
1996
|
+
const readerFit = metrics.readerFit || null;
|
|
1997
|
+
const verticalScrollAccepted = Boolean(
|
|
1998
|
+
overflowY
|
|
1999
|
+
&& !overflowX
|
|
2000
|
+
&& readabilityOk
|
|
2001
|
+
&& Number.isFinite(minimumProjectedNodeTextPx)
|
|
2002
|
+
&& ((readerLayout === 'adaptive' && readerOverflow === 'authored' && (readerFit === 'intrinsic-height' || readerFit === 'width-first'))
|
|
2003
|
+
|| (readerFit === 'authored-height' && metrics.diagramType === 'architecture'
|
|
2004
|
+
&& metrics.documentScrollUnclipped === true))
|
|
2005
|
+
);
|
|
2006
|
+
const authoredClipped = readerFit === 'authored-height' && metrics.diagramType === 'architecture'
|
|
2007
|
+
&& metrics.documentScrollUnclipped !== true;
|
|
2008
|
+
const containmentOk = !authoredClipped && !overflowX && (!overflowY || verticalScrollAccepted);
|
|
510
2009
|
const legendDockIntersectionArea = Number(metrics.legendDockIntersectionArea) || 0;
|
|
511
2010
|
const dockStageIntersectionArea = Number(metrics.dockStageIntersectionArea) || 0;
|
|
512
2011
|
const dockStageGap = metrics.dockStageGap == null ? null : Number(metrics.dockStageGap);
|
|
@@ -530,13 +2029,26 @@ function observation({ width, height, theme, metrics }) {
|
|
|
530
2029
|
scrollHeight,
|
|
531
2030
|
overflowX,
|
|
532
2031
|
overflowY,
|
|
533
|
-
|
|
2032
|
+
verticalScrollAccepted,
|
|
2033
|
+
overflowDisposition: authoredClipped || overflowX || (overflowY && !verticalScrollAccepted)
|
|
2034
|
+
? 'unexpected-overflow'
|
|
2035
|
+
: verticalScrollAccepted ? 'readable-vertical-scroll' : 'contained',
|
|
2036
|
+
readerLayout,
|
|
2037
|
+
readerOverflow,
|
|
2038
|
+
readerFit,
|
|
2039
|
+
...(readerFit === 'authored-height' ? { diagramType: metrics.diagramType, documentScrollUnclipped: metrics.documentScrollUnclipped === true } : {}),
|
|
2040
|
+
ok: containmentOk,
|
|
534
2041
|
readerWidth: Number(metrics.readerWidth) || null,
|
|
535
2042
|
diagramWidth: Number(metrics.diagramWidth) || null,
|
|
2043
|
+
...(metrics.pageComposition ? { pageComposition: metrics.pageComposition } : {}),
|
|
536
2044
|
viewBoxWidth: Number(metrics.viewBoxWidth) || null,
|
|
2045
|
+
...(metrics.workflowLanes?.length ? { workflowLanes: metrics.workflowLanes } : {}),
|
|
537
2046
|
minimumProjectedNodeTextPx,
|
|
2047
|
+
minimumProjectedNonEdgeTextPx,
|
|
2048
|
+
minimumProjectedEdgeTextPx,
|
|
538
2049
|
minimumProjectedNodeText: metrics.minimumProjectedNodeText || null,
|
|
539
2050
|
minimumProjectedNodeTextDetail: metrics.minimumProjectedNodeTextDetail || null,
|
|
2051
|
+
minimumProjectedNodeTextOwner: metrics.minimumProjectedNodeTextOwner || null,
|
|
540
2052
|
minimumRequiredNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX,
|
|
541
2053
|
readabilityOk,
|
|
542
2054
|
hasLegend: Boolean(metrics.hasLegend),
|
|
@@ -557,14 +2069,14 @@ function contactSheetHtml({ artifactPath, receipt, screenshots }) {
|
|
|
557
2069
|
const cards = screenshots.map((entry) => `
|
|
558
2070
|
<figure>
|
|
559
2071
|
<img src="${htmlEscape(entry.file)}" alt="${htmlEscape(`${entry.theme} ${entry.width} by ${entry.height}`)}">
|
|
560
|
-
<figcaption><strong>${htmlEscape(entry.theme.toUpperCase())}</strong> · ${entry.width}×${entry.height} · containment ${entry.ok ? 'pass' : 'fail'}</figcaption>
|
|
2072
|
+
<figcaption><strong>${htmlEscape(entry.theme.toUpperCase())}</strong> · ${entry.width}×${entry.height} · containment ${entry.ok ? 'pass' : 'fail'}${entry.verticalScrollAccepted ? ' · readable vertical scroll' : ''}</figcaption>
|
|
561
2073
|
</figure>`).join('');
|
|
562
2074
|
return `<!doctype html>
|
|
563
2075
|
<html lang="en">
|
|
564
2076
|
<head>
|
|
565
|
-
<meta charset="utf-8"
|
|
2077
|
+
<meta charset="utf-8">${softwareNoticesHtml}
|
|
566
2078
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
567
|
-
<title>
|
|
2079
|
+
<title>Archify automated browser evidence · ${htmlEscape(path.basename(artifactPath))}</title>
|
|
568
2080
|
<style>
|
|
569
2081
|
*{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
|
570
2082
|
</style>
|
|
@@ -578,6 +2090,46 @@ function contactSheetHtml({ artifactPath, receipt, screenshots }) {
|
|
|
578
2090
|
`;
|
|
579
2091
|
}
|
|
580
2092
|
|
|
2093
|
+
const publishedBrowserEvidenceReceipts = new WeakSet();
|
|
2094
|
+
|
|
2095
|
+
// Keep detailed observations in the bound receipt; expose every review image and
|
|
2096
|
+
// every failure without repeating the same viewport metrics in CLI context.
|
|
2097
|
+
export function summarizeBrowserEvidence(receipt) {
|
|
2098
|
+
const directory = receipt.sidecars?.directory || path.dirname(receipt.artifact.path);
|
|
2099
|
+
const evidencePath = (file) => file ? path.resolve(directory, file) : undefined;
|
|
2100
|
+
const published = publishedBrowserEvidenceReceipts.has(receipt);
|
|
2101
|
+
return {
|
|
2102
|
+
schemaVersion: receipt.schemaVersion,
|
|
2103
|
+
command: receipt.command,
|
|
2104
|
+
ok: receipt.ok,
|
|
2105
|
+
status: receipt.status,
|
|
2106
|
+
evidenceKind: receipt.evidenceKind,
|
|
2107
|
+
visualReview: receipt.visualReview,
|
|
2108
|
+
artifact: receipt.artifact,
|
|
2109
|
+
provenance: receipt.provenance,
|
|
2110
|
+
deliveryReceiptId: receipt.deliveryReceiptId,
|
|
2111
|
+
state: receipt.state,
|
|
2112
|
+
chrome: receipt.chrome,
|
|
2113
|
+
checks: Object.fromEntries(['containment', 'readability', 'viewerChrome', 'themeStates', 'captures']
|
|
2114
|
+
.filter((key) => receipt[key])
|
|
2115
|
+
.map((key) => [key, receipt[key].status])),
|
|
2116
|
+
error: receipt.error,
|
|
2117
|
+
publication: receipt.publication,
|
|
2118
|
+
diagnostics: receipt.diagnostics || [],
|
|
2119
|
+
evidence: {
|
|
2120
|
+
receipt: published ? evidencePath(receipt.sidecars?.receipt) : undefined,
|
|
2121
|
+
contactSheet: published ? evidencePath(receipt.captures?.contactSheet) : undefined,
|
|
2122
|
+
screenshots: (published ? receipt.captures?.screenshots || [] : []).map((entry) => ({
|
|
2123
|
+
path: evidencePath(entry.file),
|
|
2124
|
+
width: entry.width,
|
|
2125
|
+
height: entry.height,
|
|
2126
|
+
theme: entry.theme,
|
|
2127
|
+
resolvedTheme: entry.resolvedTheme,
|
|
2128
|
+
})),
|
|
2129
|
+
},
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
|
|
581
2133
|
function viewportSubject(artifact, entry) {
|
|
582
2134
|
return {
|
|
583
2135
|
artifact,
|
|
@@ -589,13 +2141,92 @@ function failureDiagnostic({ code, message, subject, evidence, supportedFixes, s
|
|
|
589
2141
|
return { code, severity, message, subject, evidence, supportedFixes };
|
|
590
2142
|
}
|
|
591
2143
|
|
|
592
|
-
function
|
|
2144
|
+
function resolveSidecarDirectory(artifactPath, outputs, compareParents) {
|
|
2145
|
+
const artifact = path.resolve(artifactPath);
|
|
2146
|
+
const artifactDirectory = path.dirname(artifact);
|
|
2147
|
+
const sidecarDirectory = path.dirname(outputs.receipt);
|
|
2148
|
+
const comparison = compareParents(outputs.receipt, artifact);
|
|
2149
|
+
|
|
2150
|
+
const sidecars = {
|
|
2151
|
+
...(comparison?.status === 'different' ? { directory: sidecarDirectory } : {}),
|
|
2152
|
+
receipt: path.basename(outputs.receipt),
|
|
2153
|
+
...(outputs.capture === false ? {} : { contactSheet: path.basename(outputs.contactSheet) }),
|
|
2154
|
+
};
|
|
2155
|
+
if (comparison?.status === 'match' || comparison?.status === 'different') {
|
|
2156
|
+
return { ok: true, sidecars };
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
const reason = comparison?.reason || { code: 'invalid-path-identity-result' };
|
|
2160
|
+
const error = `visual-check could not determine the physical identity of its evidence directory "${sidecarDirectory}" relative to the artifact directory "${artifactDirectory}".`;
|
|
2161
|
+
return {
|
|
2162
|
+
ok: false,
|
|
2163
|
+
sidecars,
|
|
2164
|
+
error,
|
|
2165
|
+
diagnostic: failureDiagnostic({
|
|
2166
|
+
code: 'viewer/sidecar-directory-identity',
|
|
2167
|
+
message: error,
|
|
2168
|
+
subject: { artifact, sidecarDirectory },
|
|
2169
|
+
evidence: { comparison: reason },
|
|
2170
|
+
supportedFixes: [
|
|
2171
|
+
'use existing, accessible artifact and evidence directories, then rerun visual-check',
|
|
2172
|
+
],
|
|
2173
|
+
}),
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// Turn a measured vertical overflow into the pixel budget an author can act on:
|
|
2178
|
+
// which stacked block holds the height, and what the SVG or cards must shrink to.
|
|
2179
|
+
export function verticalBudgetFixes(entry) {
|
|
2180
|
+
const page = entry.pageComposition;
|
|
2181
|
+
if (!entry.overflowY || !page) return [];
|
|
2182
|
+
const excess = entry.scrollHeight - entry.innerHeight;
|
|
2183
|
+
const fixes = [];
|
|
2184
|
+
const stacked = `${page.bodyPaddingPx}px body padding + ${page.headerPx}px header + ${page.diagramChromePx}px diagram chrome + ${page.svgPx}px SVG + ${page.cardsPx}px cards = ${entry.scrollHeight}px against ${entry.innerHeight}px`;
|
|
2185
|
+
if (page.svgPx > 0 && page.viewBoxHeight > 0) {
|
|
2186
|
+
const targetSvg = page.svgPx - excess;
|
|
2187
|
+
const targetViewBoxHeight = Math.floor(page.viewBoxHeight * targetSvg / page.svgPx);
|
|
2188
|
+
if (entry.readerLayout === 'adaptive') {
|
|
2189
|
+
fixes.push(targetSvg > 0
|
|
2190
|
+
? `the page is ${excess}px too tall (${stacked}); the Reader already narrowed the stage to its ${entry.diagramWidth}px minimum, so the SVG height only follows meta.viewBox: keep every node and relationship and reduce the viewBox height to at most ${targetViewBoxHeight} (from ${page.viewBoxHeight}) by tightening vertical gaps and empty rows`
|
|
2191
|
+
: `the page is ${excess}px too tall (${stacked}) and the SVG alone exceeds the viewport at the minimum reader width; split the diagram into two`);
|
|
2192
|
+
} else {
|
|
2193
|
+
fixes.push(`the page is ${excess}px too tall (${stacked}); the SVG spans the full ${entry.diagramWidth}px reader width because its viewBox ratio is below 1.55 and it declares no automatic Reader fit, so the Reader can neither narrow it nor accept readable vertical scroll: either remove meta.viewBox so the renderer sizes the canvas and declares the fit, or make the viewBox at least 1.55x wider than tall`);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
if (page.cardsPx >= excess) {
|
|
2197
|
+
fixes.push(`alternatively, conclusion cards occupy ${page.cardsPx}px below the diagram; shortening card copy or dropping a card row so cards take at most ${page.cardsPx - excess}px also fits`);
|
|
2198
|
+
}
|
|
2199
|
+
return fixes;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function observationDiagnostics({ artifact, allObservations, readabilityObservations, command }) {
|
|
593
2203
|
const diagnostics = [];
|
|
594
2204
|
for (const entry of allObservations) {
|
|
2205
|
+
if (entry.resolvedTheme !== entry.theme) {
|
|
2206
|
+
diagnostics.push(failureDiagnostic({
|
|
2207
|
+
code: 'viewer/theme-state',
|
|
2208
|
+
message: `The rendered artifact resolved ${entry.resolvedTheme || 'no theme'} instead of the requested ${entry.theme} theme at ${entry.width}x${entry.height}.`,
|
|
2209
|
+
subject: viewportSubject(artifact, entry),
|
|
2210
|
+
evidence: {
|
|
2211
|
+
requestedTheme: entry.theme,
|
|
2212
|
+
resolvedTheme: entry.resolvedTheme,
|
|
2213
|
+
},
|
|
2214
|
+
supportedFixes: [
|
|
2215
|
+
`restore deterministic ${entry.theme} theme resolution, then rerun ${command}`,
|
|
2216
|
+
],
|
|
2217
|
+
}));
|
|
2218
|
+
}
|
|
595
2219
|
if (!entry.ok) {
|
|
2220
|
+
const authoredClipped = entry.readerFit === 'authored-height' && entry.diagramType === 'architecture'
|
|
2221
|
+
&& !entry.documentScrollUnclipped;
|
|
2222
|
+
const budgetFixes = authoredClipped
|
|
2223
|
+
? ['restore the full SVG inside the diagram panel and allow normal document scrolling; remove internal scrollers and clipping without changing authored geometry']
|
|
2224
|
+
: verticalBudgetFixes(entry);
|
|
596
2225
|
diagnostics.push(failureDiagnostic({
|
|
597
|
-
code: 'viewer/viewport-overflow',
|
|
598
|
-
message:
|
|
2226
|
+
code: authoredClipped ? 'viewer/diagram-clipped' : 'viewer/viewport-overflow',
|
|
2227
|
+
message: authoredClipped
|
|
2228
|
+
? `The authored Architecture canvas is clipped or cannot scroll in the document at ${entry.width}x${entry.height} (${entry.theme}).`
|
|
2229
|
+
: `The rendered artifact overflows the ${entry.width}x${entry.height} ${entry.theme} viewport.`,
|
|
599
2230
|
subject: viewportSubject(artifact, entry),
|
|
600
2231
|
evidence: {
|
|
601
2232
|
innerWidth: entry.innerWidth,
|
|
@@ -604,9 +2235,27 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat
|
|
|
604
2235
|
scrollHeight: entry.scrollHeight,
|
|
605
2236
|
overflowX: entry.overflowX,
|
|
606
2237
|
overflowY: entry.overflowY,
|
|
2238
|
+
overflowDisposition: entry.overflowDisposition,
|
|
2239
|
+
readerLayout: entry.readerLayout,
|
|
2240
|
+
readerOverflow: entry.readerOverflow,
|
|
2241
|
+
readerFit: entry.readerFit,
|
|
2242
|
+
...(authoredClipped ? { documentScrollUnclipped: false } : {}),
|
|
2243
|
+
...(entry.overflowY && entry.pageComposition ? {
|
|
2244
|
+
pageComposition: entry.pageComposition,
|
|
2245
|
+
pageCompositionMeasurement: 'CSS pixels at this viewport; body padding, header, diagram chrome, SVG and cards stack vertically and sum to scrollHeight',
|
|
2246
|
+
} : {}),
|
|
2247
|
+
...(entry.overflowY && entry.workflowLanes?.length ? {
|
|
2248
|
+
workflowLanes: entry.workflowLanes,
|
|
2249
|
+
measurement: 'CSS pixels; rendered node boxes geometrically contained in each lane frame; spaces include headers and routing, not guaranteed removable space',
|
|
2250
|
+
} : {}),
|
|
607
2251
|
},
|
|
608
2252
|
supportedFixes: [
|
|
609
|
-
|
|
2253
|
+
...budgetFixes,
|
|
2254
|
+
...(entry.overflowY && entry.workflowLanes?.length ? [
|
|
2255
|
+
'run validate workflow <source.json> --layout-json and compare the tallest rendered lane frames with source lanes, col and yOffset; frame IDs are rendered indices, not source lane IDs',
|
|
2256
|
+
'where ownership and explicit geometry permit, distribute stacked steps across logical columns and meaningful lanes before increasing yOffset; preserve nodes, branches, labels and hard pins',
|
|
2257
|
+
`read references/authoring-contract.md#workflow-viewport-repair, then validate and deliver the changed source before rerunning ${command} on the new artifact; this is inspection guidance, not a verified coordinate fix`,
|
|
2258
|
+
] : budgetFixes.length ? [] : [`contain the rendered layout within ${entry.width}x${entry.height}, then rerun ${command}`]),
|
|
610
2259
|
],
|
|
611
2260
|
}));
|
|
612
2261
|
}
|
|
@@ -617,7 +2266,7 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat
|
|
|
617
2266
|
subject: viewportSubject(artifact, entry),
|
|
618
2267
|
evidence: { legendDockIntersectionArea: entry.legendDockIntersectionArea },
|
|
619
2268
|
supportedFixes: [
|
|
620
|
-
|
|
2269
|
+
`move the SVG Legend or Viewer Dock until legendDockIntersectionArea is 0, then rerun ${command}`,
|
|
621
2270
|
],
|
|
622
2271
|
}));
|
|
623
2272
|
}
|
|
@@ -635,7 +2284,7 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat
|
|
|
635
2284
|
requiredDockStageGap: entry.requiredDockStageGap,
|
|
636
2285
|
},
|
|
637
2286
|
supportedFixes: [
|
|
638
|
-
`adjust Viewer stage reservation or clipping until dockStageGap is at least ${entry.requiredDockStageGap} and dockStageIntersectionArea is 0, then rerun
|
|
2287
|
+
`adjust Viewer stage reservation or clipping until dockStageGap is at least ${entry.requiredDockStageGap} and dockStageIntersectionArea is 0, then rerun ${command}`,
|
|
639
2288
|
],
|
|
640
2289
|
}));
|
|
641
2290
|
}
|
|
@@ -649,25 +2298,30 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat
|
|
|
649
2298
|
evidence: {
|
|
650
2299
|
text: entry.minimumProjectedNodeText,
|
|
651
2300
|
detail: entry.minimumProjectedNodeTextDetail,
|
|
2301
|
+
owner: entry.minimumProjectedNodeTextOwner,
|
|
652
2302
|
minimumProjectedNodeTextPx: entry.minimumProjectedNodeTextPx,
|
|
653
2303
|
minimumRequiredNodeTextPx: entry.minimumRequiredNodeTextPx,
|
|
654
2304
|
},
|
|
655
2305
|
supportedFixes: [
|
|
656
|
-
`increase projected node text to at least ${entry.minimumRequiredNodeTextPx}px at ${entry.width}x${entry.height}, then rerun
|
|
2306
|
+
`increase projected ${entry.minimumProjectedNodeTextOwner?.kind === 'edge' ? 'relationship label' : entry.minimumProjectedNodeTextOwner?.kind === 'boundary' ? 'boundary label' : 'node text'} to at least ${entry.minimumRequiredNodeTextPx}px at ${entry.width}x${entry.height}, then rerun ${command}`,
|
|
657
2307
|
],
|
|
658
2308
|
}));
|
|
659
2309
|
}
|
|
660
2310
|
return diagnostics;
|
|
661
2311
|
}
|
|
662
2312
|
|
|
663
|
-
function baseReceipt({ artifactPath, artifact,
|
|
2313
|
+
function baseReceipt({ artifactPath, artifact, sidecars, chrome, deliveryProvenance, command, capture }) {
|
|
664
2314
|
return {
|
|
665
2315
|
schemaVersion: 1,
|
|
666
2316
|
ok: false,
|
|
667
|
-
command
|
|
2317
|
+
command,
|
|
668
2318
|
evidenceKind: 'automated-browser',
|
|
669
2319
|
status: 'fail',
|
|
670
|
-
visualReview: 'pending',
|
|
2320
|
+
visualReview: capture ? 'pending' : 'not-requested',
|
|
2321
|
+
...(deliveryProvenance ? {
|
|
2322
|
+
provenance: deliveryProvenance.status,
|
|
2323
|
+
...(deliveryProvenance.receiptId ? { deliveryReceiptId: deliveryProvenance.receiptId } : {}),
|
|
2324
|
+
} : {}),
|
|
671
2325
|
artifact: {
|
|
672
2326
|
path: artifactPath,
|
|
673
2327
|
sha256: sha256(artifact),
|
|
@@ -676,61 +2330,282 @@ function baseReceipt({ artifactPath, artifact, outputs, chrome }) {
|
|
|
676
2330
|
state: { detail: 'read', motion: 'still' },
|
|
677
2331
|
chrome,
|
|
678
2332
|
diagnostics: [],
|
|
679
|
-
containment: {
|
|
2333
|
+
containment: {
|
|
2334
|
+
status: 'fail',
|
|
2335
|
+
policy: 'fit-or-reader-declared-readable-vertical-scroll',
|
|
2336
|
+
viewports: [],
|
|
2337
|
+
},
|
|
2338
|
+
themeStates: { status: 'fail', viewports: [] },
|
|
680
2339
|
readability: { status: 'fail', minimumProjectedNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX, viewports: [] },
|
|
681
2340
|
viewerChrome: { status: 'fail', viewports: [] },
|
|
682
|
-
captures: { status: 'fail', screenshots: [], contactSheet: null },
|
|
683
|
-
sidecars: {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
2341
|
+
captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null },
|
|
2342
|
+
sidecars: { ...sidecars, files: [] },
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
function appendEvidencePublicationFailure(receipt, failure) {
|
|
2347
|
+
if (!failure?.ok) {
|
|
2348
|
+
if (!receipt.error) receipt.error = failure.error;
|
|
2349
|
+
receipt.diagnostics = [...(receipt.diagnostics || []), failure.diagnostic];
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
function appendEvidenceCleanupWarning(receipt, publication) {
|
|
2354
|
+
if (publication?.status !== 'committed-with-warning') return false;
|
|
2355
|
+
const committedStatus = receipt.status;
|
|
2356
|
+
const cleanupErrors = publication.cleanupErrors || [];
|
|
2357
|
+
const cleanupMessage = publication.recoveryDirectory
|
|
2358
|
+
? `The evidence set was committed, but cleanup is incomplete. Recovery directory: "${publication.recoveryDirectory}".`
|
|
2359
|
+
: 'The evidence set was committed, but cleanup is incomplete.';
|
|
2360
|
+
receipt.ok = false;
|
|
2361
|
+
receipt.status = 'fail';
|
|
2362
|
+
receipt.error = receipt.error ? `${receipt.error} ${cleanupMessage}` : cleanupMessage;
|
|
2363
|
+
receipt.publication = {
|
|
2364
|
+
status: publication.status,
|
|
2365
|
+
committedReceipt: publication.committedReceipt,
|
|
2366
|
+
...(publication.recoveryDirectory
|
|
2367
|
+
? { recoveryDirectory: publication.recoveryDirectory } : {}),
|
|
2368
|
+
cleanupErrors,
|
|
2369
|
+
committedStatus,
|
|
687
2370
|
};
|
|
2371
|
+
receipt.diagnostics = [...(receipt.diagnostics || []), failureDiagnostic({
|
|
2372
|
+
code: 'viewer/evidence-cleanup-incomplete',
|
|
2373
|
+
severity: 'warning',
|
|
2374
|
+
message: cleanupMessage,
|
|
2375
|
+
subject: {
|
|
2376
|
+
artifact: receipt.artifact?.path,
|
|
2377
|
+
receipt: publication.committedReceipt,
|
|
2378
|
+
},
|
|
2379
|
+
evidence: {
|
|
2380
|
+
publicationStatus: publication.status,
|
|
2381
|
+
...(publication.recoveryDirectory
|
|
2382
|
+
? { recoveryDirectory: publication.recoveryDirectory } : {}),
|
|
2383
|
+
cleanupErrors,
|
|
2384
|
+
},
|
|
2385
|
+
supportedFixes: publication.recoveryDirectory
|
|
2386
|
+
? [`inspect and safely retire the retained files under "${publication.recoveryDirectory}"`]
|
|
2387
|
+
: ['resolve the reported filesystem cleanup failure before rerunning visual-check'],
|
|
2388
|
+
})];
|
|
2389
|
+
return true;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
function publishReceiptOnly(artifactPath, outputs, receipt, ownership) {
|
|
2393
|
+
const cleanupErrors = cleanupStagedEvidence(ownership);
|
|
2394
|
+
if (cleanupErrors.length) {
|
|
2395
|
+
const failure = evidenceWriteFailure(
|
|
2396
|
+
artifactPath,
|
|
2397
|
+
'visual-check could not safely retire this run\'s incomplete staged evidence.',
|
|
2398
|
+
cleanupErrors,
|
|
2399
|
+
);
|
|
2400
|
+
cleanupStagedEvidence(ownership, { removeDirectory: true });
|
|
2401
|
+
ownership.active = false;
|
|
2402
|
+
return failure;
|
|
2403
|
+
}
|
|
2404
|
+
try {
|
|
2405
|
+
writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
const failure = evidenceWriteFailure(
|
|
2408
|
+
artifactPath,
|
|
2409
|
+
'visual-check could not stage its evidence receipt.',
|
|
2410
|
+
[{ file: stagedPathFor(ownership, outputs.receipt), reason: error.message }],
|
|
2411
|
+
);
|
|
2412
|
+
cleanupStagedEvidence(ownership, { removeDirectory: true });
|
|
2413
|
+
ownership.active = false;
|
|
2414
|
+
return failure;
|
|
2415
|
+
}
|
|
2416
|
+
return commitVisualEvidence(artifactPath, outputs, ownership, [outputs.receipt]);
|
|
688
2417
|
}
|
|
689
2418
|
|
|
690
|
-
function
|
|
691
|
-
|
|
2419
|
+
function persistBrowserEvidenceFailure(
|
|
2420
|
+
artifactPath,
|
|
2421
|
+
failure,
|
|
2422
|
+
{ outDir, compareSidecarParents = sameParent, ownership, command = 'visual-check', capture = true, captureViewports = CAPTURE_VIEWPORTS } = {},
|
|
2423
|
+
) {
|
|
2424
|
+
const outputs = evidenceSidecarPaths(artifactPath, { outDir, command, capture, captureViewports });
|
|
2425
|
+
const directory = resolveSidecarDirectory(artifactPath, outputs, compareSidecarParents);
|
|
2426
|
+
const capturedArtifact = readRegularFileContents(artifactPath, {
|
|
2427
|
+
subject: 'failure-artifact',
|
|
2428
|
+
});
|
|
2429
|
+
const artifactBytes = capturedArtifact.ok ? capturedArtifact.bytes : null;
|
|
2430
|
+
const receipt = {
|
|
2431
|
+
...failure,
|
|
2432
|
+
ok: false, status: 'fail',
|
|
2433
|
+
...(artifactBytes ? {
|
|
2434
|
+
artifact: {
|
|
2435
|
+
path: path.resolve(artifactPath),
|
|
2436
|
+
sha256: sha256(artifactBytes),
|
|
2437
|
+
bytes: artifactBytes.byteLength,
|
|
2438
|
+
},
|
|
2439
|
+
} : {}),
|
|
2440
|
+
containment: {
|
|
2441
|
+
status: 'fail',
|
|
2442
|
+
policy: 'fit-or-reader-declared-readable-vertical-scroll',
|
|
2443
|
+
viewports: [],
|
|
2444
|
+
},
|
|
2445
|
+
captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null },
|
|
2446
|
+
sidecars: { ...directory.sidecars, files: [] },
|
|
2447
|
+
};
|
|
2448
|
+
if (!directory.ok) {
|
|
2449
|
+
receipt.error = directory.error;
|
|
2450
|
+
receipt.diagnostics = [...(failure.diagnostics || []), directory.diagnostic];
|
|
2451
|
+
return receipt;
|
|
2452
|
+
}
|
|
2453
|
+
let activeOwnership = ownership;
|
|
2454
|
+
if (!visualEvidenceOwnershipMatches(activeOwnership, artifactPath, outputs)) {
|
|
2455
|
+
if (!artifactBytes) {
|
|
2456
|
+
const conflict = evidencePathConflict(artifactPath, outputs, outputs.receipt, {
|
|
2457
|
+
code: 'artifact-unreadable',
|
|
2458
|
+
});
|
|
2459
|
+
receipt.error = conflict.error;
|
|
2460
|
+
receipt.diagnostics = [...(failure.diagnostics || []), conflict.diagnostic];
|
|
2461
|
+
return receipt;
|
|
2462
|
+
}
|
|
2463
|
+
const preflight = beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs);
|
|
2464
|
+
if (!preflight.ok) {
|
|
2465
|
+
receipt.error = preflight.error;
|
|
2466
|
+
receipt.diagnostics = [...(failure.diagnostics || []), preflight.diagnostic];
|
|
2467
|
+
return receipt;
|
|
2468
|
+
}
|
|
2469
|
+
activeOwnership = preflight.ownership;
|
|
2470
|
+
}
|
|
2471
|
+
const publication = publishReceiptOnly(artifactPath, outputs, receipt, activeOwnership);
|
|
2472
|
+
if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt);
|
|
2473
|
+
appendEvidencePublicationFailure(receipt, publication);
|
|
2474
|
+
appendEvidenceCleanupWarning(receipt, publication);
|
|
2475
|
+
return receipt;
|
|
692
2476
|
}
|
|
693
2477
|
|
|
694
|
-
|
|
2478
|
+
async function runBrowserEvidence({
|
|
695
2479
|
artifactPath,
|
|
2480
|
+
outDir,
|
|
696
2481
|
chromePath,
|
|
697
2482
|
resolveChrome = findChrome,
|
|
698
2483
|
browserFactory = async (resolvedChrome) => new ChromeVisualBrowser(resolvedChrome),
|
|
2484
|
+
deliveryProvenance,
|
|
2485
|
+
verifyArtifact,
|
|
2486
|
+
compareSidecarParents = sameParent,
|
|
2487
|
+
command, capture,
|
|
699
2488
|
} = {}) {
|
|
700
|
-
if (!artifactPath) throw new Error(
|
|
2489
|
+
if (!artifactPath) throw new Error(`${command} requires one delivered HTML artifact.`);
|
|
701
2490
|
const artifact = path.resolve(artifactPath);
|
|
702
|
-
if (!/\.html?$/i.test(artifact)) throw new Error(
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
2491
|
+
if (!/\.html?$/i.test(artifact)) throw new Error(`${command} requires an .html artifact.`);
|
|
2492
|
+
const capturedArtifact = captureRegularFileBinding(artifact, {
|
|
2493
|
+
subject: 'visual-check-artifact',
|
|
2494
|
+
expectedLinks: 1,
|
|
2495
|
+
includeContent: true,
|
|
2496
|
+
});
|
|
2497
|
+
if (capturedArtifact.status !== 'captured') {
|
|
2498
|
+
throw regularFileCaptureError('The visual-check artifact', capturedArtifact);
|
|
2499
|
+
}
|
|
2500
|
+
const artifactBytes = capturedArtifact.content.buffer;
|
|
2501
|
+
const explainer = isExplainerHtml(artifactBytes.toString('utf8'));
|
|
2502
|
+
const inspectionViewports = explainer ? EXPLAINER_VIEWPORTS : VISUAL_CHECK_VIEWPORTS;
|
|
2503
|
+
const captureViewports = explainer ? EXPLAINER_VIEWPORTS : CAPTURE_VIEWPORTS;
|
|
2504
|
+
const observe = explainer ? explainerObservation : observation;
|
|
2505
|
+
try {
|
|
2506
|
+
const outputs = evidenceSidecarPaths(artifact, { outDir, command, capture, captureViewports });
|
|
2507
|
+
const directory = resolveSidecarDirectory(artifact, outputs, compareSidecarParents);
|
|
709
2508
|
const receipt = baseReceipt({
|
|
710
2509
|
artifactPath: artifact,
|
|
711
2510
|
artifact: artifactBytes,
|
|
712
|
-
|
|
713
|
-
chrome:
|
|
714
|
-
|
|
715
|
-
: { status: 'unavailable', executable: null },
|
|
2511
|
+
sidecars: directory.sidecars,
|
|
2512
|
+
chrome: { status: 'not-checked', executable: null },
|
|
2513
|
+
deliveryProvenance, command, capture,
|
|
716
2514
|
});
|
|
2515
|
+
if (explainer) {
|
|
2516
|
+
receipt.family = 'explainer';
|
|
2517
|
+
receipt.viewportPolicy = 'explainer-desktop-v1';
|
|
2518
|
+
receipt.containment.policy = 'horizontal-containment-readable-document-scroll';
|
|
2519
|
+
}
|
|
2520
|
+
if (!directory.ok) {
|
|
2521
|
+
receipt.error = directory.error;
|
|
2522
|
+
receipt.diagnostics = [directory.diagnostic];
|
|
2523
|
+
return { exitCode: EXIT.fail, receipt };
|
|
2524
|
+
}
|
|
2525
|
+
const preflight = beginVisualEvidenceWrite(artifact, artifactBytes, outputs);
|
|
2526
|
+
if (!preflight.ok) {
|
|
2527
|
+
receipt.error = preflight.error;
|
|
2528
|
+
receipt.diagnostics = [preflight.diagnostic];
|
|
2529
|
+
return { exitCode: EXIT.fail, receipt };
|
|
2530
|
+
}
|
|
2531
|
+
const ownership = preflight.ownership;
|
|
2532
|
+
let inspectionArtifact;
|
|
2533
|
+
try {
|
|
2534
|
+
// Chrome's file loader cannot reliably open long Windows/UNC paths. Keep
|
|
2535
|
+
// the one inspected snapshot local; publish evidence on its target volume.
|
|
2536
|
+
ownership.inspection = {
|
|
2537
|
+
directory: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-inspection-')),
|
|
2538
|
+
};
|
|
2539
|
+
const stat = fs.lstatSync(ownership.inspection.directory, { bigint: true });
|
|
2540
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.ino === 0n) {
|
|
2541
|
+
throw new Error('the private inspection directory has no stable identity');
|
|
2542
|
+
}
|
|
2543
|
+
ownership.inspection.identity = fileIdentity(stat);
|
|
2544
|
+
inspectionArtifact = path.join(ownership.inspection.directory, 'artifact-snapshot.html');
|
|
2545
|
+
ownership.stagingPaths.set(artifact, inspectionArtifact);
|
|
2546
|
+
writeStagedEvidence(ownership, artifact, artifactBytes);
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, {
|
|
2549
|
+
...receipt,
|
|
2550
|
+
status: 'fail',
|
|
2551
|
+
ok: false,
|
|
2552
|
+
error: `visual-check could not stage its private artifact snapshot: ${error.message}`,
|
|
2553
|
+
diagnostics: [failureDiagnostic({
|
|
2554
|
+
code: 'viewer/artifact-snapshot',
|
|
2555
|
+
message: 'visual-check could not bind a private artifact snapshot.',
|
|
2556
|
+
subject: { artifact },
|
|
2557
|
+
evidence: { reason: error.message },
|
|
2558
|
+
supportedFixes: ['retry after resolving the reported staging filesystem error'],
|
|
2559
|
+
})],
|
|
2560
|
+
}, { outDir, compareSidecarParents, ownership, command, capture, captureViewports }) };
|
|
2561
|
+
}
|
|
2562
|
+
const verifyInspectionArtifact = () => {
|
|
2563
|
+
const entry = ownership.stagedEntries.get(artifact);
|
|
2564
|
+
if (!entry || !currentEvidenceMatches(entry.path, entry.identity, entry.evidence)) {
|
|
2565
|
+
throw new Error('The private visual-check artifact snapshot changed during inspection.');
|
|
2566
|
+
}
|
|
2567
|
+
};
|
|
2568
|
+
try {
|
|
2569
|
+
verifyArtifact?.(artifactBytes);
|
|
2570
|
+
} catch (error) {
|
|
2571
|
+
return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, {
|
|
2572
|
+
schemaVersion: 1, command, evidenceKind: 'automated-browser', visualReview: capture ? 'pending' : 'not-requested',
|
|
2573
|
+
artifact: { path: artifact, sha256: sha256(artifactBytes), bytes: artifactBytes.byteLength },
|
|
2574
|
+
provenance: error.deliveryProvenance?.status, error: error.message,
|
|
2575
|
+
diagnostics: error.archifyDiagnostics || [],
|
|
2576
|
+
}, { outDir, compareSidecarParents, ownership, command, capture, captureViewports }) };
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
const resolvedChrome = chromePath || resolveChrome();
|
|
2580
|
+
receipt.chrome = resolvedChrome
|
|
2581
|
+
? { status: 'available', executable: resolvedChrome }
|
|
2582
|
+
: { status: 'unavailable', executable: null };
|
|
717
2583
|
|
|
718
2584
|
if (!resolvedChrome) {
|
|
719
2585
|
receipt.status = 'skipped';
|
|
720
2586
|
receipt.containment.status = 'skipped';
|
|
721
2587
|
receipt.readability.status = 'skipped';
|
|
722
2588
|
receipt.viewerChrome.status = 'skipped';
|
|
723
|
-
receipt.
|
|
724
|
-
receipt.
|
|
2589
|
+
receipt.themeStates.status = 'skipped';
|
|
2590
|
+
if (capture) receipt.captures.status = 'skipped';
|
|
2591
|
+
receipt.error = 'No supported browser found. Install Chrome/Chromium (or Microsoft Edge), or set ARCHIFY_CHROME to its executable path.';
|
|
725
2592
|
receipt.diagnostics = [failureDiagnostic({
|
|
726
2593
|
code: 'viewer/chrome-unavailable',
|
|
727
2594
|
severity: 'warning',
|
|
728
2595
|
message: receipt.error,
|
|
729
2596
|
subject: { artifact },
|
|
730
2597
|
evidence: { executable: null },
|
|
731
|
-
supportedFixes: [
|
|
2598
|
+
supportedFixes: [`set ARCHIFY_CHROME to a Chrome/Chromium executable (or Microsoft Edge) and rerun ${command}`],
|
|
732
2599
|
})];
|
|
733
|
-
|
|
2600
|
+
const publication = publishReceiptOnly(artifact, outputs, receipt, ownership);
|
|
2601
|
+
if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt);
|
|
2602
|
+
if (!publication.ok) {
|
|
2603
|
+
appendEvidencePublicationFailure(receipt, publication);
|
|
2604
|
+
return { exitCode: EXIT.fail, receipt };
|
|
2605
|
+
}
|
|
2606
|
+
if (appendEvidenceCleanupWarning(receipt, publication)) {
|
|
2607
|
+
return { exitCode: EXIT.fail, receipt };
|
|
2608
|
+
}
|
|
734
2609
|
return { exitCode: EXIT.skipped, receipt };
|
|
735
2610
|
}
|
|
736
2611
|
|
|
@@ -738,40 +2613,77 @@ export async function runVisualCheck({
|
|
|
738
2613
|
try {
|
|
739
2614
|
browser = await browserFactory(resolvedChrome);
|
|
740
2615
|
const observations = new Map();
|
|
741
|
-
const screenshotsByKey = new Map(outputs.screenshots.map((entry) => [
|
|
2616
|
+
const screenshotsByKey = new Map(outputs.screenshots.map((entry, index) => [
|
|
742
2617
|
screenshotKey(entry.width, entry.height, entry.theme),
|
|
743
|
-
entry,
|
|
2618
|
+
{ ...entry, stagedPath: ownership.stagedOutputs.screenshots[index].path },
|
|
744
2619
|
]));
|
|
745
2620
|
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
2621
|
+
try {
|
|
2622
|
+
const inspect = (options) => explainer ? inspectExplainerBrowser(browser, options) : browser.inspect(options);
|
|
2623
|
+
for (const viewport of inspectionViewports) {
|
|
2624
|
+
const key = screenshotKey(viewport.width, viewport.height, 'light');
|
|
2625
|
+
const screenshot = screenshotsByKey.get(key);
|
|
2626
|
+
const metrics = await inspect({
|
|
2627
|
+
artifactPath: inspectionArtifact,
|
|
2628
|
+
...viewport,
|
|
2629
|
+
theme: 'light',
|
|
2630
|
+
...(screenshot ? {
|
|
2631
|
+
screenshotPath: screenshot.stagedPath,
|
|
2632
|
+
writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes),
|
|
2633
|
+
} : {}),
|
|
2634
|
+
});
|
|
2635
|
+
verifyInspectionArtifact();
|
|
2636
|
+
if (screenshot) {
|
|
2637
|
+
if (screenshot && !ownership.stagedEntries.has(screenshot.path)) {
|
|
2638
|
+
registerStagedEvidence(ownership, screenshot.path);
|
|
2639
|
+
} else {
|
|
2640
|
+
const staged = ownership.stagedEntries.get(screenshot.path);
|
|
2641
|
+
if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) {
|
|
2642
|
+
throw new Error('The staged visual-check screenshot changed after capture.');
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
observations.set(key, observe({ ...viewport, theme: 'light', metrics }));
|
|
2647
|
+
}
|
|
2648
|
+
for (const viewport of captureViewports) {
|
|
2649
|
+
const key = screenshotKey(viewport.width, viewport.height, 'dark');
|
|
2650
|
+
const screenshot = screenshotsByKey.get(key);
|
|
2651
|
+
const metrics = await inspect({
|
|
2652
|
+
artifactPath: inspectionArtifact,
|
|
2653
|
+
...viewport,
|
|
2654
|
+
theme: 'dark',
|
|
2655
|
+
...(screenshot ? {
|
|
2656
|
+
screenshotPath: screenshot.stagedPath,
|
|
2657
|
+
writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes),
|
|
2658
|
+
} : {}),
|
|
2659
|
+
});
|
|
2660
|
+
verifyInspectionArtifact();
|
|
2661
|
+
if (screenshot && !ownership.stagedEntries.has(screenshot.path)) {
|
|
2662
|
+
registerStagedEvidence(ownership, screenshot.path);
|
|
2663
|
+
} else if (screenshot) {
|
|
2664
|
+
const staged = ownership.stagedEntries.get(screenshot.path);
|
|
2665
|
+
if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) {
|
|
2666
|
+
throw new Error('The staged visual-check screenshot changed after capture.');
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
observations.set(key, observe({ ...viewport, theme: 'dark', metrics }));
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
} finally {
|
|
2673
|
+
if (browser?.close) await browser.close();
|
|
767
2674
|
}
|
|
768
2675
|
|
|
769
|
-
|
|
770
|
-
if (
|
|
771
|
-
throw
|
|
2676
|
+
let artifactVerification = verifyRegularFileBinding(capturedArtifact.binding);
|
|
2677
|
+
if (artifactVerification.status !== 'match') {
|
|
2678
|
+
throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification);
|
|
2679
|
+
}
|
|
2680
|
+
verifyArtifact?.(artifactBytes);
|
|
2681
|
+
artifactVerification = verifyRegularFileBinding(capturedArtifact.binding);
|
|
2682
|
+
if (artifactVerification.status !== 'match') {
|
|
2683
|
+
throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification);
|
|
772
2684
|
}
|
|
773
2685
|
|
|
774
|
-
receipt.containment.viewports =
|
|
2686
|
+
receipt.containment.viewports = inspectionViewports.map(({ width, height }) => (
|
|
775
2687
|
observations.get(screenshotKey(width, height, 'light'))
|
|
776
2688
|
));
|
|
777
2689
|
receipt.readability.viewports = receipt.containment.viewports.map((entry) => ({ ...entry }));
|
|
@@ -781,49 +2693,122 @@ export async function runVisualCheck({
|
|
|
781
2693
|
file: path.basename(entry.path),
|
|
782
2694
|
}));
|
|
783
2695
|
const allObservations = [...observations.values()];
|
|
2696
|
+
receipt.themeStates.viewports = allObservations.map((entry) => ({
|
|
2697
|
+
width: entry.width,
|
|
2698
|
+
height: entry.height,
|
|
2699
|
+
requestedTheme: entry.theme,
|
|
2700
|
+
resolvedTheme: entry.resolvedTheme,
|
|
2701
|
+
ok: entry.resolvedTheme === entry.theme,
|
|
2702
|
+
}));
|
|
2703
|
+
const themeStatePass = receipt.themeStates.viewports.every(entry => entry.ok);
|
|
784
2704
|
const containmentPass = allObservations.every((entry) => entry.ok);
|
|
785
2705
|
const readabilityPass = receipt.readability.viewports.every((entry) => entry.readabilityOk);
|
|
786
2706
|
const viewerChromePass = allObservations.every((entry) => entry.viewerChromeOk);
|
|
787
|
-
receipt.diagnostics = observationDiagnostics({
|
|
2707
|
+
receipt.diagnostics = (explainer ? explainerObservationDiagnostics : observationDiagnostics)({
|
|
788
2708
|
artifact,
|
|
789
2709
|
allObservations,
|
|
790
|
-
readabilityObservations: receipt.readability.viewports,
|
|
2710
|
+
readabilityObservations: receipt.readability.viewports, command,
|
|
791
2711
|
});
|
|
2712
|
+
receipt.themeStates.status = themeStatePass ? 'pass' : 'fail';
|
|
792
2713
|
receipt.containment.status = containmentPass ? 'pass' : 'fail';
|
|
793
2714
|
receipt.readability.status = readabilityPass ? 'pass' : 'fail';
|
|
794
2715
|
receipt.viewerChrome.status = viewerChromePass ? 'pass' : 'fail';
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
2716
|
+
if (capture) {
|
|
2717
|
+
receipt.captures.status = 'pass';
|
|
2718
|
+
receipt.captures.contactSheet = path.basename(outputs.contactSheet);
|
|
2719
|
+
}
|
|
2720
|
+
if (explainer) {
|
|
2721
|
+
receipt.containment.viewports = allObservations.map(entry => ({ ...entry }));
|
|
2722
|
+
receipt.readability.viewports = allObservations.map(entry => ({ ...entry }));
|
|
2723
|
+
receipt.viewerChrome.viewports = allObservations.map(entry => ({ ...entry }));
|
|
2724
|
+
receipt.commonQa = { status: allObservations.every(e => e.consoleErrors.length === 0 && e.duplicateIds.length === 0 && e.keyboardIssues.length === 0 && e.keyboardTraversal?.ok) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.consoleErrors.length===0&&e.duplicateIds.length===0&&e.keyboardIssues.length===0&&e.keyboardTraversal?.ok===true})) };
|
|
2725
|
+
receipt.interactions = { status: allObservations.every(e => e.interactionFailures.length === 0) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.interactionFailures.length===0,failures:e.interactionFailures})) };
|
|
2726
|
+
receipt.references = { status: allObservations.every(e => e.references.length === 0) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.references.length===0,failures:e.references})) };
|
|
2727
|
+
receipt.noJavaScript = { status: allObservations.every(e => e.noJavaScript.every(n => n.ok)) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.noJavaScript.every(n=>n.ok),states:e.noJavaScript})) };
|
|
2728
|
+
receipt.print = { status: allObservations.every(e => e.print.ok) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,...e.print})) };
|
|
2729
|
+
}
|
|
2730
|
+
receipt.ok = containmentPass && themeStatePass && readabilityPass && viewerChromePass && (!explainer || receipt.diagnostics.length === 0);
|
|
2731
|
+
receipt.status = receipt.ok ? 'pass' : 'fail';
|
|
2732
|
+
if (capture) writeStagedEvidence(ownership, outputs.contactSheet, contactSheetHtml({
|
|
800
2733
|
artifactPath: artifact,
|
|
801
2734
|
receipt,
|
|
802
2735
|
screenshots: receipt.captures.screenshots,
|
|
803
2736
|
}));
|
|
804
|
-
|
|
2737
|
+
recordVisualEvidenceFiles(receipt, outputs, ownership);
|
|
2738
|
+
writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
2739
|
+
const publication = commitVisualEvidence(
|
|
2740
|
+
artifact,
|
|
2741
|
+
outputs,
|
|
2742
|
+
ownership,
|
|
2743
|
+
visualEvidenceTargets(outputs).map((target) => target.path),
|
|
2744
|
+
);
|
|
2745
|
+
if (!publication.ok) {
|
|
2746
|
+
appendEvidencePublicationFailure(receipt, publication);
|
|
2747
|
+
receipt.ok = false;
|
|
2748
|
+
receipt.status = 'fail';
|
|
2749
|
+
return { exitCode: EXIT.fail, receipt };
|
|
2750
|
+
}
|
|
2751
|
+
publishedBrowserEvidenceReceipts.add(receipt);
|
|
2752
|
+
if (appendEvidenceCleanupWarning(receipt, publication)) {
|
|
2753
|
+
return { exitCode: EXIT.fail, receipt };
|
|
2754
|
+
}
|
|
805
2755
|
return { exitCode: receipt.ok ? EXIT.pass : EXIT.fail, receipt };
|
|
806
2756
|
} catch (error) {
|
|
807
|
-
|
|
2757
|
+
const startupTimeout = error.code === 'ERR_CHROME_CDP_TIMEOUT'
|
|
2758
|
+
&& error.method === 'Target.getTargets';
|
|
808
2759
|
receipt.status = 'fail';
|
|
809
2760
|
receipt.ok = false;
|
|
810
2761
|
receipt.error = error.message;
|
|
811
2762
|
receipt.containment.status = 'fail';
|
|
812
2763
|
receipt.readability.status = 'fail';
|
|
813
2764
|
receipt.viewerChrome.status = 'fail';
|
|
814
|
-
receipt.
|
|
2765
|
+
receipt.themeStates.status = 'fail';
|
|
2766
|
+
receipt.captures.status = capture ? 'fail' : 'not-requested';
|
|
815
2767
|
receipt.captures.screenshots = [];
|
|
816
2768
|
receipt.captures.contactSheet = null;
|
|
817
|
-
receipt.
|
|
818
|
-
|
|
819
|
-
|
|
2769
|
+
if (error.deliveryProvenance) receipt.provenance = error.deliveryProvenance.status;
|
|
2770
|
+
receipt.diagnostics = error.archifyDiagnostics || [failureDiagnostic({
|
|
2771
|
+
code: error.code === 'ARCHIFY_TEMP_CLEANUP' ? 'viewer/temp-cleanup-incomplete'
|
|
2772
|
+
: startupTimeout ? 'viewer/chrome-startup-timeout' : `viewer/${command}-runtime`,
|
|
2773
|
+
message: error.code === 'ARCHIFY_TEMP_CLEANUP'
|
|
2774
|
+
? 'Browser cleanup could not safely finish; inspect the reported retained path.'
|
|
2775
|
+
: startupTimeout
|
|
2776
|
+
? 'Chrome did not finish its initial DevTools handshake within the startup window.'
|
|
2777
|
+
: `${command} could not complete its Chrome inspection.`,
|
|
820
2778
|
subject: { artifact },
|
|
821
|
-
evidence: { reason: error.message },
|
|
822
|
-
supportedFixes:
|
|
2779
|
+
evidence: { reason: error.message, ...(error.directory ? { retainedDirectory: error.directory } : {}) },
|
|
2780
|
+
supportedFixes: error.code === 'ARCHIFY_TEMP_CLEANUP'
|
|
2781
|
+
? ['resolve the reported process or filesystem failure; verify directory ownership before manual cleanup']
|
|
2782
|
+
: startupTimeout
|
|
2783
|
+
? [
|
|
2784
|
+
'do not edit or simplify the artifact because this is a browser-startup failure',
|
|
2785
|
+
`retry ${command} once after host load subsides; if it repeats, stop and report the environment failure`,
|
|
2786
|
+
]
|
|
2787
|
+
: [`resolve the reported Chrome inspection error, then rerun ${command}`],
|
|
823
2788
|
})];
|
|
824
|
-
|
|
825
|
-
|
|
2789
|
+
return {
|
|
2790
|
+
exitCode: EXIT.fail,
|
|
2791
|
+
receipt: persistBrowserEvidenceFailure(artifact, receipt, {
|
|
2792
|
+
outDir,
|
|
2793
|
+
compareSidecarParents,
|
|
2794
|
+
ownership, command, capture, captureViewports,
|
|
2795
|
+
}),
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
826
2798
|
} finally {
|
|
827
|
-
|
|
2799
|
+
releaseRegularFileBinding(capturedArtifact.binding);
|
|
828
2800
|
}
|
|
829
2801
|
}
|
|
2802
|
+
|
|
2803
|
+
export function persistVisualCheckFailure(artifactPath, failure, options = {}) {
|
|
2804
|
+
return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'visual-check', capture: true });
|
|
2805
|
+
}
|
|
2806
|
+
export function persistBrowserCheckFailure(artifactPath, failure, options = {}) {
|
|
2807
|
+
return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'browser-check', capture: false });
|
|
2808
|
+
}
|
|
2809
|
+
export function runVisualCheck(options = {}) {
|
|
2810
|
+
return runBrowserEvidence({ ...options, command: 'visual-check', capture: true });
|
|
2811
|
+
}
|
|
2812
|
+
export function runBrowserCheck(options = {}) {
|
|
2813
|
+
return runBrowserEvidence({ ...options, command: 'browser-check', capture: false });
|
|
2814
|
+
}
|