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
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
|
+

|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

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