snapdrift 0.3.0 → 0.4.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 +18 -0
- package/actions/comment/action.yml +22 -3
- package/actions/pr-diff/action.yml +16 -3
- package/docs/contracts.md +122 -0
- package/lib/cli.mjs +160 -26
- package/lib/init-from-action.mjs +244 -0
- package/lib/migrate-baselines.mjs +199 -0
- package/lib/provider.mjs +106 -0
- package/lib/snap-provider.mjs +734 -0
- package/lib/snapdrift-config.mjs +3 -1
- package/package.json +9 -5
- package/types/visual-diff-types.d.ts +65 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-05-26
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **VisualProvider interface** — new `VisualProvider` interface (`capture`, `diff`, `publishBaseline`, `fetchLatestBaseline`, `buildCommentBody`) with `LocalProvider` and `SnapProvider` implementations. Config `provider` field (`"local"` | `"snap"`) selects the backend; defaults to `"local"` for zero-config backward compatibility.
|
|
8
|
+
- **SnapProvider** — calls Snap's `/v1/visual/*` API for capture, diff, and baseline management. Retries on 5xx with exponential backoff (3 attempts, 30 s cap). Honors `snap.onUnavailable` (`"fail"` | `"warn-and-skip"` | `"fallback-local"`). 4xx errors never retry or fall back.
|
|
9
|
+
- **Snap config** — new `snap` config block (`apiUrl`, `apiKeyEnv`/`apiKey`, `projectId`, `onUnavailable`) in `snapdrift.json`. Exactly one of `apiKeyEnv` or `apiKey` is required when `provider: "snap"`.
|
|
10
|
+
- **PR comment unification** — both providers now produce identical PR comment markdown via a shared `buildCommentBody(summary, meta?)` method. `SnapProvider` appends a "View in dashboard →" link when `dashboardUrl` is present; `LocalProvider` omits it. Comment steps route through the provider instead of calling `buildReportCommentBody` directly, with a defensive fallback to `LocalProvider` if config loading fails.
|
|
11
|
+
- **`migrate-baselines` command** — one-shot CLI command (`snapdrift migrate-baselines --to snap` / `--to local`) to upload local baselines to Snap or export Snap baselines back to a local directory. Supports `--accept-cross-engine` for engine-mismatch scenarios.
|
|
12
|
+
- **`init --from-snap-action` codemod** — translates a Snap `github-action` workflow YAML into `snapdrift.json` with `provider: "snap"`. Emits structured warnings for features with no direct equivalent (jpeg format, baseline tags, single-shot artifact upload).
|
|
13
|
+
|
|
14
|
+
### Infrastructure
|
|
15
|
+
|
|
16
|
+
- **`@snapdrift/manifest` v1.1.0** — added `ProviderCommentMeta`, `dashboardUrl` on `VisualDiffSummary`, `buildCommentBody` on `VisualProvider`, `SnapConfig` re-export.
|
|
17
|
+
- **`@snapdrift/adapter-report-md` v1.1.0** — `buildReportCommentBody` now accepts `dashboardUrl` in meta; renders "View in dashboard →" link when present and valid.
|
|
18
|
+
- **Action contract tests** — `snapdrift-actions-contract` test suite now asserts that comment and pr-diff steps include `createProvider` and `buildCommentBody` calls.
|
|
19
|
+
- **Provider unification tests** — new `buildCommentBody — provider unification` test block verifying identical markdown output across providers, dashboard link inclusion/exclusion, and URL validation.
|
|
20
|
+
|
|
3
21
|
## 0.3.0 - 2026-05-26
|
|
4
22
|
|
|
5
23
|
### Infrastructure
|
|
@@ -20,6 +20,10 @@ inputs:
|
|
|
20
20
|
description: Artifact label to include in the comment.
|
|
21
21
|
required: false
|
|
22
22
|
default: ''
|
|
23
|
+
repo-config-path:
|
|
24
|
+
description: Path to the SnapDrift config file (needed for provider-aware comment rendering).
|
|
25
|
+
required: false
|
|
26
|
+
default: .github/snapdrift.json
|
|
23
27
|
max-changed-rows:
|
|
24
28
|
description: Maximum number of changed-route rows shown in the PR comment before truncation.
|
|
25
29
|
required: false
|
|
@@ -42,6 +46,7 @@ runs:
|
|
|
42
46
|
VISUAL_DIFF_RUN_URL: ${{ inputs.run-url }}
|
|
43
47
|
VISUAL_DIFF_ARTIFACT_NAME: ${{ inputs.artifact-name }}
|
|
44
48
|
INPUT_PR_NUMBER: ${{ inputs.pr-number }}
|
|
49
|
+
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
45
50
|
MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
|
|
46
51
|
MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
|
|
47
52
|
with:
|
|
@@ -63,15 +68,29 @@ runs:
|
|
|
63
68
|
return;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
const
|
|
67
|
-
const
|
|
71
|
+
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
72
|
+
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
73
|
+
const prCommentModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
|
|
74
|
+
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
75
|
+
const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
|
|
76
|
+
const { PR_COMMENT_MARKERS } = await import(pathToFileURL(prCommentModulePath));
|
|
77
|
+
|
|
78
|
+
// Provider-loading block duplicated in actions/pr-diff/action.yml — keep in sync.
|
|
79
|
+
let provider;
|
|
80
|
+
try {
|
|
81
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
82
|
+
provider = createProvider(config.provider || 'local', config);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
core.warning(`Could not load SnapDrift config; falling back to LocalProvider. ${err.message}`);
|
|
85
|
+
provider = createProvider('local');
|
|
86
|
+
}
|
|
68
87
|
|
|
69
88
|
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
|
|
70
89
|
const runUrl = process.env.VISUAL_DIFF_RUN_URL || `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
71
90
|
|
|
72
91
|
const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
|
|
73
92
|
const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
|
|
74
|
-
const body =
|
|
93
|
+
const body = provider.buildCommentBody(summary, {
|
|
75
94
|
artifactName: process.env.VISUAL_DIFF_ARTIFACT_NAME || '',
|
|
76
95
|
runUrl,
|
|
77
96
|
maxChangedRows,
|
|
@@ -498,6 +498,7 @@ runs:
|
|
|
498
498
|
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
499
499
|
ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
|
|
500
500
|
RUN_URL: ${{ steps.context.outputs.run_url }}
|
|
501
|
+
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
501
502
|
MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
|
|
502
503
|
MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
|
|
503
504
|
with:
|
|
@@ -531,13 +532,25 @@ runs:
|
|
|
531
532
|
return;
|
|
532
533
|
}
|
|
533
534
|
|
|
534
|
-
const
|
|
535
|
-
const
|
|
535
|
+
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
536
|
+
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
537
|
+
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
538
|
+
const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
|
|
539
|
+
|
|
540
|
+
// Provider-loading block duplicated in actions/comment/action.yml — keep in sync.
|
|
541
|
+
let provider;
|
|
542
|
+
try {
|
|
543
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
544
|
+
provider = createProvider(config.provider || 'local', config);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
core.warning(`Could not load SnapDrift config; falling back to LocalProvider. ${err.message}`);
|
|
547
|
+
provider = createProvider('local');
|
|
548
|
+
}
|
|
536
549
|
|
|
537
550
|
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
|
|
538
551
|
const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
|
|
539
552
|
const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
|
|
540
|
-
const body =
|
|
553
|
+
const body = provider.buildCommentBody(summary, {
|
|
541
554
|
artifactName: process.env.ARTIFACT_NAME || '',
|
|
542
555
|
runUrl: process.env.RUN_URL || '',
|
|
543
556
|
maxChangedRows,
|
package/docs/contracts.md
CHANGED
|
@@ -34,6 +34,12 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
|
|
|
34
34
|
|:------|:-----|:------------|
|
|
35
35
|
| `selection.sharedPrefixes` | `string[]` | Prefixes that force the full route set |
|
|
36
36
|
| `selection.sharedExact` | `string[]` | Exact files that force the full route set |
|
|
37
|
+
| `provider` | `string` | `"local"` (default) or `"snap"` for hosted backend |
|
|
38
|
+
| `snap.apiUrl` | `string` | Snap API base URL (default: `https://snap.i2dev.com`) |
|
|
39
|
+
| `snap.apiKeyEnv` | `string` | Env var name for API key (mutually exclusive with `snap.apiKey`) |
|
|
40
|
+
| `snap.apiKey` | `string` | Inline API key with `${VAR}` interpolation (mutually exclusive with `snap.apiKeyEnv`) |
|
|
41
|
+
| `snap.projectId` | `string` | Snap project ID or `"auto"` (default: `"auto"`, derives from `GITHUB_REPOSITORY`) |
|
|
42
|
+
| `snap.onUnavailable` | `string` | Behavior when Snap is unreachable: `"fail"` (default), `"warn-and-skip"`, or `"fallback-local"` |
|
|
37
43
|
|
|
38
44
|
### Example
|
|
39
45
|
|
|
@@ -129,6 +135,12 @@ The pull request drift bundle contains:
|
|
|
129
135
|
}
|
|
130
136
|
```
|
|
131
137
|
|
|
138
|
+
### Optional summary fields
|
|
139
|
+
|
|
140
|
+
| Field | Type | Description |
|
|
141
|
+
|:------|:-----|:------------|
|
|
142
|
+
| `dashboardUrl` | `string?` | Snap dashboard URL for the run (set by `SnapProvider`; omitted by `LocalProvider`) |
|
|
143
|
+
|
|
132
144
|
### Status values
|
|
133
145
|
|
|
134
146
|
| Status | Meaning |
|
|
@@ -208,6 +220,47 @@ When using the `snapdrift` CLI, outputs are written to `.snapdrift/` by default:
|
|
|
208
220
|
|
|
209
221
|
All three directories can be overridden with `--baseline-dir`, `--current-dir`, and `--diff-dir`. See the [Local CLI guide](local-cli.md) for details.
|
|
210
222
|
|
|
223
|
+
## Migration commands
|
|
224
|
+
|
|
225
|
+
### migrate-baselines
|
|
226
|
+
|
|
227
|
+
Migrate baselines between local storage and Snap.
|
|
228
|
+
|
|
229
|
+
**Upload local baselines to Snap:**
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
snapdrift migrate-baselines --to snap [--config <path>] [--baseline-dir <dir>]
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
- Reads local manifest + baseline images from the baseline directory.
|
|
236
|
+
- Uploads as the initial accepted baseline on Snap via `POST /v1/visual/projects/:id/baselines`.
|
|
237
|
+
- Idempotent: skips upload if a baseline already exists with the matching commit SHA.
|
|
238
|
+
- Requires snapdrift.json with `provider: "snap"` (or snap config).
|
|
239
|
+
|
|
240
|
+
**Download Snap baselines to local:**
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
snapdrift migrate-baselines --to local --from snap [--accept-cross-engine] [--config <path>] [--baseline-dir <dir>]
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- Downloads baselines from Snap's export endpoint.
|
|
247
|
+
- Writes to the local baseline directory.
|
|
248
|
+
- Without `--accept-cross-engine`: hard-errors if the exported baselines were captured by a different engine.
|
|
249
|
+
- With `--accept-cross-engine`: overrides the engine name to `snapdrift-local` in the imported manifest (visual differences may occur).
|
|
250
|
+
|
|
251
|
+
### init
|
|
252
|
+
|
|
253
|
+
**Translate a Snap action workflow to snapdrift.json:**
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
snapdrift init --from-snap-action <workflow-yaml-path>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- Reads an existing Snap `github-action/` workflow YAML.
|
|
260
|
+
- Translates Snap action inputs into `.github/snapdrift.json`.
|
|
261
|
+
- Emits `.github/MIGRATION_NOTES.md` listing warnings and deferred decisions.
|
|
262
|
+
- Idempotent: refuses to overwrite an existing `snapdrift.json`.
|
|
263
|
+
|
|
211
264
|
## Primary entrypoints
|
|
212
265
|
|
|
213
266
|
- `actions/baseline`
|
|
@@ -235,3 +288,72 @@ These are for custom orchestration only. Wrapper actions set them automatically.
|
|
|
235
288
|
| `SNAPDRIFT_BASELINE_SOURCE_SHA` | `compare-results.mjs` | Baseline source SHA to embed in the report |
|
|
236
289
|
| `SNAPDRIFT_ENFORCE_OUTCOME` | `compare-results.mjs` | Set to `0` to disable enforcement in direct CLI usage |
|
|
237
290
|
| `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture-routes.mjs` | Max concurrent route captures per viewport context (positive integer, default `5`). Set to `1` to restore serial behaviour for apps with shared session/auth state. |
|
|
291
|
+
|
|
292
|
+
## PR comment markdown shape
|
|
293
|
+
|
|
294
|
+
The canonical PR comment is produced by `provider.buildCommentBody(summary, meta)` in `@snapdrift/adapter-report-md`. Both `LocalProvider` and `SnapProvider` emit byte-identical markdown for the same summary — the only provider-specific difference is the optional **"View in dashboard →"** link appended by `SnapProvider`.
|
|
295
|
+
|
|
296
|
+
Snap's server-side notification posting should render the same template from the run summary JSON so that reviewers see a consistent format regardless of which provider produced the run.
|
|
297
|
+
|
|
298
|
+
### Structure
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
<!-- snapdrift-report -->
|
|
302
|
+
## {status-icon} SnapDrift Report — {status-label}
|
|
303
|
+
|
|
304
|
+
| Signal | Count |
|
|
305
|
+
|:-------|------:|
|
|
306
|
+
| Drift signals | N |
|
|
307
|
+
| Missing in baseline | N |
|
|
308
|
+
| Missing in current capture | N |
|
|
309
|
+
| Dimension shifts | N |
|
|
310
|
+
|
|
311
|
+
> **Note:** {message} ← optional, when summary.message is set
|
|
312
|
+
|
|
313
|
+
<details><summary>Error details</summary> ← collapsible, only when errors exist
|
|
314
|
+
| Route | Viewport | Error |
|
|
315
|
+
|:------|:---------|:------|
|
|
316
|
+
| route-id | viewport | error message |
|
|
317
|
+
*...and N more* — [View full report →]({runUrl}) ← when errors exceed maxErrorRows
|
|
318
|
+
</details>
|
|
319
|
+
|
|
320
|
+
<details><summary>Drift signals</summary> ← collapsible, only when changed routes exist
|
|
321
|
+
| Route | Viewport | Mismatch |
|
|
322
|
+
|:------|:---------|:---------|
|
|
323
|
+
| route-id | viewport | X.XX% |
|
|
324
|
+
*...and N more* — [View full report →]({runUrl}) ← when routes exceed maxChangedRows
|
|
325
|
+
</details>
|
|
326
|
+
|
|
327
|
+
<details open><summary>Dimension shifts — comparison skipped</summary> ← auto-expanded, only when dimension shifts exist
|
|
328
|
+
> SnapDrift detected a dimension shift …
|
|
329
|
+
| Route | Viewport | Baseline | Current |
|
|
330
|
+
|:------|:---------|:---------|:--------|
|
|
331
|
+
| route-id | viewport | WxH | WxH |
|
|
332
|
+
</details>
|
|
333
|
+
|
|
334
|
+
<sub>SnapDrift · artifact `name` · baseline `name` · sha `abc1234` · [View run](url) · [View in dashboard →](url)</sub>
|
|
335
|
+
↑ LocalProvider omits "View in dashboard"
|
|
336
|
+
|
|
337
|
+
<div align="right"><sub>Powered by <a href="…">SnapDrift</a></sub></div>
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Meta parameters
|
|
341
|
+
|
|
342
|
+
| Field | Type | Description |
|
|
343
|
+
|:------|:-----|:------------|
|
|
344
|
+
| `artifactName` | `string?` | PR diff artifact label |
|
|
345
|
+
| `runUrl` | `string?` | GitHub Actions run URL (adds `[View run]` link) |
|
|
346
|
+
| `dashboardUrl` | `string?` | Snap dashboard URL (adds `[View in dashboard →]` link; SnapProvider only) |
|
|
347
|
+
| `maxChangedRows` | `number` | Max drift-signal rows before truncation (default 20) |
|
|
348
|
+
| `maxErrorRows` | `number` | Max error rows before truncation (default 10) |
|
|
349
|
+
|
|
350
|
+
### Update-in-place semantics
|
|
351
|
+
|
|
352
|
+
Comments are identified by the `<!-- snapdrift-report -->` HTML marker. On re-run, the action finds the most recent matching comment and updates it in place. Duplicate markers from earlier runs are deleted.
|
|
353
|
+
|
|
354
|
+
### Provider contract
|
|
355
|
+
|
|
356
|
+
The `VisualProvider` interface requires `buildCommentBody(summary, meta?)`:
|
|
357
|
+
|
|
358
|
+
- **`LocalProvider`** — delegates to `buildReportCommentBody` without a `dashboardUrl`.
|
|
359
|
+
- **`SnapProvider`** — constructs `dashboardUrl` from `{apiUrl}/projects/{projectId}/runs/{lastRunId}` and passes it through.
|
package/lib/cli.mjs
CHANGED
|
@@ -4,8 +4,10 @@ import { execSync } from 'node:child_process';
|
|
|
4
4
|
import fs from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { loadSnapdriftConfig } from '@snapdrift/adapter-fs';
|
|
8
8
|
import { shouldFailDriftCheck } from '@snapdrift/manifest';
|
|
9
|
+
import { createProvider } from './provider.mjs';
|
|
10
|
+
import { SnapFallbackError, SnapSkipError } from './snap-provider.mjs';
|
|
9
11
|
import { generateHtmlReport } from './report.mjs';
|
|
10
12
|
|
|
11
13
|
/** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
|
|
@@ -23,13 +25,17 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
|
23
25
|
* routes: string[],
|
|
24
26
|
* baselineDir: string,
|
|
25
27
|
* currentDir: string,
|
|
26
|
-
* diffDir: string
|
|
28
|
+
* diffDir: string,
|
|
29
|
+
* to?: 'snap' | 'local',
|
|
30
|
+
* from?: 'snap',
|
|
31
|
+
* acceptCrossEngine?: boolean,
|
|
32
|
+
* fromSnapAction?: string
|
|
27
33
|
* }} CliOptions
|
|
28
34
|
*/
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
37
|
* Parse CLI arguments from process.argv (or a provided argv array).
|
|
32
|
-
* Supported commands: capture, diff
|
|
38
|
+
* Supported commands: capture, diff, migrate-baselines, init
|
|
33
39
|
* Supported flags:
|
|
34
40
|
* --open Open the HTML report after diff
|
|
35
41
|
* --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
|
|
@@ -37,6 +43,10 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
|
37
43
|
* --baseline-dir <p> Override local baseline directory (default: .snapdrift/baseline)
|
|
38
44
|
* --current-dir <p> Override local current-capture directory (default: .snapdrift/current)
|
|
39
45
|
* --diff-dir <p> Override local diff-output directory (default: .snapdrift/diff)
|
|
46
|
+
* --to <snap|local> Migration target (migrate-baselines command)
|
|
47
|
+
* --from <snap> Migration source (migrate-baselines --to local)
|
|
48
|
+
* --accept-cross-engine Allow importing baselines from a different capture engine
|
|
49
|
+
* --from-snap-action <path> Snap action workflow YAML path (init command)
|
|
40
50
|
*
|
|
41
51
|
* @param {string[]} argv - process.argv
|
|
42
52
|
* @returns {CliOptions}
|
|
@@ -52,6 +62,13 @@ export function parseArgs(argv) {
|
|
|
52
62
|
let baselineDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_BASELINE_SUBDIR);
|
|
53
63
|
let currentDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_CURRENT_SUBDIR);
|
|
54
64
|
let diffDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_DIFF_SUBDIR);
|
|
65
|
+
/** @type {'snap' | 'local' | undefined} */
|
|
66
|
+
let to;
|
|
67
|
+
/** @type {'snap' | undefined} */
|
|
68
|
+
let from;
|
|
69
|
+
let acceptCrossEngine = false;
|
|
70
|
+
/** @type {string | undefined} */
|
|
71
|
+
let fromSnapAction;
|
|
55
72
|
|
|
56
73
|
for (let i = 1; i < args.length; i++) {
|
|
57
74
|
const arg = args[i];
|
|
@@ -72,10 +89,24 @@ export function parseArgs(argv) {
|
|
|
72
89
|
currentDir = path.resolve(args[++i]);
|
|
73
90
|
} else if (arg === '--diff-dir' && i + 1 < args.length) {
|
|
74
91
|
diffDir = path.resolve(args[++i]);
|
|
92
|
+
} else if (arg === '--to' && i + 1 < args.length) {
|
|
93
|
+
const toVal = args[++i];
|
|
94
|
+
if (toVal === 'snap' || toVal === 'local') {
|
|
95
|
+
to = toVal;
|
|
96
|
+
}
|
|
97
|
+
} else if (arg === '--from' && i + 1 < args.length) {
|
|
98
|
+
const fromVal = args[++i];
|
|
99
|
+
if (fromVal === 'snap') {
|
|
100
|
+
from = fromVal;
|
|
101
|
+
}
|
|
102
|
+
} else if (arg === '--accept-cross-engine') {
|
|
103
|
+
acceptCrossEngine = true;
|
|
104
|
+
} else if (arg === '--from-snap-action' && i + 1 < args.length) {
|
|
105
|
+
fromSnapAction = args[++i];
|
|
75
106
|
}
|
|
76
107
|
}
|
|
77
108
|
|
|
78
|
-
return { command, open, configPath, routes, baselineDir, currentDir, diffDir };
|
|
109
|
+
return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
|
|
79
110
|
}
|
|
80
111
|
|
|
81
112
|
/**
|
|
@@ -148,12 +179,29 @@ function openFile(filePath) {
|
|
|
148
179
|
*/
|
|
149
180
|
export async function runCaptureCommand(opts) {
|
|
150
181
|
process.stdout.write(`Capturing baseline to ${opts.baselineDir} ...\n`);
|
|
151
|
-
const {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
182
|
+
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
183
|
+
const provider = createProvider(config.provider ?? 'local', config);
|
|
184
|
+
try {
|
|
185
|
+
const { selectedRouteIds } = await provider.capture({
|
|
186
|
+
configPath: opts.configPath,
|
|
187
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
188
|
+
outDir: opts.baselineDir
|
|
189
|
+
});
|
|
190
|
+
process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error instanceof SnapFallbackError) {
|
|
193
|
+
process.stdout.write('Falling back to local provider for capture...\n');
|
|
194
|
+
const localProvider = createProvider('local');
|
|
195
|
+
const { selectedRouteIds } = await localProvider.capture({
|
|
196
|
+
configPath: opts.configPath,
|
|
197
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
198
|
+
outDir: opts.baselineDir
|
|
199
|
+
});
|
|
200
|
+
process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
157
205
|
}
|
|
158
206
|
|
|
159
207
|
/**
|
|
@@ -166,12 +214,30 @@ export async function runCaptureCommand(opts) {
|
|
|
166
214
|
export async function runDiffCommand(opts) {
|
|
167
215
|
process.stdout.write(`Capturing current screenshots to ${opts.currentDir} ...\n`);
|
|
168
216
|
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
217
|
+
const provider = createProvider(config.provider ?? 'local', config);
|
|
169
218
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
219
|
+
let currentCapture;
|
|
220
|
+
let usedLocalFallback = false;
|
|
221
|
+
try {
|
|
222
|
+
currentCapture = await provider.capture({
|
|
223
|
+
configPath: opts.configPath,
|
|
224
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
225
|
+
outDir: opts.currentDir
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error instanceof SnapFallbackError) {
|
|
229
|
+
process.stdout.write('Falling back to local provider for capture...\n');
|
|
230
|
+
const localProvider = createProvider('local');
|
|
231
|
+
currentCapture = await localProvider.capture({
|
|
232
|
+
configPath: opts.configPath,
|
|
233
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
234
|
+
outDir: opts.currentDir
|
|
235
|
+
});
|
|
236
|
+
usedLocalFallback = true;
|
|
237
|
+
} else {
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
175
241
|
|
|
176
242
|
process.stdout.write('Comparing against baseline ...\n');
|
|
177
243
|
|
|
@@ -183,16 +249,28 @@ export async function runDiffCommand(opts) {
|
|
|
183
249
|
const markdownPath = path.join(opts.diffDir, 'summary.md');
|
|
184
250
|
const htmlPath = path.join(opts.diffDir, 'report.html');
|
|
185
251
|
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
252
|
+
const diffProvider = usedLocalFallback ? createProvider('local') : provider;
|
|
253
|
+
let diffResult;
|
|
254
|
+
try {
|
|
255
|
+
diffResult = await diffProvider.diff({
|
|
256
|
+
configPath: opts.configPath,
|
|
257
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
258
|
+
baselineResultsPath,
|
|
259
|
+
baselineManifestPath,
|
|
260
|
+
currentResultsPath: currentCapture.resultsPath,
|
|
261
|
+
currentManifestPath: currentCapture.manifestPath,
|
|
262
|
+
baselineRunDir: opts.baselineDir,
|
|
263
|
+
currentRunDir: currentCapture.screenshotsRoot
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof SnapSkipError) {
|
|
267
|
+
process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const { summary, markdown } = diffResult;
|
|
196
274
|
|
|
197
275
|
const html = await generateHtmlReport(summary, {
|
|
198
276
|
baselineRunDir: opts.baselineDir,
|
|
@@ -218,6 +296,58 @@ export async function runDiffCommand(opts) {
|
|
|
218
296
|
}
|
|
219
297
|
}
|
|
220
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Run the `migrate-baselines` command — migrate baselines between local and Snap.
|
|
301
|
+
*
|
|
302
|
+
* @param {CliOptions} opts
|
|
303
|
+
* @returns {Promise<void>}
|
|
304
|
+
*/
|
|
305
|
+
export async function runMigrateBaselinesCommand(opts) {
|
|
306
|
+
if (!opts.to || (opts.to !== 'snap' && opts.to !== 'local')) {
|
|
307
|
+
process.stderr.write('Usage: snapdrift migrate-baselines --to <snap|local> [--from snap] [--accept-cross-engine] [--config <path>] [--baseline-dir <dir>]\n');
|
|
308
|
+
process.exitCode = 1;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (opts.to === 'local' && opts.from !== 'snap') {
|
|
313
|
+
process.stderr.write('Error: --to local requires --from snap\n');
|
|
314
|
+
process.exitCode = 1;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (opts.acceptCrossEngine && opts.to !== 'local') {
|
|
319
|
+
process.stderr.write('Error: --accept-cross-engine is only valid with --to local\n');
|
|
320
|
+
process.exitCode = 1;
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { runMigrateToSnap, runMigrateToLocal } = await import('./migrate-baselines.mjs');
|
|
325
|
+
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
326
|
+
|
|
327
|
+
if (opts.to === 'snap') {
|
|
328
|
+
await runMigrateToSnap(config, opts);
|
|
329
|
+
} else {
|
|
330
|
+
await runMigrateToLocal(config, opts);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Run the `init` command — initialize snapdrift config from a Snap action workflow.
|
|
336
|
+
*
|
|
337
|
+
* @param {CliOptions} opts
|
|
338
|
+
* @returns {Promise<void>}
|
|
339
|
+
*/
|
|
340
|
+
export async function runInitCommand(opts) {
|
|
341
|
+
if (!opts.fromSnapAction) {
|
|
342
|
+
process.stderr.write('Usage: snapdrift init --from-snap-action <workflow-yaml-path>\n');
|
|
343
|
+
process.exitCode = 1;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const { runInitFromAction } = await import('./init-from-action.mjs');
|
|
348
|
+
await runInitFromAction(opts.fromSnapAction);
|
|
349
|
+
}
|
|
350
|
+
|
|
221
351
|
/**
|
|
222
352
|
* Main CLI entry point — parse args and dispatch to the appropriate command.
|
|
223
353
|
*
|
|
@@ -231,9 +361,13 @@ export async function main(argv) {
|
|
|
231
361
|
await runCaptureCommand(opts);
|
|
232
362
|
} else if (opts.command === 'diff') {
|
|
233
363
|
await runDiffCommand(opts);
|
|
364
|
+
} else if (opts.command === 'migrate-baselines') {
|
|
365
|
+
await runMigrateBaselinesCommand(opts);
|
|
366
|
+
} else if (opts.command === 'init') {
|
|
367
|
+
await runInitCommand(opts);
|
|
234
368
|
} else {
|
|
235
369
|
process.stderr.write(
|
|
236
|
-
`Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff> [options]\n`
|
|
370
|
+
`Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff|migrate-baselines|init> [options]\n`
|
|
237
371
|
);
|
|
238
372
|
process.exitCode = 1;
|
|
239
373
|
}
|