snapdrift 0.2.0 → 0.2.1

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 CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.1 - 2026-04-13
6
+
7
+ ### Infrastructure
8
+
9
+ - **Integration test for compare → stage pipeline** — `tests/integration/capture-compare-pipeline.test.js` exercises the full `generateDriftReport → stageArtifacts` data flow with synthetic PNG fixtures (no live browser needed), catching interface mismatches between modules that unit tests alone would miss.
10
+
11
+ ### Features
12
+
13
+ - **Configurable PR comment truncation limits** — `actions/pr-diff` and `actions/comment` now accept optional `max-changed-rows` (default: 20) and `max-error-rows` (default: 10) inputs, letting teams control how many rows appear in the drift and error tables before the overflow note.
14
+ - **Per-route navigation timeout** — routes now accept an optional `navigationTimeout` (positive integer, ms) that overrides the global 30 000 ms default for that route. Useful for slow SSR pages that need more time or fast static pages that should fail fast.
15
+ - **Baseline refresh workflow template** — `docs/workflow-templates/refresh-baseline-on-merge.yml` is a drop-in workflow template for consumer repos that automatically republishes the SnapDrift baseline on every push to the default branch. Supports an optional label gate to avoid republishing on every push.
16
+
17
+ ### Fixes
18
+
19
+ - **Baseline refresh template trigger** — switched from `pull_request: closed` to `push` on the default branch so published artifacts are actually discoverable by the baseline resolver in `actions/pr-diff`, which hardcodes `event:'push'` when querying workflow runs.
20
+ - **Artifact action pins** — `actions/upload-artifact` and `actions/download-artifact` in composite action YAMLs are now pinned to v7 SHAs, matching the CI workflow (the v0.2.0 changelog noted this upgrade but only the CI workflow was updated at the time).
21
+ - **Stale version references** — README and integration guide examples now reference `@v0.2.1` instead of the outdated `@v0.1.0`. README "Current constraints" updated to reflect custom viewport support.
22
+
5
23
  ## 0.2.0 - 2026-04-08
6
24
 
7
25
  ### Features
package/README.md CHANGED
@@ -44,7 +44,7 @@ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDri
44
44
 
45
45
  ```yaml
46
46
  - name: SnapDrift Baseline
47
- uses: ranacseruet/snapdrift/actions/baseline@v0.1.0
47
+ uses: ranacseruet/snapdrift/actions/baseline@v0.2.1
48
48
  with:
49
49
  repo-config-path: .github/snapdrift.json
50
50
  ```
@@ -53,7 +53,7 @@ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDri
53
53
 
