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.
@@ -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 |