snapdrift 0.2.0
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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/actions/baseline/action.yml +142 -0
- package/actions/capture/action.yml +78 -0
- package/actions/comment/action.yml +104 -0
- package/actions/compare/action.yml +163 -0
- package/actions/enforce/action.yml +50 -0
- package/actions/pr-diff/action.yml +612 -0
- package/actions/resolve-baseline/action.yml +165 -0
- package/actions/scope/action.yml +107 -0
- package/actions/stage/action.yml +116 -0
- package/bin/snapdrift.mjs +14 -0
- package/docs/contracts.md +233 -0
- package/docs/integration-guide.md +222 -0
- package/docs/local-cli.md +174 -0
- package/lib/capture-routes.mjs +370 -0
- package/lib/cli.mjs +241 -0
- package/lib/compare-results.mjs +643 -0
- package/lib/drift-summary.mjs +156 -0
- package/lib/pr-comment.mjs +147 -0
- package/lib/report.mjs +268 -0
- package/lib/snapdrift-config.mjs +280 -0
- package/lib/stage-artifacts.mjs +125 -0
- package/package.json +94 -0
- package/types/visual-diff-types.d.ts +137 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
name: SnapDrift Resolve Baseline
|
|
2
|
+
description: Resolve and download the latest successful SnapDrift baseline from main.
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
github-token:
|
|
6
|
+
description: Token used to query workflow runs and download artifacts.
|
|
7
|
+
required: true
|
|
8
|
+
repository:
|
|
9
|
+
description: Repository in owner/name format.
|
|
10
|
+
required: false
|
|
11
|
+
default: ''
|
|
12
|
+
repo-config-path:
|
|
13
|
+
description: Path to the SnapDrift config file.
|
|
14
|
+
required: false
|
|
15
|
+
default: .github/snapdrift.json
|
|
16
|
+
artifact-name:
|
|
17
|
+
description: Optional explicit baseline name override.
|
|
18
|
+
required: false
|
|
19
|
+
default: ''
|
|
20
|
+
workflow-id:
|
|
21
|
+
description: Workflow id or filename to search.
|
|
22
|
+
required: false
|
|
23
|
+
default: ci.yml
|
|
24
|
+
branch:
|
|
25
|
+
description: Branch that publishes the baseline.
|
|
26
|
+
required: false
|
|
27
|
+
default: main
|
|
28
|
+
download-path:
|
|
29
|
+
description: Directory where the artifact should be downloaded.
|
|
30
|
+
required: false
|
|
31
|
+
default: snapdrift-baseline-download
|
|
32
|
+
|
|
33
|
+
outputs:
|
|
34
|
+
found:
|
|
35
|
+
description: Whether a non-expired baseline was found.
|
|
36
|
+
value: ${{ steps.resolve.outputs.found }}
|
|
37
|
+
artifact-name:
|
|
38
|
+
description: Resolved artifact name.
|
|
39
|
+
value: ${{ steps.resolve.outputs.artifact_name }}
|
|
40
|
+
head-sha:
|
|
41
|
+
description: Head SHA of the baseline-producing run.
|
|
42
|
+
value: ${{ steps.resolve.outputs.head_sha }}
|
|
43
|
+
run-id:
|
|
44
|
+
description: Workflow run id that produced the baseline.
|
|
45
|
+
value: ${{ steps.resolve.outputs.run_id }}
|
|
46
|
+
message:
|
|
47
|
+
description: Informational message when no artifact was found.
|
|
48
|
+
value: ${{ steps.resolve.outputs.message }}
|
|
49
|
+
results-file:
|
|
50
|
+
description: Path to the downloaded baseline results JSON.
|
|
51
|
+
value: ${{ steps.paths.outputs.results_file }}
|
|
52
|
+
manifest-file:
|
|
53
|
+
description: Path to the downloaded baseline screenshot manifest.
|
|
54
|
+
value: ${{ steps.paths.outputs.manifest_file }}
|
|
55
|
+
run-dir:
|
|
56
|
+
description: Root directory for the downloaded artifact.
|
|
57
|
+
value: ${{ steps.paths.outputs.run_dir }}
|
|
58
|
+
screenshots-dir:
|
|
59
|
+
description: Root directory containing downloaded baseline screenshots.
|
|
60
|
+
value: ${{ steps.paths.outputs.screenshots_dir }}
|
|
61
|
+
|
|
62
|
+
runs:
|
|
63
|
+
using: composite
|
|
64
|
+
steps:
|
|
65
|
+
- id: resolve
|
|
66
|
+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
|
67
|
+
env:
|
|
68
|
+
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
69
|
+
INPUT_ARTIFACT_NAME: ${{ inputs.artifact-name }}
|
|
70
|
+
INPUT_REPOSITORY: ${{ inputs.repository }}
|
|
71
|
+
INPUT_WORKFLOW_ID: ${{ inputs.workflow-id }}
|
|
72
|
+
INPUT_BRANCH: ${{ inputs.branch }}
|
|
73
|
+
with:
|
|
74
|
+
github-token: ${{ inputs.github-token }}
|
|
75
|
+
script: |
|
|
76
|
+
const path = require('path');
|
|
77
|
+
const { pathToFileURL } = require('url');
|
|
78
|
+
|
|
79
|
+
const repository = process.env.INPUT_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;
|
|
80
|
+
const [owner, repo] = repository.split('/');
|
|
81
|
+
const modulePath = path.resolve(process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH, '..', '..', 'lib', 'snapdrift-config.mjs');
|
|
82
|
+
const { loadSnapdriftConfig } = await import(pathToFileURL(modulePath));
|
|
83
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
84
|
+
const artifactName = process.env.INPUT_ARTIFACT_NAME || config.baselineArtifactName;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
88
|
+
owner,
|
|
89
|
+
repo,
|
|
90
|
+
workflow_id: process.env.INPUT_WORKFLOW_ID || 'ci.yml',
|
|
91
|
+
branch: process.env.INPUT_BRANCH || 'main',
|
|
92
|
+
event: 'push',
|
|
93
|
+
per_page: 20
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
let selectedRun = null;
|
|
97
|
+
let selectedArtifact = null;
|
|
98
|
+
|
|
99
|
+
for (const run of runs) {
|
|
100
|
+
if (run.status !== 'completed' || run.conclusion !== 'success') {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
|
105
|
+
owner,
|
|
106
|
+
repo,
|
|
107
|
+
run_id: run.id,
|
|
108
|
+
per_page: 100
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const match = artifacts.data.artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
|
112
|
+
if (match) {
|
|
113
|
+
selectedRun = run;
|
|
114
|
+
selectedArtifact = match;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!selectedRun || !selectedArtifact) {
|
|
120
|
+
core.setOutput('found', 'false');
|
|
121
|
+
core.setOutput('artifact_name', artifactName);
|
|
122
|
+
core.setOutput('message', `No non-expired SnapDrift baseline artifact named ${artifactName} was found.`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
core.setOutput('found', 'true');
|
|
127
|
+
core.setOutput('artifact_name', selectedArtifact.name);
|
|
128
|
+
core.setOutput('head_sha', selectedRun.head_sha);
|
|
129
|
+
core.setOutput('run_id', String(selectedRun.id));
|
|
130
|
+
core.setOutput('message', '');
|
|
131
|
+
} catch (error) {
|
|
132
|
+
core.setOutput('found', 'false');
|
|
133
|
+
core.setOutput('artifact_name', artifactName);
|
|
134
|
+
core.setOutput('message', `Unable to resolve the SnapDrift baseline artifact: ${error.message}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
- if: steps.resolve.outputs.found == 'true'
|
|
138
|
+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
|
139
|
+
with:
|
|
140
|
+
github-token: ${{ inputs.github-token }}
|
|
141
|
+
repository: ${{ inputs.repository || github.repository }}
|
|
142
|
+
run-id: ${{ steps.resolve.outputs.run_id }}
|
|
143
|
+
name: ${{ steps.resolve.outputs.artifact_name }}
|
|
144
|
+
path: ${{ inputs.download-path }}
|
|
145
|
+
|
|
146
|
+
- id: paths
|
|
147
|
+
if: steps.resolve.outputs.found == 'true'
|
|
148
|
+
shell: bash
|
|
149
|
+
env:
|
|
150
|
+
DOWNLOAD_PATH: ${{ inputs.download-path }}
|
|
151
|
+
run: |
|
|
152
|
+
set -euo pipefail
|
|
153
|
+
RESULTS_FILE="$(find "$DOWNLOAD_PATH" -type f -name 'results.json' | head -n 1)"
|
|
154
|
+
MANIFEST_FILE="$(find "$DOWNLOAD_PATH" -type f -name 'manifest.json' | head -n 1)"
|
|
155
|
+
if [ -z "$RESULTS_FILE" ] || [ -z "$MANIFEST_FILE" ]; then
|
|
156
|
+
echo "Downloaded artifact did not contain the expected SnapDrift baseline files."
|
|
157
|
+
find "$DOWNLOAD_PATH" -maxdepth 5 -type f | sort || true
|
|
158
|
+
exit 1
|
|
159
|
+
fi
|
|
160
|
+
{
|
|
161
|
+
echo "results_file=$RESULTS_FILE"
|
|
162
|
+
echo "manifest_file=$MANIFEST_FILE"
|
|
163
|
+
echo "run_dir=${GITHUB_WORKSPACE}/${DOWNLOAD_PATH}"
|
|
164
|
+
echo "screenshots_dir=$(dirname "$MANIFEST_FILE")/screenshots"
|
|
165
|
+
} >> "$GITHUB_OUTPUT"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
name: SnapDrift Scope
|
|
2
|
+
description: Determine whether SnapDrift should run and which configured routes should be selected for the current change set.
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
github-token:
|
|
6
|
+
description: Token used to query pull request changed files.
|
|
7
|
+
required: true
|
|
8
|
+
repo-config-path:
|
|
9
|
+
description: Path to the SnapDrift config file.
|
|
10
|
+
required: false
|
|
11
|
+
default: .github/snapdrift.json
|
|
12
|
+
pr-number:
|
|
13
|
+
description: Pull request number for changed-file inspection. When omitted, the action falls back to running all routes.
|
|
14
|
+
required: false
|
|
15
|
+
default: ''
|
|
16
|
+
force-run:
|
|
17
|
+
description: Whether to force a full SnapDrift run without inspecting changed files.
|
|
18
|
+
required: false
|
|
19
|
+
default: 'false'
|
|
20
|
+
force-run-reason:
|
|
21
|
+
description: Reason to report when force-run is enabled.
|
|
22
|
+
required: false
|
|
23
|
+
default: 'forced'
|
|
24
|
+
|
|
25
|
+
outputs:
|
|
26
|
+
should-run:
|
|
27
|
+
description: Whether SnapDrift should run.
|
|
28
|
+
value: ${{ steps.scope.outputs.should_run }}
|
|
29
|
+
reason:
|
|
30
|
+
description: Reason for the scope decision.
|
|
31
|
+
value: ${{ steps.scope.outputs.reason }}
|
|
32
|
+
selected-route-ids:
|
|
33
|
+
description: Comma-separated route ids selected for the run.
|
|
34
|
+
value: ${{ steps.scope.outputs.selected_route_ids }}
|
|
35
|
+
|
|
36
|
+
runs:
|
|
37
|
+
using: composite
|
|
38
|
+
steps:
|
|
39
|
+
- name: Set action root path
|
|
40
|
+
shell: bash
|
|
41
|
+
run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
|
|
42
|
+
|
|
43
|
+
- id: scope
|
|
44
|
+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
|
45
|
+
env:
|
|
46
|
+
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
47
|
+
INPUT_PR_NUMBER: ${{ inputs.pr-number }}
|
|
48
|
+
FORCE_RUN: ${{ inputs.force-run }}
|
|
49
|
+
FORCE_RUN_REASON: ${{ inputs.force-run-reason }}
|
|
50
|
+
with:
|
|
51
|
+
github-token: ${{ inputs.github-token }}
|
|
52
|
+
script: |
|
|
53
|
+
const path = require('path');
|
|
54
|
+
const { pathToFileURL } = require('url');
|
|
55
|
+
|
|
56
|
+
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
57
|
+
const { loadSnapdriftConfig, selectRoutesForChangedFiles } = await import(pathToFileURL(configModulePath));
|
|
58
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
59
|
+
|
|
60
|
+
const allRouteIds = (config.routes || []).map((route) => route.id);
|
|
61
|
+
const resolvedPrNumber = Number(process.env.INPUT_PR_NUMBER || context.payload.pull_request?.number || '');
|
|
62
|
+
|
|
63
|
+
function setOutputs(scope) {
|
|
64
|
+
core.setOutput('should_run', scope.shouldRun ? 'true' : 'false');
|
|
65
|
+
core.setOutput('reason', scope.reason);
|
|
66
|
+
core.setOutput('selected_route_ids', (scope.selectedRouteIds || []).join(','));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (process.env.FORCE_RUN === 'true') {
|
|
70
|
+
setOutputs({
|
|
71
|
+
shouldRun: true,
|
|
72
|
+
reason: process.env.FORCE_RUN_REASON || 'forced',
|
|
73
|
+
selectedRouteIds: allRouteIds
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!Number.isFinite(resolvedPrNumber) || resolvedPrNumber <= 0) {
|
|
79
|
+
setOutputs({
|
|
80
|
+
shouldRun: true,
|
|
81
|
+
reason: 'missing_pr_number',
|
|
82
|
+
selectedRouteIds: allRouteIds
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
89
|
+
owner: context.repo.owner,
|
|
90
|
+
repo: context.repo.repo,
|
|
91
|
+
pull_number: resolvedPrNumber,
|
|
92
|
+
per_page: 100
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const scope = selectRoutesForChangedFiles(
|
|
96
|
+
config,
|
|
97
|
+
files.map((file) => file.filename)
|
|
98
|
+
);
|
|
99
|
+
setOutputs(scope);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
core.warning(`Unable to inspect PR files for SnapDrift scope; running all configured captures instead. ${error.message}`);
|
|
102
|
+
setOutputs({
|
|
103
|
+
shouldRun: true,
|
|
104
|
+
reason: 'snapdrift_scope_check_failed',
|
|
105
|
+
selectedRouteIds: allRouteIds
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
name: SnapDrift Stage
|
|
2
|
+
description: Stage baseline or drift artifacts into the SnapDrift bundle structure before upload.
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
artifact-type:
|
|
6
|
+
description: Either baseline or diff.
|
|
7
|
+
required: true
|
|
8
|
+
bundle-dir:
|
|
9
|
+
description: Output bundle directory.
|
|
10
|
+
required: false
|
|
11
|
+
default: ''
|
|
12
|
+
results-path:
|
|
13
|
+
description: Baseline results path for baseline artifact staging.
|
|
14
|
+
required: false
|
|
15
|
+
default: ''
|
|
16
|
+
manifest-path:
|
|
17
|
+
description: Baseline manifest path for baseline artifact staging.
|
|
18
|
+
required: false
|
|
19
|
+
default: ''
|
|
20
|
+
screenshots-dir:
|
|
21
|
+
description: Screenshots directory for baseline artifact staging.
|
|
22
|
+
required: false
|
|
23
|
+
default: ''
|
|
24
|
+
summary-json-path:
|
|
25
|
+
description: Diff summary JSON path.
|
|
26
|
+
required: false
|
|
27
|
+
default: ''
|
|
28
|
+
summary-markdown-path:
|
|
29
|
+
description: Diff summary markdown path.
|
|
30
|
+
required: false
|
|
31
|
+
default: ''
|
|
32
|
+
baseline-results-path:
|
|
33
|
+
description: Baseline results path for diff artifact staging.
|
|
34
|
+
required: false
|
|
35
|
+
default: ''
|
|
36
|
+
current-results-path:
|
|
37
|
+
description: Current results path for diff artifact staging.
|
|
38
|
+
required: false
|
|
39
|
+
default: ''
|
|
40
|
+
baseline-manifest-path:
|
|
41
|
+
description: Baseline manifest path for diff artifact staging.
|
|
42
|
+
required: false
|
|
43
|
+
default: ''
|
|
44
|
+
current-manifest-path:
|
|
45
|
+
description: Current manifest path for diff artifact staging.
|
|
46
|
+
required: false
|
|
47
|
+
default: ''
|
|
48
|
+
baseline-screenshots-dir:
|
|
49
|
+
description: Baseline screenshots dir for diff artifact staging.
|
|
50
|
+
required: false
|
|
51
|
+
default: ''
|
|
52
|
+
current-screenshots-dir:
|
|
53
|
+
description: Current screenshots dir for diff artifact staging.
|
|
54
|
+
required: false
|
|
55
|
+
default: ''
|
|
56
|
+
|
|
57
|
+
outputs:
|
|
58
|
+
bundle-dir:
|
|
59
|
+
description: The staged artifact bundle directory.
|
|
60
|
+
value: ${{ steps.stage.outputs.bundle_dir }}
|
|
61
|
+
|
|
62
|
+
runs:
|
|
63
|
+
using: composite
|
|
64
|
+
steps:
|
|
65
|
+
- name: Set action root path
|
|
66
|
+
shell: bash
|
|
67
|
+
run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
|
|
68
|
+
|
|
69
|
+
- name: Set up Node.js
|
|
70
|
+
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
|
71
|
+
with:
|
|
72
|
+
node-version: '22'
|
|
73
|
+
|
|
74
|
+
- id: stage
|
|
75
|
+
shell: bash
|
|
76
|
+
env:
|
|
77
|
+
ARTIFACT_TYPE: ${{ inputs.artifact-type }}
|
|
78
|
+
BUNDLE_DIR: ${{ inputs.bundle-dir }}
|
|
79
|
+
RESULTS_PATH: ${{ inputs.results-path }}
|
|
80
|
+
MANIFEST_PATH: ${{ inputs.manifest-path }}
|
|
81
|
+
SCREENSHOTS_DIR: ${{ inputs.screenshots-dir }}
|
|
82
|
+
SUMMARY_JSON_PATH: ${{ inputs.summary-json-path }}
|
|
83
|
+
SUMMARY_MARKDOWN_PATH: ${{ inputs.summary-markdown-path }}
|
|
84
|
+
BASELINE_RESULTS_PATH: ${{ inputs.baseline-results-path }}
|
|
85
|
+
CURRENT_RESULTS_PATH: ${{ inputs.current-results-path }}
|
|
86
|
+
BASELINE_MANIFEST_PATH: ${{ inputs.baseline-manifest-path }}
|
|
87
|
+
CURRENT_MANIFEST_PATH: ${{ inputs.current-manifest-path }}
|
|
88
|
+
BASELINE_SCREENSHOTS_DIR: ${{ inputs.baseline-screenshots-dir }}
|
|
89
|
+
CURRENT_SCREENSHOTS_DIR: ${{ inputs.current-screenshots-dir }}
|
|
90
|
+
run: |
|
|
91
|
+
node --input-type=module <<'EOF'
|
|
92
|
+
import fs from 'node:fs/promises';
|
|
93
|
+
import path from 'node:path';
|
|
94
|
+
import { pathToFileURL } from 'node:url';
|
|
95
|
+
|
|
96
|
+
const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/stage-artifacts.mjs');
|
|
97
|
+
const { stageArtifacts } = await import(pathToFileURL(modulePath));
|
|
98
|
+
|
|
99
|
+
const result = await stageArtifacts({
|
|
100
|
+
artifactType: process.env.ARTIFACT_TYPE,
|
|
101
|
+
bundleDir: process.env.BUNDLE_DIR || undefined,
|
|
102
|
+
resultsPath: process.env.RESULTS_PATH || undefined,
|
|
103
|
+
manifestPath: process.env.MANIFEST_PATH || undefined,
|
|
104
|
+
screenshotsDir: process.env.SCREENSHOTS_DIR || undefined,
|
|
105
|
+
summaryJsonPath: process.env.SUMMARY_JSON_PATH || undefined,
|
|
106
|
+
summaryMarkdownPath: process.env.SUMMARY_MARKDOWN_PATH || undefined,
|
|
107
|
+
baselineResultsPath: process.env.BASELINE_RESULTS_PATH || undefined,
|
|
108
|
+
currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
|
|
109
|
+
baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
|
|
110
|
+
currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
|
|
111
|
+
baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
|
|
112
|
+
currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
|
|
116
|
+
EOF
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// Resolve cli.mjs relative to this file so the bin entry works regardless of
|
|
7
|
+
// where the consumer installs the package.
|
|
8
|
+
const cliPath = new URL('../lib/cli.mjs', import.meta.url).pathname;
|
|
9
|
+
const { main } = await import(pathToFileURL(cliPath).href);
|
|
10
|
+
|
|
11
|
+
main(process.argv).catch((error) => {
|
|
12
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Contracts
|
|
2
|
+
|
|
3
|
+
## Config
|
|
4
|
+
|
|
5
|
+
SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
|
|
6
|
+
|
|
7
|
+
### Required fields
|
|
8
|
+
|
|
9
|
+
| Field | Type | Description |
|
|
10
|
+
|:------|:-----|:------------|
|
|
11
|
+
| `baselineArtifactName` | `string` | Uploaded baseline artifact name |
|
|
12
|
+
| `workingDirectory` | `string` | Root directory for resolving relative paths |
|
|
13
|
+
| `baseUrl` | `string` | URL where the running app is reachable |
|
|
14
|
+
| `resultsFile` | `string` | Path for capture results JSON |
|
|
15
|
+
| `manifestFile` | `string` | Path for screenshot manifest JSON |
|
|
16
|
+
| `screenshotsRoot` | `string` | Parent directory for screenshot output |
|
|
17
|
+
| `routes` | `array` | Route list to capture |
|
|
18
|
+
| `diff.threshold` | `number` | Max allowed mismatch ratio per screenshot |
|
|
19
|
+
| `diff.mode` | `string` | One of `report-only`, `fail-on-changes`, `fail-on-incomplete`, `strict` |
|
|
20
|
+
|
|
21
|
+
### Route entry
|
|
22
|
+
|
|
23
|
+
| Field | Type | Description |
|
|
24
|
+
|:------|:-----|:------------|
|
|
25
|
+
| `id` | `string` | Unique route identifier across runs |
|
|
26
|
+
| `path` | `string` | URL path appended to `baseUrl` |
|
|
27
|
+
| `viewport` | `string` or `object` | Preset name (`"desktop"`, `"mobile"`) or a custom object `{ "width": number, "height": number }` |
|
|
28
|
+
| `changePaths` | `string[]` | Optional prefixes used for changed-file scoping |
|
|
29
|
+
|
|
30
|
+
### Optional fields
|
|
31
|
+
|
|
32
|
+
| Field | Type | Description |
|
|
33
|
+
|:------|:-----|:------------|
|
|
34
|
+
| `selection.sharedPrefixes` | `string[]` | Prefixes that force the full route set |
|
|
35
|
+
| `selection.sharedExact` | `string[]` | Exact files that force the full route set |
|
|
36
|
+
|
|
37
|
+
### Example
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"baselineArtifactName": "ui-foundation-snapdrift-baseline",
|
|
42
|
+
"workingDirectory": ".",
|
|
43
|
+
"baseUrl": "http://127.0.0.1:8080",
|
|
44
|
+
"resultsFile": "qa-artifacts/snapdrift/baseline/current/results.json",
|
|
45
|
+
"manifestFile": "qa-artifacts/snapdrift/baseline/current/manifest.json",
|
|
46
|
+
"screenshotsRoot": "qa-artifacts/snapdrift/baseline/current",
|
|
47
|
+
"routes": [
|
|
48
|
+
{ "id": "root-index-desktop", "path": "/", "viewport": "desktop" },
|
|
49
|
+
{ "id": "root-index-mobile", "path": "/", "viewport": "mobile" }
|
|
50
|
+
],
|
|
51
|
+
"diff": {
|
|
52
|
+
"threshold": 0.01,
|
|
53
|
+
"mode": "report-only"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Baseline artifact
|
|
59
|
+
|
|
60
|
+
The published baseline bundle contains:
|
|
61
|
+
|
|
62
|
+
| File | Description |
|
|
63
|
+
|:-----|:------------|
|
|
64
|
+
| `results.json` | Capture results with per-route status and timing |
|
|
65
|
+
| `manifest.json` | Screenshot manifest with ids, paths, and dimensions |
|
|
66
|
+
| `screenshots/*.png` | Captured screenshot images |
|
|
67
|
+
|
|
68
|
+
## Drift artifact
|
|
69
|
+
|
|
70
|
+
The pull request drift bundle contains:
|
|
71
|
+
|
|
72
|
+
| File | Description |
|
|
73
|
+
|:-----|:------------|
|
|
74
|
+
| `summary.json` | Structured SnapDrift summary |
|
|
75
|
+
| `summary.md` | Human-readable SnapDrift report |
|
|
76
|
+
| `baseline/results.json` | Baseline capture results |
|
|
77
|
+
| `baseline/manifest.json` | Baseline manifest |
|
|
78
|
+
| `baseline/screenshots/*.png` | Baseline images |
|
|
79
|
+
| `current/results.json` | Current capture results |
|
|
80
|
+
| `current/manifest.json` | Current manifest |
|
|
81
|
+
| `current/screenshots/*.png` | Current images |
|
|
82
|
+
|
|
83
|
+
## Screenshot manifest shape
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"generatedAt": "2024-01-01T00:00:00.000Z",
|
|
88
|
+
"baseUrl": "http://127.0.0.1:8080",
|
|
89
|
+
"screenshots": [
|
|
90
|
+
{
|
|
91
|
+
"id": "root-index-desktop",
|
|
92
|
+
"path": "/",
|
|
93
|
+
"viewport": "desktop",
|
|
94
|
+
"imagePath": "screenshots/root-index-desktop.png",
|
|
95
|
+
"width": 1440,
|
|
96
|
+
"height": 900
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Summary shape
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"startedAt": "2024-01-01T00:00:00.000Z",
|
|
107
|
+
"finishedAt": "2024-01-01T00:00:01.000Z",
|
|
108
|
+
"completed": true,
|
|
109
|
+
"status": "clean",
|
|
110
|
+
"diffMode": "report-only",
|
|
111
|
+
"threshold": 0.01,
|
|
112
|
+
"totalScreenshots": 1,
|
|
113
|
+
"matchedScreenshots": 1,
|
|
114
|
+
"changedScreenshots": 0,
|
|
115
|
+
"missingInBaseline": 0,
|
|
116
|
+
"missingInCurrent": 0,
|
|
117
|
+
"selectedRoutes": ["root-index-desktop"],
|
|
118
|
+
"changed": [],
|
|
119
|
+
"missing": [],
|
|
120
|
+
"errors": [],
|
|
121
|
+
"dimensionChanges": [],
|
|
122
|
+
"baselineResultsPath": "...",
|
|
123
|
+
"currentResultsPath": "...",
|
|
124
|
+
"baselineManifestPath": "...",
|
|
125
|
+
"currentManifestPath": "...",
|
|
126
|
+
"baselineArtifactName": "ui-foundation-snapdrift-baseline",
|
|
127
|
+
"baselineSourceSha": "abc1234"
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Status values
|
|
132
|
+
|
|
133
|
+
| Status | Meaning |
|
|
134
|
+
|:-------|:--------|
|
|
135
|
+
| `clean` | All captures matched within threshold |
|
|
136
|
+
| `changes-detected` | One or more captures exceeded threshold |
|
|
137
|
+
| `incomplete` | Missing captures, dimension shifts, or comparison errors occurred |
|
|
138
|
+
| `skipped` | The report was intentionally skipped |
|
|
139
|
+
|
|
140
|
+
### Skipped summary
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"status": "skipped",
|
|
145
|
+
"reason": "no_snapdrift_relevant_changes",
|
|
146
|
+
"message": "No drift-relevant changes were detected in this pull request.",
|
|
147
|
+
"selectedRoutes": []
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
|
|
152
|
+
|
|
153
|
+
## Drift semantics
|
|
154
|
+
|
|
155
|
+
- Screenshots are matched by `id`
|
|
156
|
+
- Mismatch ratio is `different_pixels / total_pixels`
|
|
157
|
+
- `diff.threshold` applies per screenshot
|
|
158
|
+
- Missing captures are counted separately from drift signals
|
|
159
|
+
- Dimension mismatches skip pixel comparison and land in `dimensionChanges[]`
|
|
160
|
+
- `diff.mode` controls enforcement, not summary generation
|
|
161
|
+
|
|
162
|
+
## Drift modes
|
|
163
|
+
|
|
164
|
+
| Mode | Stops the run when |
|
|
165
|
+
|:-----|:-------------------|
|
|
166
|
+
| `report-only` | Never |
|
|
167
|
+
| `fail-on-changes` | `changedScreenshots > 0` |
|
|
168
|
+
| `fail-on-incomplete` | Errors, dimension shifts, or missing captures occur |
|
|
169
|
+
| `strict` | Any drift or incomplete comparison appears |
|
|
170
|
+
|
|
171
|
+
## Viewport presets
|
|
172
|
+
|
|
173
|
+
| Preset | Width | Height | Scale factor | Mobile | Touch |
|
|
174
|
+
|:-------|------:|-------:|-------------:|:-------|:------|
|
|
175
|
+
| `desktop` | 1440 | 900 | 1 | No | No |
|
|
176
|
+
| `mobile` | 390 | 844 | 3 | Yes | Yes |
|
|
177
|
+
|
|
178
|
+
## Capture defaults
|
|
179
|
+
|
|
180
|
+
| Setting | Value |
|
|
181
|
+
|:--------|:------|
|
|
182
|
+
| Navigation wait | `networkidle` |
|
|
183
|
+
| Navigation timeout | 30000ms |
|
|
184
|
+
| Settle delay | 300ms |
|
|
185
|
+
|
|
186
|
+
## Local CLI directory layout
|
|
187
|
+
|
|
188
|
+
When using the `snapdrift` CLI, outputs are written to `.snapdrift/` by default:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
.snapdrift/
|
|
192
|
+
baseline/ # written by: snapdrift capture
|
|
193
|
+
results.json
|
|
194
|
+
manifest.json
|
|
195
|
+
screenshots/*.png
|
|
196
|
+
current/ # written by: snapdrift diff
|
|
197
|
+
results.json
|
|
198
|
+
manifest.json
|
|
199
|
+
screenshots/*.png
|
|
200
|
+
diff/ # written by: snapdrift diff
|
|
201
|
+
summary.json
|
|
202
|
+
summary.md
|
|
203
|
+
report.html
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
All three directories can be overridden with `--baseline-dir`, `--current-dir`, and `--diff-dir`. See the [Local CLI guide](local-cli.md) for details.
|
|
207
|
+
|
|
208
|
+
## Primary entrypoints
|
|
209
|
+
|
|
210
|
+
- `actions/baseline`
|
|
211
|
+
- `actions/pr-diff`
|
|
212
|
+
- `snapdrift` CLI (local development)
|
|
213
|
+
|
|
214
|
+
## Advanced environment variables
|
|
215
|
+
|
|
216
|
+
These are for custom orchestration only. Wrapper actions set them automatically.
|
|
217
|
+
|
|
218
|
+
| Variable | Module | Description |
|
|
219
|
+
|:---------|:-------|:------------|
|
|
220
|
+
| `SNAPDRIFT_CONFIG_PATH` | `snapdrift-config.mjs` | Override config path |
|
|
221
|
+
| `SNAPDRIFT_ROUTE_IDS` | `capture-routes.mjs`, `compare-results.mjs` | Comma-separated route ids |
|
|
222
|
+
| `SNAPDRIFT_BASELINE_RESULTS_PATH` | `compare-results.mjs` | Baseline results path |
|
|
223
|
+
| `SNAPDRIFT_BASELINE_MANIFEST_PATH` | `compare-results.mjs` | Baseline manifest path |
|
|
224
|
+
| `SNAPDRIFT_CURRENT_RESULTS_PATH` | `compare-results.mjs` | Current results path |
|
|
225
|
+
| `SNAPDRIFT_CURRENT_MANIFEST_PATH` | `compare-results.mjs` | Current manifest path |
|
|
226
|
+
| `SNAPDRIFT_BASELINE_RUN_DIR` | `compare-results.mjs` | Baseline screenshot root |
|
|
227
|
+
| `SNAPDRIFT_CURRENT_RUN_DIR` | `compare-results.mjs` | Current screenshot root |
|
|
228
|
+
| `SNAPDRIFT_DRIFT_OUT_DIR` | `compare-results.mjs` | Drift report output directory |
|
|
229
|
+
| `SNAPDRIFT_SUMMARY_PATH` | `compare-results.mjs` | Summary JSON path |
|
|
230
|
+
| `SNAPDRIFT_SUMMARY_MARKDOWN_PATH` | `compare-results.mjs` | Summary markdown path |
|
|
231
|
+
| `SNAPDRIFT_BASELINE_ARTIFACT_NAME` | `compare-results.mjs` | Baseline artifact label to embed in the report |
|
|
232
|
+
| `SNAPDRIFT_BASELINE_SOURCE_SHA` | `compare-results.mjs` | Baseline source SHA to embed in the report |
|
|
233
|
+
| `SNAPDRIFT_ENFORCE_OUTCOME` | `compare-results.mjs` | Set to `0` to disable enforcement in direct CLI usage |
|