54
54
  ```yaml
55
55
  - name: SnapDrift Report
56
- uses: ranacseruet/snapdrift/actions/pr-diff@v0.1.0
56
+ uses: ranacseruet/snapdrift/actions/pr-diff@v0.2.1
57
57
  with:
58
58
  github-token: ${{ secrets.GITHUB_TOKEN }}
59
59
  repo-config-path: .github/snapdrift.json
@@ -90,7 +90,7 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
90
90
 
91
91
  - Ubuntu runners only
92
92
  - Full-page capture only
93
- - Fixed viewport presets: `desktop` (1440×900) and `mobile` (390×844)
93
+ - Viewport presets: `desktop` (1440×900) and `mobile` (390×844), or custom `{ "width": number, "height": number }`
94
94
  - One global `diff.threshold`
95
95
  - Dimension shifts are reported separately from pixel drift
96
96
 
@@ -134,7 +134,7 @@ runs:
134
134
  EOF
135
135
 
136
136
  - if: inputs.upload-artifact == 'true'
137
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
137
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
138
138
  with:
139
139
  name: ${{ steps.config.outputs.artifact_name }}
140
140
  retention-days: ${{ inputs.artifact-retention-days }}
@@ -20,6 +20,14 @@ inputs:
20
20
  description: Artifact label to include in the comment.
21
21
  required: false
22
22
  default: ''
23
+ max-changed-rows:
24
+ description: Maximum number of changed-route rows shown in the PR comment before truncation.
25
+ required: false
26
+ default: '20'
27
+ max-error-rows:
28
+ description: Maximum number of error rows shown in the PR comment before truncation.
29
+ required: false
30
+ default: '10'
23
31
 
24
32
  runs:
25
33
  using: composite
@@ -34,6 +42,8 @@ runs:
34
42
  VISUAL_DIFF_RUN_URL: ${{ inputs.run-url }}
35
43
  VISUAL_DIFF_ARTIFACT_NAME: ${{ inputs.artifact-name }}
36
44
  INPUT_PR_NUMBER: ${{ inputs.pr-number }}
45
+ MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
46
+ MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
37
47
  with:
38
48
  github-token: ${{ inputs.github-token }}
39
49
  script: |
@@ -59,9 +69,13 @@ runs:
59
69
  const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
60
70
  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
71
 
72
+ const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
73
+ const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
62
74
  const body = buildReportCommentBody(summary, {
63
75
  artifactName: process.env.VISUAL_DIFF_ARTIFACT_NAME || '',
64
- runUrl
76
+ runUrl,
77
+ maxChangedRows,
78
+ maxErrorRows
65
79
  });
66
80
 
67
81
  const comments = await github.paginate(github.rest.issues.listComments, {
@@ -45,6 +45,14 @@ inputs:
45
45
  description: Whether to publish the SnapDrift PR report.
46
46
  required: false
47
47
  default: 'true'
48
+ max-changed-rows:
49
+ description: Maximum number of changed-route rows shown in the PR comment before truncation.
50
+ required: false
51
+ default: '20'
52
+ max-error-rows:
53
+ description: Maximum number of error rows shown in the PR comment before truncation.
54
+ required: false
55
+ default: '10'
48
56
 
49
57
  outputs:
50
58
  should-run:
@@ -254,7 +262,7 @@ runs:
254
262
  }
255
263
 
256
264
  - if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found == 'true'
257
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
265
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
258
266
  with:
259
267
  github-token: ${{ inputs.github-token }}
260
268
  repository: ${{ inputs.baseline-repository || github.repository }}
@@ -467,7 +475,7 @@ runs:
467
475
  await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
468
476
  EOF
469
477
 
470
- - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
478
+ - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
471
479
  with:
472
480
  name: ${{ steps.context.outputs.artifact_name }}
473
481
  retention-days: ${{ inputs.artifact-retention-days }}
@@ -490,6 +498,8 @@ runs:
490
498
  SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
491
499
  ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
492
500
  RUN_URL: ${{ steps.context.outputs.run_url }}
501
+ MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
502
+ MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
493
503
  with:
494
504
  github-token: ${{ inputs.github-token }}
495
505
  script: |
@@ -525,9 +535,13 @@ runs:
525
535
  const { buildReportCommentBody } = await import(pathToFileURL(modulePath));
526
536
 
527
537
  const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
538
+ const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
539
+ const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
528
540
  const body = buildReportCommentBody(summary, {
529
541
  artifactName: process.env.ARTIFACT_NAME || '',
530
- runUrl: process.env.RUN_URL || ''
542
+ runUrl: process.env.RUN_URL || '',
543
+ maxChangedRows,
544
+ maxErrorRows
531
545
  });
532
546
 
533
547
  const comments = await github.paginate(github.rest.issues.listComments, {
@@ -135,7 +135,7 @@ runs:
135
135
  }
136
136
 
137
137
  - if: steps.resolve.outputs.found == 'true'
138
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
138
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
139
139
  with:
140
140
  github-token: ${{ inputs.github-token }}
141
141
  repository: ${{ inputs.repository || github.repository }}
package/docs/contracts.md CHANGED
@@ -26,6 +26,7 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
26
26
  | `path` | `string` | URL path appended to `baseUrl` |
27
27
  | `viewport` | `string` or `object` | Preset name (`"desktop"`, `"mobile"`) or a custom object `{ "width": number, "height": number }` |
28
28
  | `changePaths` | `string[]` | Optional prefixes used for changed-file scoping |
29
+ | `navigationTimeout` | `number` | Optional per-route navigation timeout in ms (overrides the 30 000 ms global default) |
29
30
 
30
31
  ### Optional fields
31
32
 
@@ -46,7 +46,7 @@ SnapDrift owns route selection, capture, comparison, skipped-report generation,
46
46
 
47
47
  ```yaml
