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 ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.2.0 - 2026-04-08
6
+
7
+ ### Features
8
+
9
+ - **Local CLI** (`snapdrift capture` / `snapdrift diff`) — run visual captures and diffs locally against a running app without GitHub Actions. Outputs land in `.snapdrift/` by default; all paths are overridable via flags. Exits non-zero when `diff.mode` enforces failure.
10
+ - **Self-contained HTML diff report** — the drift artifact now includes a single `report.html` with baseline/current screenshots and diff images embedded as base64, viewable without any server.
11
+ - **Custom viewport support** — route `viewport` now accepts an object `{ "width": number, "height": number }` in addition to the `"desktop"` and `"mobile"` presets.
12
+ - **Parallel capture by viewport** — routes are now captured concurrently per viewport group, roughly halving capture time on multi-route configurations.
13
+ - **Route ID sanitization** — route IDs are sanitised before use as filenames, preventing path-traversal sequences from escaping the screenshots directory.
14
+ - **Capture retry logic** — failed route captures are retried once before being recorded as errors.
15
+ - **Progress logging** — capture and comparison steps now emit per-route progress to stdout.
16
+
17
+ ### Fixes
18
+
19
+ - HTML report image embedding now falls back correctly when a resolved image path is missing.
20
+ - Custom viewport values are formatted correctly in PR comment reports and dimension-shift entries.
21
+ - Viewport width/height are cast to numbers before comparison to prevent type-mismatch false positives.
22
+ - Dimension shifts section in PR comment reports is auto-expanded by default.
23
+
24
+ ### Dependencies
25
+
26
+ - `playwright` 1.58.2 → 1.59.1
27
+ - `eslint` 9.x → 10.x, `@eslint/js` 9.x → 10.x
28
+ - `typescript` 5.x → 6.x
29
+ - `jest` 30.2.0 → 30.3.0
30
+ - `actions/upload-artifact` v4 → v7
31
+
32
+ ### Infrastructure
33
+
34
+ - Added npm publish workflow (`.github/workflows/publish.yml`) triggered on GitHub release, with provenance attestation.
35
+ - Added `publishConfig` to `package.json` to make public access and registry explicit.
36
+
37
+ ## 0.1.0 - 2026-03-09
38
+
39
+ - Prepared SnapDrift for the first public GitHub release under version `0.1.0`.
40
+ - Fixed capture metadata to record actual full-page PNG dimensions so dimension shifts stay classified separately from comparison errors.
41
+ - Standardized the public contract on SnapDrift-only config paths, environment variables, report markers, and artifact filenames.
42
+ - Added Node 22 self-provisioning to the standalone actions that shell out to `node` or `npm`.
43
+ - Updated README, contracts, and integration docs to reference public release tags, with commit SHA pinning as an optional hardening step.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 CodeSamplez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # SnapDrift
2
+
3
+ ![SnapDrift](assets/snapdrift-logo-banner.png)
4
+
5
+ ![CI](https://github.com/ranacseruet/snapdrift/actions/workflows/ci.yml/badge.svg)
6
+ ![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)
7
+ ![License: MIT](https://img.shields.io/badge/license-MIT-blue)
8
+
9
+ SnapDrift captures full-page application frames, compares them against a known baseline, and reports drift directly in GitHub Actions.
10
+
11
+ SnapDrift is ready to integrate from public GitHub releases. Workflow examples below use the first public tag for readability; security-conscious consumers can pin the resolved commit SHA instead.
12
+
13
+ ## What SnapDrift handles
14
+
15
+ - Baseline capture on `main`
16
+ - Pull request drift detection against the latest successful baseline
17
+ - Route scoping from changed files
18
+ - PR report upserts
19
+ - Drift enforcement through `diff.mode`
20
+
21
+ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDrift takes over once the app is reachable.
22
+
23
+ ## Quickstart
24
+
25
+ **1. Add `.github/snapdrift.json` to your repo:**
26
+
27
+ ```json
28
+ {
29
+ "baselineArtifactName": "my-app-snapdrift-baseline",
30
+ "workingDirectory": ".",
31
+ "baseUrl": "http://127.0.0.1:8080",
32
+ "resultsFile": "qa-artifacts/snapdrift/baseline/current/results.json",
33
+ "manifestFile": "qa-artifacts/snapdrift/baseline/current/manifest.json",
34
+ "screenshotsRoot": "qa-artifacts/snapdrift/baseline/current",
35
+ "routes": [
36
+ { "id": "home-desktop", "path": "/", "viewport": "desktop" },
37
+ { "id": "home-mobile", "path": "/", "viewport": "mobile" }
38
+ ],
39
+ "diff": { "threshold": 0.01, "mode": "report-only" }
40
+ }
41
+ ```
42
+
43
+ **2. Publish a baseline on push to `main`:**
44
+
45
+ ```yaml
46
+ - name: SnapDrift Baseline
47
+ uses: ranacseruet/snapdrift/actions/baseline@v0.1.0
48
+ with:
49
+ repo-config-path: .github/snapdrift.json
50
+ ```
51
+
52
+ **3. Run SnapDrift on pull requests:**
53
+
54
+ ```yaml
55
+ - name: SnapDrift Report
56
+ uses: ranacseruet/snapdrift/actions/pr-diff@v0.1.0
57
+ with:
58
+ github-token: ${{ secrets.GITHUB_TOKEN }}
59
+ repo-config-path: .github/snapdrift.json
60
+ ```
61
+
62
+ That is the full integration. See the [Integration Guide](docs/integration-guide.md) for workflow examples, permissions, compatibility notes, and advanced overrides.
63
+
64
+ ## Local CLI
65
+
66
+ SnapDrift ships a `snapdrift` CLI for running captures and diffs locally against a running app — no GitHub Actions required. Use it during development to validate UI changes before pushing.
67
+
68
+ ```bash
69
+ # Capture a baseline
70
+ snapdrift capture
71
+
72
+ # Compare against it after making UI changes
73
+ snapdrift diff --open
74
+ ```
75
+
76
+ See the [Local CLI guide](docs/local-cli.md) for installation, all flags, directory layout, and examples.
77
+
78
+ ## Drift modes
79
+
80
+ Start with `report-only` while baselines settle. Move to `fail-on-changes` or stricter modes once the signal is stable.
81
+
82
+ | Mode | Stops the run when |
83
+ |------|--------------------|
84
+ | `report-only` | Never |
85
+ | `fail-on-changes` | Any capture exceeds threshold |
86
+ | `fail-on-incomplete` | Captures are missing, dimensions shift, or comparison errors occur |
87
+ | `strict` | Any drift signal or incomplete comparison appears |
88
+
89
+ ## Current constraints
90
+
91
+ - Ubuntu runners only
92
+ - Full-page capture only
93
+ - Fixed viewport presets: `desktop` (1440×900) and `mobile` (390×844)
94
+ - One global `diff.threshold`
95
+ - Dimension shifts are reported separately from pixel drift
96
+
97
+ ## Docs
98
+
99
+ - [Integration Guide](docs/integration-guide.md)
100
+ - [Local CLI](docs/local-cli.md)
101
+ - [Contracts](docs/contracts.md)
102
+ - [Changelog](CHANGELOG.md)
103
+ - [Contributing](CONTRIBUTING.md)
104
+ - [License](LICENSE)
@@ -0,0 +1,142 @@
1
+ name: SnapDrift Baseline
2
+ description: Capture configured routes, stage a SnapDrift baseline, and upload it with the configured artifact name.
3
+
4
+ inputs:
5
+ repo-config-path:
6
+ description: Path to the SnapDrift config file.
7
+ required: false
8
+ default: .github/snapdrift.json
9
+ route-ids:
10
+ description: Optional comma-separated route ids to capture.
11
+ required: false
12
+ default: ''
13
+ artifact-retention-days:
14
+ description: Retention period for the uploaded baseline artifact.
15
+ required: false
16
+ default: '30'
17
+ upload-artifact:
18
+ description: Whether to upload the staged baseline artifact.
19
+ required: false
20
+ default: 'true'
21
+
22
+ outputs:
23
+ artifact-name:
24
+ description: Artifact name loaded from the SnapDrift config.
25
+ value: ${{ steps.config.outputs.artifact_name }}
26
+ bundle-dir:
27
+ description: Staged artifact bundle directory.
28
+ value: ${{ steps.stage.outputs.bundle_dir }}
29
+ results-file:
30
+ description: Absolute path to the generated baseline results file.
31
+ value: ${{ steps.capture.outputs.results_file }}
32
+ manifest-file:
33
+ description: Absolute path to the generated baseline manifest file.
34
+ value: ${{ steps.capture.outputs.manifest_file }}
35
+ screenshots-root:
36
+ description: Absolute path to the generated screenshots root.
37
+ value: ${{ steps.capture.outputs.screenshots_root }}
38
+ selected-route-ids:
39
+ description: Comma-separated route ids captured in this run.
40
+ value: ${{ steps.capture.outputs.selected_route_ids }}
41
+
42
+ runs:
43
+ using: composite
44
+ steps:
45
+ - name: Set action root path
46
+ shell: bash
47
+ run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
48
+
49
+ - name: Set up Node.js
50
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
51
+ with:
52
+ node-version: '22'
53
+
54
+ - id: config
55
+ shell: bash
56
+ env:
57
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
58
+ run: |
59
+ node --input-type=module <<'EOF'
60
+ import fs from 'node:fs/promises';
61
+ import path from 'node:path';
62
+ import { pathToFileURL } from 'node:url';
63
+
64
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
65
+ const { loadSnapdriftConfig } = await import(pathToFileURL(modulePath));
66
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
67
+
68
+ await fs.appendFile(process.env.GITHUB_OUTPUT, `artifact_name=${config.baselineArtifactName}\n`);
69
+ EOF
70
+
71
+ - name: Install shared action dependencies
72
+ shell: bash
73
+ run: npm ci --prefix "$ACTION_ROOT"
74
+
75
+ - name: Install Playwright Chromium
76
+ shell: bash
77
+ run: |
78
+ "$ACTION_ROOT/node_modules/.bin/playwright" install --with-deps chromium
79
+
80
+ - id: capture
81
+ shell: bash
82
+ env:
83
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
84
+ ROUTE_IDS: ${{ inputs.route-ids }}
85
+ run: |
86
+ node --input-type=module <<'EOF'
87
+ import fs from 'node:fs/promises';
88
+ import path from 'node:path';
89
+ import { pathToFileURL } from 'node:url';
90
+
91
+ const captureModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/capture-routes.mjs');
92
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
93
+ const { runBaselineCapture } = await import(pathToFileURL(captureModulePath));
94
+ const { splitCommaList } = await import(pathToFileURL(configModulePath));
95
+
96
+ const result = await runBaselineCapture({
97
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
98
+ routeIds: splitCommaList(process.env.ROUTE_IDS)
99
+ });
100
+
101
+ const outputs = [
102
+ `results_file=${result.resultsPath}`,
103
+ `manifest_file=${result.manifestPath}`,
104
+ `screenshots_root=${result.screenshotsRoot}`,
105
+ `selected_route_ids=${result.selectedRouteIds.join(',')}`
106
+ ].join('\n') + '\n';
107
+
108
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
109
+ EOF
110
+
111
+ - id: stage
112
+ shell: bash
113
+ env:
114
+ RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
115
+ MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
116
+ SCREENSHOTS_DIR: ${{ steps.capture.outputs.screenshots_root }}/screenshots
117
+ run: |
118
+ node --input-type=module <<'EOF'
119
+ import fs from 'node:fs/promises';
120
+ import path from 'node:path';
121
+ import { pathToFileURL } from 'node:url';
122
+
123
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/stage-artifacts.mjs');
124
+ const { stageArtifacts } = await import(pathToFileURL(modulePath));
125
+
126
+ const result = await stageArtifacts({
127
+ artifactType: 'baseline',
128
+ resultsPath: process.env.RESULTS_PATH || undefined,
129
+ manifestPath: process.env.MANIFEST_PATH || undefined,
130
+ screenshotsDir: process.env.SCREENSHOTS_DIR || undefined
131
+ });
132
+
133
+ await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
134
+ EOF
135
+
136
+ - if: inputs.upload-artifact == 'true'
137
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
138
+ with:
139
+ name: ${{ steps.config.outputs.artifact_name }}
140
+ retention-days: ${{ inputs.artifact-retention-days }}
141
+ if-no-files-found: warn
142
+ path: ${{ steps.stage.outputs.bundle_dir }}
@@ -0,0 +1,78 @@
1
+ name: SnapDrift Capture
2
+ description: Capture configured SnapDrift routes and emit the standard results and manifest files.
3
+
4
+ inputs:
5
+ repo-config-path:
6
+ description: Path to the SnapDrift config file.
7
+ required: false
8
+ default: .github/snapdrift.json
9
+ route-ids:
10
+ description: Optional comma-separated route ids to capture.
11
+ required: false
12
+ default: ''
13
+
14
+ outputs:
15
+ results-file:
16
+ description: Absolute path to the generated baseline results file.
17
+ value: ${{ steps.capture.outputs.results_file }}
18
+ manifest-file:
19
+ description: Absolute path to the generated screenshot manifest file.
20
+ value: ${{ steps.capture.outputs.manifest_file }}
21
+ screenshots-root:
22
+ description: Absolute path to the generated screenshots root.
23
+ value: ${{ steps.capture.outputs.screenshots_root }}
24
+ selected-route-ids:
25
+ description: Comma-separated route ids captured in this run.
26
+ value: ${{ steps.capture.outputs.selected_route_ids }}
27
+
28
+ runs:
29
+ using: composite
30
+ steps:
31
+ - name: Set action root path
32
+ shell: bash
33
+ run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
34
+
35
+ - name: Set up Node.js
36
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
37
+ with:
38
+ node-version: '22'
39
+
40
+ - name: Install shared action dependencies
41
+ shell: bash
42
+ run: npm ci --prefix "$ACTION_ROOT"
43
+
44
+ - name: Install Playwright Chromium
45
+ shell: bash
46
+ run: |
47
+ "$ACTION_ROOT/node_modules/.bin/playwright" install --with-deps chromium
48
+
49
+ - id: capture
50
+ shell: bash
51
+ env:
52
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
53
+ ROUTE_IDS: ${{ inputs.route-ids }}
54
+ run: |
55
+ node --input-type=module <<'EOF'
56
+ import fs from 'node:fs/promises';
57
+ import path from 'node:path';
58
+ import { pathToFileURL } from 'node:url';
59
+
60
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/capture-routes.mjs');
61
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
62
+ const { runBaselineCapture } = await import(pathToFileURL(modulePath));
63
+ const { splitCommaList } = await import(pathToFileURL(configModulePath));
64
+
65
+ const result = await runBaselineCapture({
66
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
67
+ routeIds: splitCommaList(process.env.ROUTE_IDS)
68
+ });
69
+
70
+ const outputs = [
71
+ `results_file=${result.resultsPath}`,
72
+ `manifest_file=${result.manifestPath}`,
73
+ `screenshots_root=${result.screenshotsRoot}`,
74
+ `selected_route_ids=${result.selectedRouteIds.join(',')}`
75
+ ].join('\n') + '\n';
76
+
77
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
78
+ EOF
@@ -0,0 +1,104 @@
1
+ name: SnapDrift Comment
2
+ description: Upsert a PR comment with the SnapDrift report.
3
+
4
+ inputs:
5
+ github-token:
6
+ description: Token used to update the pull request comment.
7
+ required: true
8
+ pr-number:
9
+ description: Pull request number to comment on.
10
+ required: false
11
+ default: ''
12
+ summary-path:
13
+ description: Path to the SnapDrift summary JSON.
14
+ required: true
15
+ run-url:
16
+ description: Workflow run URL to include in the comment.
17
+ required: false
18
+ default: ''
19
+ artifact-name:
20
+ description: Artifact label to include in the comment.
21
+ required: false
22
+ default: ''
23
+
24
+ runs:
25
+ using: composite
26
+ steps:
27
+ - name: Set action root path
28
+ shell: bash
29
+ run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
30
+
31
+ - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
32
+ env:
33
+ VISUAL_DIFF_SUMMARY_PATH: ${{ inputs.summary-path }}
34
+ VISUAL_DIFF_RUN_URL: ${{ inputs.run-url }}
35
+ VISUAL_DIFF_ARTIFACT_NAME: ${{ inputs.artifact-name }}
36
+ INPUT_PR_NUMBER: ${{ inputs.pr-number }}
37
+ with:
38
+ github-token: ${{ inputs.github-token }}
39
+ script: |
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+ const { pathToFileURL } = require('url');
43
+
44
+ const summaryPath = process.env.VISUAL_DIFF_SUMMARY_PATH;
45
+ if (!fs.existsSync(summaryPath)) {
46
+ core.warning(`SnapDrift summary not found at ${summaryPath}`);
47
+ return;
48
+ }
49
+
50
+ const issueNumber = Number(process.env.INPUT_PR_NUMBER || context.payload.pull_request?.number || context.issue?.number || '');
51
+ if (!Number.isFinite(issueNumber) || issueNumber <= 0) {
52
+ core.warning('SnapDrift PR report skipped because no pull request number was available.');
53
+ return;
54
+ }
55
+
56
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
57
+ const { buildReportCommentBody, PR_COMMENT_MARKERS } = await import(pathToFileURL(modulePath));
58
+
59
+ const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
60
+ const runUrl = process.env.VISUAL_DIFF_RUN_URL || `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
61
+
62
+ const body = buildReportCommentBody(summary, {
63
+ artifactName: process.env.VISUAL_DIFF_ARTIFACT_NAME || '',
64
+ runUrl
65
+ });
66
+
67
+ const comments = await github.paginate(github.rest.issues.listComments, {
68
+ owner: context.repo.owner,
69
+ repo: context.repo.repo,
70
+ issue_number: issueNumber,
71
+ per_page: 100
72
+ });
73
+ const matchingComments = comments
74
+ .filter((comment) => comment.body && PR_COMMENT_MARKERS.some((marker) => comment.body.includes(marker)))
75
+ .sort((left, right) => {
76
+ const leftTime = new Date(left.updated_at || left.created_at).getTime();
77
+ const rightTime = new Date(right.updated_at || right.created_at).getTime();
78
+ return rightTime - leftTime;
79
+ });
80
+ const existing = matchingComments[0];
81
+
82
+ if (existing) {
83
+ await github.rest.issues.updateComment({
84
+ owner: context.repo.owner,
85
+ repo: context.repo.repo,
86
+ comment_id: existing.id,
87
+ body
88
+ });
89
+ } else {
90
+ await github.rest.issues.createComment({
91
+ owner: context.repo.owner,
92
+ repo: context.repo.repo,
93
+ issue_number: issueNumber,
94
+ body
95
+ });
96
+ }
97
+
98
+ for (const duplicate of matchingComments.slice(1)) {
99
+ await github.rest.issues.deleteComment({
100
+ owner: context.repo.owner,
101
+ repo: context.repo.repo,
102
+ comment_id: duplicate.id
103
+ });
104
+ }
@@ -0,0 +1,163 @@
1
+ name: SnapDrift Compare
2
+ description: Compare the current capture against a baseline and emit the SnapDrift report files.
3
+
4
+ inputs:
5
+ repo-config-path:
6
+ description: Path to the SnapDrift config file.
7
+ required: false
8
+ default: .github/snapdrift.json
9
+ route-ids:
10
+ description: Optional comma-separated route ids to compare.
11
+ required: false
12
+ default: ''
13
+ baseline-results-path:
14
+ description: Absolute or workspace-relative path to the baseline results JSON.
15
+ required: true
16
+ baseline-manifest-path:
17
+ description: Absolute or workspace-relative path to the baseline screenshot manifest JSON.
18
+ required: true
19
+ current-results-path:
20
+ description: Absolute or workspace-relative path to the current results JSON.
21
+ required: false
22
+ default: ''
23
+ current-manifest-path:
24
+ description: Absolute or workspace-relative path to the current screenshot manifest JSON.
25
+ required: false
26
+ default: ''
27
+ baseline-run-dir:
28
+ description: Root directory that contains baseline screenshots.
29
+ required: true
30
+ current-run-dir:
31
+ description: Root directory that contains current screenshots.
32
+ required: false
33
+ default: ''
34
+ out-dir:
35
+ description: Directory where the SnapDrift report files should be written.
36
+ required: false
37
+ default: qa-artifacts/snapdrift/drift/current
38
+ summary-path:
39
+ description: Optional explicit path for the SnapDrift summary JSON file.
40
+ required: false
41
+ default: ''
42
+ markdown-path:
43
+ description: Optional explicit path for the SnapDrift summary markdown file.
44
+ required: false
45
+ default: ''
46
+ baseline-artifact-name:
47
+ description: Optional baseline artifact label for the summary.
48
+ required: false
49
+ default: ''
50
+ baseline-source-sha:
51
+ description: Optional baseline source SHA for the summary.
52
+ required: false
53
+ default: ''
54
+ enforce-outcome:
55
+ description: Whether to fail according to diff.mode after writing the summary.
56
+ required: false
57
+ default: 'false'
58
+
59
+ outputs:
60
+ summary-path:
61
+ description: Absolute path to the generated summary JSON file.
62
+ value: ${{ steps.compare.outputs.summary_path }}
63
+ markdown-path:
64
+ description: Absolute path to the generated summary markdown file.
65
+ value: ${{ steps.compare.outputs.markdown_path }}
66
+ status:
67
+ description: Computed SnapDrift status.
68
+ value: ${{ steps.compare.outputs.status }}
69
+ changed-screenshots:
70
+ description: Number of changed screenshots.
71
+ value: ${{ steps.compare.outputs.changed_screenshots }}
72
+ matched-screenshots:
73
+ description: Number of matched screenshots.
74
+ value: ${{ steps.compare.outputs.matched_screenshots }}
75
+ missing-in-baseline:
76
+ description: Number of screenshots missing in the baseline.
77
+ value: ${{ steps.compare.outputs.missing_in_baseline }}
78
+ missing-in-current:
79
+ description: Number of screenshots missing in the current capture.
80
+ value: ${{ steps.compare.outputs.missing_in_current }}
81
+ errors-count:
82
+ description: Number of comparison errors.
83
+ value: ${{ steps.compare.outputs.errors_count }}
84
+
85
+ runs:
86
+ using: composite
87
+ steps:
88
+ - name: Set action root path
89
+ shell: bash
90
+ run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
91
+
92
+ - name: Set up Node.js
93
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
94
+ with:
95
+ node-version: '22'
96
+
97
+ - name: Install shared action dependencies
98
+ shell: bash
99
+ run: npm ci --prefix "$ACTION_ROOT"
100
+
101
+ - id: compare
102
+ shell: bash
103
+ env:
104
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
105
+ ROUTE_IDS: ${{ inputs.route-ids }}
106
+ BASELINE_RESULTS_PATH: ${{ inputs.baseline-results-path }}
107
+ BASELINE_MANIFEST_PATH: ${{ inputs.baseline-manifest-path }}
108
+ CURRENT_RESULTS_PATH: ${{ inputs.current-results-path }}
109
+ CURRENT_MANIFEST_PATH: ${{ inputs.current-manifest-path }}
110
+ BASELINE_RUN_DIR: ${{ inputs.baseline-run-dir }}
111
+ CURRENT_RUN_DIR: ${{ inputs.current-run-dir }}
112
+ OUT_DIR: ${{ inputs.out-dir }}
113
+ SUMMARY_PATH: ${{ inputs.summary-path }}
114
+ MARKDOWN_PATH: ${{ inputs.markdown-path }}
115
+ BASELINE_ARTIFACT_NAME: ${{ inputs.baseline-artifact-name }}
116
+ BASELINE_SOURCE_SHA: ${{ inputs.baseline-source-sha }}
117
+ ENFORCE_OUTCOME: ${{ inputs.enforce-outcome }}
118
+ run: |
119
+ node --input-type=module <<'EOF'
120
+ import fs from 'node:fs/promises';
121
+ import path from 'node:path';
122
+ import { pathToFileURL } from 'node:url';
123
+
124
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/compare-results.mjs');
125
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
126
+ const { runDriftCheckCli } = await import(pathToFileURL(modulePath));
127
+ const { splitCommaList } = await import(pathToFileURL(configModulePath));
128
+
129
+ const resolvedOutDir = path.resolve(process.env.OUT_DIR || 'qa-artifacts/snapdrift/drift/current');
130
+ const resolvedSummaryPath = path.resolve(process.env.SUMMARY_PATH || path.join(resolvedOutDir, 'summary.json'));
131
+ const resolvedMarkdownPath = path.resolve(process.env.MARKDOWN_PATH || path.join(resolvedOutDir, 'summary.md'));
132
+
133
+ await runDriftCheckCli({
134
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
135
+ baselineResultsPath: process.env.BASELINE_RESULTS_PATH || undefined,
136
+ baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
137
+ currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
138
+ currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
139
+ baselineRunDir: process.env.BASELINE_RUN_DIR || undefined,
140
+ currentRunDir: process.env.CURRENT_RUN_DIR || undefined,
141
+ outDir: resolvedOutDir,
142
+ summaryPath: resolvedSummaryPath,
143
+ markdownPath: resolvedMarkdownPath,
144
+ routeIds: splitCommaList(process.env.ROUTE_IDS),
145
+ baselineArtifactName: process.env.BASELINE_ARTIFACT_NAME || undefined,
146
+ baselineSourceSha: process.env.BASELINE_SOURCE_SHA || undefined,
147
+ enforceOutcome: process.env.ENFORCE_OUTCOME === 'true'
148
+ });
149
+
150
+ const summary = JSON.parse(await fs.readFile(resolvedSummaryPath, 'utf8'));
151
+ const outputs = [
152
+ `summary_path=${resolvedSummaryPath}`,
153
+ `markdown_path=${resolvedMarkdownPath}`,
154
+ `status=${summary.status || ''}`,
155
+ `changed_screenshots=${summary.changedScreenshots || 0}`,
156
+ `matched_screenshots=${summary.matchedScreenshots || 0}`,
157
+ `missing_in_baseline=${summary.missingInBaseline || 0}`,
158
+ `missing_in_current=${summary.missingInCurrent || 0}`,
159
+ `errors_count=${Array.isArray(summary.errors) ? summary.errors.length : 0}`
160
+ ].join('\n') + '\n';
161
+
162
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
163
+ EOF