48
48
  - name: SnapDrift Baseline
49
- uses: ranacseruet/snapdrift/actions/baseline@v0.1.0
49
+ uses: ranacseruet/snapdrift/actions/baseline@v0.2.1
50
50
  with:
51
51
  repo-config-path: .github/snapdrift.json
52
52
  artifact-retention-days: '30'
@@ -70,7 +70,7 @@ Then add SnapDrift after the app is running:
70
70
 
71
71
  ```yaml
72
72
  - name: SnapDrift Report
73
- uses: ranacseruet/snapdrift/actions/pr-diff@v0.1.0
73
+ uses: ranacseruet/snapdrift/actions/pr-diff@v0.2.1
74
74
  with:
75
75
  github-token: ${{ secrets.GITHUB_TOKEN }}
76
76
  repo-config-path: .github/snapdrift.json
@@ -111,7 +111,7 @@ jobs:
111
111
  done
112
112
 
113
113
  - name: SnapDrift Report
114
- uses: ranacseruet/snapdrift/actions/pr-diff@v0.1.0
114
+ uses: ranacseruet/snapdrift/actions/pr-diff@v0.2.1
115
115
  with:
116
116
  github-token: ${{ secrets.GITHUB_TOKEN }}
117
117
  repo-config-path: .github/snapdrift.json
@@ -150,7 +150,7 @@ jobs:
150
150
  done
151
151
 
152
152
  - name: SnapDrift Report
153
- uses: ranacseruet/snapdrift/actions/pr-diff@v0.1.0
153
+ uses: ranacseruet/snapdrift/actions/pr-diff@v0.2.1
154
154
  with:
155
155
  github-token: ${{ secrets.GITHUB_TOKEN }}
156
156
  repo-config-path: .github/snapdrift.json
@@ -178,6 +178,8 @@ jobs:
178
178
  | `baseline-workflow-id` | Override the baseline workflow id |
179
179
  | `baseline-branch` | Override the baseline branch |
180
180
  | `comment-on-pr` | Set to `false` to suppress the PR report |
181
+ | `max-changed-rows` | Max changed-route rows shown before truncation (default: `20`) |
182
+ | `max-error-rows` | Max error rows shown before truncation (default: `10`) |
181
183
 
182
184
  ## Low-level actions
183
185
 
@@ -207,6 +209,20 @@ The CLI reads the same `.github/snapdrift.json` config used by the Actions workf
207
209
 
208
210
  See the [Local CLI guide](local-cli.md) for full command reference, flags, directory layout, and examples.
209
211
 
212
+ ## Refresh the baseline automatically
213
+
214
+ After an intentional layout change or dimension shift merges, the baseline must be republished before SnapDrift can compare like-for-like frames again. Without automation, this is a manual step.
215
+
216
+ Use the provided workflow template to refresh the baseline automatically on every push to your default branch (i.e. every merge):
217
+
218
+ **`docs/workflow-templates/refresh-baseline-on-merge.yml`**
219
+
220
+ Drop a copy into your repo at `.github/workflows/snapdrift-refresh-baseline.yml`, then fill in the `TODO` blocks with your app's build and start steps — the same steps you use in your PR workflow. The template uses a `push` trigger on the default branch so published artifacts are discoverable by the baseline resolver in `actions/pr-diff`.
221
+
222
+ ### Label-gated refreshes
223
+
224
+ If you don't want to republish the baseline on every push, the template includes a commented `if` condition that gates on a label (e.g. `snapdrift:refresh-baseline`). A preceding job can check the most recent merged PR for the label and set an output to control whether the baseline refresh runs.
225
+
210
226
  ## Troubleshooting
211
227
 
212
228
  **"No non-expired SnapDrift baseline artifact was found"**
@@ -0,0 +1,82 @@
1
+ # SnapDrift — Baseline Refresh on Merge
2
+ #
3
+ # Drop this file into your consumer repo at:
4
+ # .github/workflows/snapdrift-refresh-baseline.yml
5
+ #
6
+ # What it does:
7
+ # Automatically re-publishes the SnapDrift baseline on every push to your
8
+ # default branch. Since merging a PR produces a push event, this keeps the
9
+ # baseline in sync after intentional layout changes, dimension shifts, or
10
+ # any other UI refactor lands.
11
+ #
12
+ # Prerequisites:
13
+ # - A working `.github/snapdrift.json` in your repo
14
+ # - The same build + start steps used in your PR workflow
15
+ #
16
+ # Uses a push trigger so published artifacts are discoverable by the baseline
17
+ # resolver in actions/pr-diff (which filters on event: 'push'). A merge into
18
+ # the default branch produces a push event, so this fires exactly when needed.
19
+ #
20
+ # Optional — refresh only on a specific label:
21
+ # If you don't want to refresh on every push, you can gate on a label applied
22
+ # before merging. Use the pull_request: labeled event in a separate workflow to
23
+ # set an output, or simply restrict this workflow to specific paths.
24
+ # See the commented block below for a label-based gate using a preceding job.
25
+
26
+ name: SnapDrift Refresh Baseline
27
+
28
+ on:
29
+ push:
30
+ branches: [main]
31
+
32
+ permissions:
33
+ contents: read
34
+ actions: write # required to upload the baseline artifact
35
+
36
+ jobs:
37
+ refresh-baseline:
38
+ # Optional: gate on a label to avoid refreshing on every push.
39
+ # Requires a preceding job that checks for the label on the most recent
40
+ # merged PR and sets an output. Replace "snapdrift:refresh-baseline" with
41
+ # your label name.
42
+ # if: needs.check-label.outputs.refresh == 'true'
43
+
44
+ runs-on: ubuntu-latest
45
+
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+
49
+ # ── TODO: add your app's build + dependency steps here ──────────────────
50
+ # Example for a Node app:
51
+ #
52
+ # - uses: actions/setup-node@v4
53
+ # with:
54
+ # node-version: '22'
55
+ # cache: npm
56
+ # - run: npm ci
57
+ # - run: npm run build
58
+ # ────────────────────────────────────────────────────────────────────────
59
+
60
+ # ── TODO: start your app and wait for it to be ready ────────────────────
61
+ # SnapDrift needs a running server to capture screenshots.
62
+ # Replace the URL and port to match your app's baseUrl from snapdrift.json.
63
+ #
64
+ # - name: Start app
65
+ # run: |
66
+ # npm start &
67
+ # for i in $(seq 1 45); do
68
+ # curl -sf http://127.0.0.1:8080 && break || sleep 1
69
+ # done
70
+ # ────────────────────────────────────────────────────────────────────────
71
+
72
+ - name: Publish SnapDrift baseline
73
+ uses: ranacseruet/snapdrift/actions/baseline@v0.2.1
74
+ with:
75
+ repo-config-path: .github/snapdrift.json
76
+ artifact-retention-days: '30'
77
+
78
+ # ── TODO: shut down your app if needed ───────────────────────────────────
79
+ # - name: Stop app
80
+ # if: always()
81
+ # run: pkill -f "npm start" || true
82
+ # ────────────────────────────────────────────────────────────────────────
@@ -132,7 +132,7 @@ async function captureRoute(context, route, baseUrl, screenshotsRoot) {
132
132
  const targetUrl = new URL(route.path, baseUrl).toString();
133
133
  await page.goto(targetUrl, {
134
134
  waitUntil: 'networkidle',
135
- timeout: SNAPDRIFT_NAVIGATION_TIMEOUT_MS
135
+ timeout: route.navigationTimeout ?? SNAPDRIFT_NAVIGATION_TIMEOUT_MS
136
136
  });
137
137
  await page.waitForTimeout(SNAPDRIFT_SETTLE_DELAY_MS);
138
138
 
@@ -32,10 +32,12 @@ const STATUS_LABELS = {
32
32
 
33
33
  /**
34
34
  * @param {Record<string, unknown>} summary
35
- * @param {{ artifactName?: string, runUrl?: string }} [meta]
35
+ * @param {{ artifactName?: string, runUrl?: string, maxChangedRows?: number, maxErrorRows?: number }} [meta]
36
36
  * @returns {string}
37
37
  */
38
38
  export function buildReportCommentBody(summary, meta = {}) {
39
+ const maxChangedRows = meta.maxChangedRows ?? 20;
40
+ const maxErrorRows = meta.maxErrorRows ?? 10;
39
41
  const status = /** @type {string} */ (summary.status) || 'incomplete';
40
42
  const statusIcon = STATUS_ICONS[status] || '⚠️';
41
43
  const statusLabel = STATUS_LABELS[status] || status;
@@ -66,15 +68,15 @@ export function buildReportCommentBody(summary, meta = {}) {
66
68
  lines.push('');
67
69
  lines.push('| Route | Viewport | Error |');
68
70
  lines.push('|:------|:---------|:------|');
69
- for (const item of errors.slice(0, 10)) {
71
+ for (const item of errors.slice(0, maxErrorRows)) {
70
72
  lines.push(`| ${escapeMarkdown(item.id)} | ${escapeMarkdown(item.viewport)} | ${escapeMarkdown(item.message)} |`);
71
73
  }
72
- if (errorCount > 10) {
74
+ if (errorCount > maxErrorRows) {
73
75
  lines.push('');
74
76
  const runLinkSuffix = meta.runUrl && /^https?:\/\//.test(meta.runUrl)
75
77
  ? ` — [View full report →](${meta.runUrl})`
76
78
  : '';
77
- lines.push(`*...and ${errorCount - 10} more*${runLinkSuffix}`);
79
+ lines.push(`*...and ${errorCount - maxErrorRows} more*${runLinkSuffix}`);
78
80
  }
79
81
  lines.push('');
80
82
  lines.push('</details>');
@@ -87,18 +89,18 @@ export function buildReportCommentBody(summary, meta = {}) {
87
89
  lines.push('');
88
90
  lines.push('| Route | Viewport | Mismatch |');
89
91
  lines.push('|:------|:---------|:---------|');
90
- for (const item of changed.slice(0, 20)) {
92
+ for (const item of changed.slice(0, maxChangedRows)) {
91
93
  const percentChanged = typeof item.mismatchRatio === 'number'
92
94
  ? `${(item.mismatchRatio * 100).toFixed(2)}%`
93
95
  : 'n/a';
94
96
  lines.push(`| ${escapeMarkdown(item.id)} | ${escapeMarkdown(item.viewport)} | ${percentChanged} |`);
95
97
  }
96
- if (changed.length > 20) {
98
+ if (changed.length > maxChangedRows) {
97
99
  lines.push('');
98
100
  const runLinkSuffix = meta.runUrl && /^https?:\/\//.test(meta.runUrl)
99
101
  ? ` — [View full report →](${meta.runUrl})`
100
102
  : '';
101
- lines.push(`*...and ${changed.length - 20} more*${runLinkSuffix}`);
103
+ lines.push(`*...and ${changed.length - maxChangedRows} more*${runLinkSuffix}`);
102
104
  }
103
105
  lines.push('');
104
106
  lines.push('</details>');
@@ -156,6 +156,11 @@ export function validateSnapdriftConfig(value, sourceLabel = 'inline config') {
156
156
  if (route.changePaths !== undefined && !isNonEmptyStringArray(route.changePaths)) {
157
157
  errors.push(`${routeLabel}.changePaths must be an array of non-empty strings when provided.`);
158
158
  }
159
+
160
+ if (route.navigationTimeout !== undefined &&
161
+ (!Number.isInteger(route.navigationTimeout) || /** @type {number} */ (route.navigationTimeout) <= 0)) {
162
+ errors.push(`${routeLabel}.navigationTimeout must be a positive integer when provided.`);
163
+ }
159
164
  }
160
165
  }
161
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snapdrift",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
@@ -12,6 +12,7 @@ export interface VisualRegressionRouteConfig {
12
12
  path: string;
13
13
  viewport: VisualViewport;
14
14
  changePaths?: string[];
15
+ navigationTimeout?: number;
15
16
  }
16
17
 
17
18
  export interface VisualRegressionConfig {