snapdrift 0.5.1 → 0.7.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 +25 -1
- package/README.md +13 -4
- package/docs/contracts.md +5 -4
- package/docs/integration-guide.md +1 -1
- package/docs/local-cli.md +39 -5
- package/lib/cli.mjs +115 -2
- package/lib/migrate-baselines.mjs +25 -31
- package/lib/snap-provider.mjs +450 -80
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.7.0 - 2026-07-26
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **`snapdrift baseline` command** — establishes a baseline for the configured provider. With `provider: "snap"` it captures each route through Snap, waits for the hosted renders, and then publishes a baseline from the stored object keys. Previously `publishBaseline()` was reachable only from `actions/baseline`, so anyone not running GitHub Actions had **no supported way to seed a hosted project** — `snapdrift capture` submits a run but never creates a baseline from it. With `provider: "local"` the command behaves like `capture`, since the captured files are themselves the baseline. Honours `onUnavailable: fallback-local` by skipping the publish step when the capture falls back locally. (ranacseruet/snapdrift#106)
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
|
|
11
|
+
- **`publishBaseline()` fails with the real cause instead of a doomed request** — when `results.json` was missing, unparseable, or carried no `runId`, it fell through to a legacy branch that POSTed `manifest`/`results` as objects. Snap has never read those fields, so that request could only fail — with an opaque 500 before, and `400 unsupported_baseline_body` since i2Dev-com/snap#653 — while a bare `catch {}` hid the underlying read error. The legacy branch is removed; the method now reports that no run id was found, including the read error when there was one. Reached whenever a non-Snap `results.json` (for example one written by the local provider) was passed in. (#109)
|
|
12
|
+
- **`snapdrift capture` honours `onUnavailable: "warn-and-skip"`** — the mode makes `#handleUnavailable` throw `SnapSkipError`, which `diff` and `baseline` catch and turn into a clean exit. `capture` let it propagate to the CLI entry point, which writes the error to stderr and exits 1 — failing the build the user explicitly configured *not* to fail when Snap is unavailable. (#108)
|
|
13
|
+
- **Baselines published outside GitHub Actions are attributed correctly** — `publishBaseline()` read the branch and commit only from `GITHUB_REF_NAME` / `GITHUB_HEAD_REF` / `GITHUB_SHA`, so a baseline published from a terminal was recorded as branch `main` at SHA `unknown`, making baselines cut from different commits indistinguishable in the dashboard and in exported metadata. It now falls back to `git rev-parse` when those variables are absent (and to `main` / `unknown` only outside a git repository). Previously unreachable, since there was no CLI publish path.
|
|
14
|
+
|
|
15
|
+
### Breaking changes
|
|
16
|
+
|
|
17
|
+
- **`migrate-baselines --to snap` now fails with a pointer to `snapdrift baseline`**, and `SnapProvider.migrateBaselineFromLocal()` has been removed. The body it posted (`manifest`/`results`/`screenshots` as objects) was never read by Snap's baseline endpoint: the PNGs were never uploaded to storage and the manifest referenced local filenames rather than Snap object keys, so the call could only ever create a baseline with no images behind it — one that `migrate-baselines --to local --from snap` would then refuse to export. The request previously failed with an opaque 500 and is now rejected with `400 unsupported_baseline_body` (i2Dev-com/snap#653). The reverse direction, `--to local --from snap`, is unaffected.
|
|
18
|
+
|
|
19
|
+
## 0.6.0 - 2026-07-17
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
- **`migrate-baselines --to local --from snap` is now functional** — `SnapProvider.exportBaselines` downloads the project's export archive from Snap's `GET /v1/visual/projects/:id/export` endpoint (requires an API key with the `visual:export` scope), parses the tar in pure JS, selects the most recent accepted baseline, and maps it back to the local baseline layout (`results.json`, `manifest.json`, `screenshots/<routeId>.png`). The imported engine name is passed through truthfully — baselines rendered by Snap's hosted worker report their own engine (falling back to `snap-hosted`) and require `--accept-cross-engine` to import, while baselines originally published by SnapDrift report `snapdrift-local` and import cleanly. Clear errors cover a missing `visual:export` scope (403), an unknown project (404), oversized exports (413), projects with no accepted baselines, and legacy baselines that predate manifest tracking. Previously this command failed with a "Snap export endpoint is not yet available" stub error.
|
|
24
|
+
|
|
25
|
+
### Fixes
|
|
26
|
+
|
|
27
|
+
- **Reject error pages during capture** — `runBaselineCapture` now checks the HTTP status of each route navigation and fails the route if it resolves to a 4xx/5xx. `page.goto` resolves even on a 404/500 (the error body still loads), so previously a broken route — wrong slug, misconfigured `baseUrl`, or an outage — would silently screenshot its error page and upload it as a valid baseline. The failure flows into the existing retry-then-fail path, so a genuinely broken capture now fails loudly instead of poisoning the baseline. Complements the 0% -drift heuristic guard from #93 with a direct status check. Redirects (3xx) still pass, since Playwright follows them and reports the final status; `null` responses (non-navigation schemes) are treated as fine.
|
|
4
28
|
|
|
5
29
|
## 0.5.1 - 2026-07-14
|
|
6
30
|
|
package/README.md
CHANGED
|
@@ -69,14 +69,17 @@ That is the full integration. See the [Integration Guide](docs/integration-guide
|
|
|
69
69
|
SnapDrift ships a `snapdrift` CLI for running captures, diffs, migrations, and config initialization locally against a running app — no GitHub Actions required. Use it during development to validate UI changes before pushing.
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
#
|
|
72
|
+
# Establish a baseline — for provider "snap" this also publishes it
|
|
73
|
+
snapdrift baseline
|
|
74
|
+
|
|
75
|
+
# Capture screenshots without publishing anything
|
|
73
76
|
snapdrift capture
|
|
74
77
|
|
|
75
|
-
# Compare against
|
|
78
|
+
# Compare against the baseline after making UI changes
|
|
76
79
|
snapdrift diff --open
|
|
77
80
|
|
|
78
|
-
#
|
|
79
|
-
snapdrift migrate-baselines --to snap
|
|
81
|
+
# Export hosted baselines back to the local layout
|
|
82
|
+
snapdrift migrate-baselines --to local --from snap
|
|
80
83
|
|
|
81
84
|
# Translate an existing Snap github-action workflow into snapdrift.json
|
|
82
85
|
snapdrift init --from-snap-action .github/workflows/snap.yml
|
|
@@ -104,6 +107,12 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
|
|
|
104
107
|
- Dimension shifts are reported separately from pixel drift
|
|
105
108
|
- Local provider writes artifacts to the runner filesystem; for a hosted backend with a dashboard and a shared baseline store, configure `provider: "snap"` (see the [Integration Guide](docs/integration-guide.md#hosted-snap-provider))
|
|
106
109
|
|
|
110
|
+
## Outgrew GitHub artifacts?
|
|
111
|
+
|
|
112
|
+
Baselines live as GitHub Actions artifacts by default — quick to start, but they expire (30 days by default), are scoped to one repo, and every PR re-renders on your CI runner. When visual regression becomes load-bearing, `provider: "snap"` moves baselines into durable, shared storage with a review dashboard — and, when `baseUrl` points at a public preview URL Snap can reach, renders on Snap's hosted fleet instead of your runner (with a localhost `baseUrl`, capture stays a local-Playwright-and-upload hybrid). Same routes, same diff config; add a `snap` config block and API key per the [hosted-provider setup](docs/integration-guide.md#hosted-snap-provider). Snap's hosted Visual CI is built for small teams (plans from $9.99/mo).
|
|
113
|
+
|
|
114
|
+
→ **[Hosted Visual CI with Snap](https://snap.i2dev.com/ci)**
|
|
115
|
+
|
|
107
116
|
## Docs
|
|
108
117
|
|
|
109
118
|
- [Integration Guide](docs/integration-guide.md)
|
package/docs/contracts.md
CHANGED
|
@@ -272,12 +272,13 @@ snapdrift migrate-baselines --to snap [--config <path>] [--baseline-dir <dir>]
|
|
|
272
272
|
snapdrift migrate-baselines --to local --from snap [--accept-cross-engine] [--config <path>] [--baseline-dir <dir>]
|
|
273
273
|
```
|
|
274
274
|
|
|
275
|
-
- Downloads
|
|
276
|
-
-
|
|
275
|
+
- Downloads the project's export archive from Snap's export endpoint (`GET /v1/visual/projects/:id/export`, a tar containing every accepted baseline's screenshots, capture profile, and source manifest). Requires an API key with the `visual:export` scope.
|
|
276
|
+
- Imports the **most recent accepted baseline** (by `createdAt`) from the archive; older accepted baselines in the export are ignored.
|
|
277
|
+
- Writes `results.json`, `manifest.json`, and `screenshots/*.png` to the local baseline directory. Screenshot filenames are derived from the baseline's route ids (`screenshots/<routeId>.png`), matching what a local capture would produce.
|
|
277
278
|
- Writes `.migration-metadata.json` next to the baseline recording `source`, `migratedAt`, and the engine that produced the export.
|
|
278
|
-
- Without `--accept-cross-engine`: hard-errors if the exported
|
|
279
|
+
- Without `--accept-cross-engine`: hard-errors if the exported baseline's engine name is not `snapdrift-local` (e.g. `snap-hosted` for baselines rendered by Snap's worker).
|
|
279
280
|
- With `--accept-cross-engine`: overrides the engine name to `snapdrift-local` in the imported manifest (visual differences may occur when the captures came from a different engine).
|
|
280
|
-
-
|
|
281
|
+
- Fails with an actionable message — instead of producing a partial baseline — when the project has no accepted baselines, when the selected baseline predates manifest tracking (no source manifest recorded at publish time), when the key lacks the `visual:export` scope (403), or when the export exceeds Snap's size caps (413).
|
|
281
282
|
|
|
282
283
|
### init
|
|
283
284
|
|
|
@@ -227,7 +227,7 @@ Conversely, downloading a Snap baseline back to a local directory (useful for re
|
|
|
227
227
|
snapdrift migrate-baselines --to local --from snap
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
The reverse direction
|
|
230
|
+
The reverse direction downloads the project's export archive from Snap (`GET /v1/visual/projects/:id/export`, which requires an API key with the `visual:export` scope) and imports the most recent accepted baseline into the local baseline directory. See the [Contracts reference](contracts.md#migration-commands) for the full flag set, the engine-compatibility check, and the metadata file written for idempotency.
|
|
231
231
|
|
|
232
232
|
If you're adopting SnapDrift as a replacement for the upstream `snap/github-action`, the `init` codemod translates the workflow YAML and emits a `MIGRATION_NOTES.md` with everything that couldn't be auto-translated:
|
|
233
233
|
|
package/docs/local-cli.md
CHANGED
|
@@ -22,6 +22,40 @@ npx snapdrift <command>
|
|
|
22
22
|
|
|
23
23
|
## Commands
|
|
24
24
|
|
|
25
|
+
### `snapdrift baseline`
|
|
26
|
+
|
|
27
|
+
Establishes the baseline for whichever provider the config selects. This is the command to reach for when seeding a project.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
snapdrift baseline [options]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **`provider: "local"`** — identical to `snapdrift capture`: the screenshots written to the baseline directory *are* the baseline.
|
|
34
|
+
- **`provider: "snap"`** — captures each route through Snap, waits for the hosted renders to finish, then publishes a baseline referencing the stored objects. `snapdrift capture` alone does **not** do this: it submits the run and stops, leaving the project with no baseline.
|
|
35
|
+
|
|
36
|
+
`snap.onUnavailable` is honoured across both phases — the capture *and* the publish that follows it. `warn-and-skip` exits 0 without a baseline; `fallback-local` captures locally so you still end up with one.
|
|
37
|
+
|
|
38
|
+
**Baseline attribution.** The published baseline records the branch and commit it was cut from. Inside GitHub Actions these come from `GITHUB_REF_NAME` / `GITHUB_HEAD_REF` and `GITHUB_SHA`; run locally, they are read from git (`rev-parse --abbrev-ref HEAD` and `rev-parse HEAD`). Outside a git repository they fall back to `main` and `unknown`, so run the command from your checkout if you want the baseline attributed correctly.
|
|
39
|
+
|
|
40
|
+
**`snap.projectId` must be an explicit `prj_...` id** for local runs. `"auto"` derives the id from `GITHUB_REPOSITORY`, which is not set outside Actions — the command fails early telling you to set an explicit id.
|
|
41
|
+
|
|
42
|
+
**Options**
|
|
43
|
+
|
|
44
|
+
| Flag | Default | Description |
|
|
45
|
+
|:-----|:--------|:------------|
|
|
46
|
+
| `--config <path>` | `.github/snapdrift.json` | Path to the config file |
|
|
47
|
+
| `--routes <ids>` | all routes | Comma-separated route IDs to capture |
|
|
48
|
+
| `--baseline-dir <path>` | `.snapdrift/baseline` | Directory to write baseline screenshots and metadata (local provider) |
|
|
49
|
+
|
|
50
|
+
**Example**
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
snapdrift baseline
|
|
54
|
+
snapdrift baseline --routes home-desktop,home-mobile
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
25
59
|
### `snapdrift capture`
|
|
26
60
|
|
|
27
61
|
Captures full-page screenshots of all configured routes and saves them as a local baseline.
|
|
@@ -82,15 +116,15 @@ snapdrift diff --baseline-dir snapshots/baseline --diff-dir snapshots/diff
|
|
|
82
116
|
|
|
83
117
|
### `snapdrift migrate-baselines`
|
|
84
118
|
|
|
85
|
-
|
|
119
|
+
Export baselines from the hosted Snap backend to the local filesystem. Requires a config with `provider: "snap"` (or a `snap` block).
|
|
86
120
|
|
|
87
|
-
|
|
121
|
+
**`--to snap` is not supported (removed in 0.7.0).**
|
|
88
122
|
|
|
89
123
|
```bash
|
|
90
|
-
snapdrift migrate-baselines --to snap
|
|
124
|
+
snapdrift migrate-baselines --to snap # always fails
|
|
91
125
|
```
|
|
92
126
|
|
|
93
|
-
|
|
127
|
+
Snap cannot accept a pre-built local baseline bundle: the screenshots it carries are never uploaded to Snap storage, and its manifest references local filenames rather than Snap object keys, so the result would be a baseline with no pixels behind it. The endpoint rejects the request with `400 unsupported_baseline_body`. Use [`snapdrift baseline`](#snapdrift-baseline) instead — it captures each route through Snap so the images actually land in storage, then publishes a manifest that points at them.
|
|
94
128
|
|
|
95
129
|
**Download Snap baselines to a local directory:**
|
|
96
130
|
|
|
@@ -200,7 +234,7 @@ Add `.snapdrift/` to your `.gitignore` to keep local run output out of version c
|
|
|
200
234
|
|
|
201
235
|
| Code | Meaning |
|
|
202
236
|
|:-----|:--------|
|
|
203
|
-
| `0` | Clean — no drift above threshold, `diff.mode` is `report-only`, the
|
|
237
|
+
| `0` | Clean — no drift above threshold, `diff.mode` is `report-only`, the command was intentionally skipped under `onUnavailable: "warn-and-skip"` (`diff`, `capture` and `baseline` all exit 0 in that case), or the command was a `capture` / `baseline` / `migrate-baselines --to local` / `init` that completed |
|
|
204
238
|
| `1` | Drift enforced — `diff.mode` caused the run to fail, a required command argument was missing, or the `--to local` engine-name check failed (see [Drift modes](../README.md#drift-modes)) |
|
|
205
239
|
|
|
206
240
|
Enforcement follows the same `diff.mode` rules as the GitHub Actions workflow. Set `"mode": "report-only"` during local development to always get a report without a failing exit code.
|
package/lib/cli.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Parse CLI arguments from process.argv (or a provided argv array).
|
|
38
|
-
* Supported commands: capture, diff, migrate-baselines, init
|
|
38
|
+
* Supported commands: capture, baseline, diff, migrate-baselines, init
|
|
39
39
|
* Supported flags:
|
|
40
40
|
* --open Open the HTML report after diff
|
|
41
41
|
* --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
|
|
@@ -190,6 +190,13 @@ export async function runCaptureCommand(opts) {
|
|
|
190
190
|
});
|
|
191
191
|
process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
192
192
|
} catch (error) {
|
|
193
|
+
// onUnavailable: warn-and-skip — the user asked not to fail the build when
|
|
194
|
+
// Snap is down. `diff` and `baseline` both honour this; `capture` used to
|
|
195
|
+
// let SnapSkipError reach bin/snapdrift.mjs, which exits 1. See #108.
|
|
196
|
+
if (error instanceof SnapSkipError) {
|
|
197
|
+
process.stdout.write('Capture skipped (Snap unavailable, warn-and-skip mode).\n');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
193
200
|
if (error instanceof SnapFallbackError) {
|
|
194
201
|
process.stdout.write('Falling back to local provider for capture...\n');
|
|
195
202
|
const localProvider = createProvider('local');
|
|
@@ -205,6 +212,110 @@ export async function runCaptureCommand(opts) {
|
|
|
205
212
|
}
|
|
206
213
|
}
|
|
207
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Run the `baseline` command — establish a baseline for the configured provider.
|
|
217
|
+
*
|
|
218
|
+
* For `provider: "local"` this is `capture`: the screenshots written to
|
|
219
|
+
* `--baseline-dir` *are* the baseline, and there is nothing to publish.
|
|
220
|
+
*
|
|
221
|
+
* For `provider: "snap"` the capture step only submits a run; the baseline is
|
|
222
|
+
* not created until `publishBaseline()` harvests the rendered object keys. That
|
|
223
|
+
* call previously existed only inside `actions/baseline`, which left anyone not
|
|
224
|
+
* running GitHub Actions with no supported way to seed a hosted project — see
|
|
225
|
+
* ranacseruet/snapdrift#106.
|
|
226
|
+
*
|
|
227
|
+
* Mirrors the two provider-conditional steps of `actions/baseline/action.yml`.
|
|
228
|
+
*
|
|
229
|
+
* @param {CliOptions} opts
|
|
230
|
+
* @returns {Promise<void>}
|
|
231
|
+
*/
|
|
232
|
+
export async function runBaselineCommand(opts) {
|
|
233
|
+
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
234
|
+
const providerName = config.provider ?? 'local';
|
|
235
|
+
const provider = createProvider(providerName, config);
|
|
236
|
+
|
|
237
|
+
process.stdout.write(
|
|
238
|
+
providerName === 'snap'
|
|
239
|
+
? 'Establishing baseline via Snap ...\n'
|
|
240
|
+
: `Capturing baseline to ${opts.baselineDir} ...\n`
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
/** Capture locally so `fallback-local` still leaves the user with a baseline. */
|
|
244
|
+
async function captureLocalFallback() {
|
|
245
|
+
process.stdout.write('Falling back to local provider for capture...\n');
|
|
246
|
+
const localProvider = createProvider('local');
|
|
247
|
+
const { selectedRouteIds } = await localProvider.capture({
|
|
248
|
+
configPath: opts.configPath,
|
|
249
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
250
|
+
outDir: opts.baselineDir
|
|
251
|
+
});
|
|
252
|
+
process.stdout.write(
|
|
253
|
+
`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir} (local fallback — nothing published to Snap)\n`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Apply the configured `onUnavailable` behaviour. Snap can go down during
|
|
259
|
+
* *either* phase — the capture submission or the publish that follows it — and
|
|
260
|
+
* the user's choice must hold for both, otherwise `warn-and-skip` still exits 1
|
|
261
|
+
* and `fallback-local` still leaves them with no baseline.
|
|
262
|
+
*
|
|
263
|
+
* @param {unknown} error
|
|
264
|
+
* @param {string} skipMessage
|
|
265
|
+
* @returns {Promise<boolean>} true when the error was absorbed
|
|
266
|
+
*/
|
|
267
|
+
async function handleUnavailable(error, skipMessage) {
|
|
268
|
+
if (error instanceof SnapSkipError) {
|
|
269
|
+
process.stdout.write(skipMessage);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (error instanceof SnapFallbackError) {
|
|
273
|
+
await captureLocalFallback();
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** @type {{ resultsPath?: string, selectedRouteIds: string[] }} */
|
|
280
|
+
let captured;
|
|
281
|
+
try {
|
|
282
|
+
captured = await provider.capture({
|
|
283
|
+
configPath: opts.configPath,
|
|
284
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
285
|
+
outDir: opts.baselineDir,
|
|
286
|
+
purpose: 'baseline'
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (await handleUnavailable(error, 'Baseline skipped (Snap unavailable, warn-and-skip mode).\n')) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (providerName !== 'snap') {
|
|
296
|
+
process.stdout.write(`Captured ${captured.selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
process.stdout.write(
|
|
301
|
+
`Captured ${captured.selectedRouteIds.length} route(s); waiting for Snap to finish rendering ...\n`
|
|
302
|
+
);
|
|
303
|
+
try {
|
|
304
|
+
await provider.publishBaseline({ resultsPath: captured.resultsPath });
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (
|
|
307
|
+
await handleUnavailable(
|
|
308
|
+
error,
|
|
309
|
+
'Baseline publish skipped (Snap unavailable, warn-and-skip mode).\n'
|
|
310
|
+
)
|
|
311
|
+
) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
process.stdout.write('Snap baseline published successfully.\n');
|
|
317
|
+
}
|
|
318
|
+
|
|
208
319
|
/**
|
|
209
320
|
* Run the `diff` command — capture current screenshots, compare against the local baseline,
|
|
210
321
|
* write a summary + HTML report, and optionally open it.
|
|
@@ -360,6 +471,8 @@ export async function main(argv) {
|
|
|
360
471
|
|
|
361
472
|
if (opts.command === 'capture') {
|
|
362
473
|
await runCaptureCommand(opts);
|
|
474
|
+
} else if (opts.command === 'baseline') {
|
|
475
|
+
await runBaselineCommand(opts);
|
|
363
476
|
} else if (opts.command === 'diff') {
|
|
364
477
|
await runDiffCommand(opts);
|
|
365
478
|
} else if (opts.command === 'migrate-baselines') {
|
|
@@ -368,7 +481,7 @@ export async function main(argv) {
|
|
|
368
481
|
await runInitCommand(opts);
|
|
369
482
|
} else {
|
|
370
483
|
process.stderr.write(
|
|
371
|
-
`Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff|migrate-baselines|init> [options]\n`
|
|
484
|
+
`Unknown command: ${opts.command}\nUsage: snapdrift <capture|baseline|diff|migrate-baselines|init> [options]\n`
|
|
372
485
|
);
|
|
373
486
|
process.exitCode = 1;
|
|
374
487
|
}
|
|
@@ -86,39 +86,33 @@ function requireSnapConfig(config) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
* Run the migrate-to-snap direction.
|
|
90
|
-
* Reads local baselines and uploads them to Snap.
|
|
89
|
+
* Run the migrate-to-snap direction — retired.
|
|
91
90
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
91
|
+
* This uploaded a pre-built local baseline bundle to
|
|
92
|
+
* `POST /v1/visual/projects/:id/baselines`, but the server has never read the
|
|
93
|
+
* `manifest`/`results`/`screenshots` fields that body carries: the PNGs were
|
|
94
|
+
* never stored, and the manifest referenced local filenames rather than Snap
|
|
95
|
+
* object keys. The request used to fail with an opaque 500 and, since
|
|
96
|
+
* i2Dev-com/snap#653, is rejected outright with `400 unsupported_baseline_body`.
|
|
97
|
+
*
|
|
98
|
+
* `snapdrift baseline` is the supported replacement — it captures each route
|
|
99
|
+
* through Snap so the pixels actually land in storage, then publishes a manifest
|
|
100
|
+
* that references them (and that `--to local --from snap` can export back out).
|
|
101
|
+
*
|
|
102
|
+
* @param {import('../types/visual-diff-types').VisualRegressionConfig} _config
|
|
103
|
+
* @param {import('../types/visual-diff-types').CliOptions} _opts
|
|
104
|
+
* @returns {Promise<never>}
|
|
105
|
+
* @throws {Error} Always — the direction is not supported by the Snap API.
|
|
95
106
|
*/
|
|
96
|
-
export async function runMigrateToSnap(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
process.stdout.write(`Found ${screenshots.length} screenshot(s) for commit ${headSha.substring(0, 8)}\n`);
|
|
106
|
-
|
|
107
|
-
// Check idempotency — skip if baseline already exists
|
|
108
|
-
const existing = await provider.checkBaselineExists(headSha);
|
|
109
|
-
if (existing) {
|
|
110
|
-
process.stdout.write(`Baseline already exists for commit ${headSha.substring(0, 8)}. Skipping upload.\n`);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const { uploaded, baselineId } = await provider.migrateBaselineFromLocal({
|
|
115
|
-
results,
|
|
116
|
-
manifest,
|
|
117
|
-
screenshots,
|
|
118
|
-
headSha
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
process.stdout.write(`Uploaded ${uploaded} screenshot(s) as baseline ${baselineId}\n`);
|
|
107
|
+
export async function runMigrateToSnap(_config, _opts) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'migrate-baselines --to snap is no longer supported: Snap cannot accept a pre-built local ' +
|
|
110
|
+
'baseline bundle, because its screenshots are never uploaded to Snap storage and its manifest ' +
|
|
111
|
+
'references local filenames rather than Snap object keys.\n' +
|
|
112
|
+
'Run `snapdrift baseline` instead — it captures each route through Snap and publishes a ' +
|
|
113
|
+
'baseline from the stored objects.\n' +
|
|
114
|
+
'Migration in the other direction (`migrate-baselines --to local --from snap`) is unaffected.'
|
|
115
|
+
);
|
|
122
116
|
}
|
|
123
117
|
|
|
124
118
|
/**
|
package/lib/snap-provider.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
3
4
|
import crypto from 'node:crypto';
|
|
4
5
|
import fs from 'node:fs/promises';
|
|
5
6
|
import fsSync from 'node:fs';
|
|
@@ -9,7 +10,7 @@ import path from 'node:path';
|
|
|
9
10
|
|
|
10
11
|
import { makeMarkdown, buildReportCommentBody } from '@snapdrift/adapter-report-md';
|
|
11
12
|
import { loadSnapdriftConfig, runBaselineCapture } from '@snapdrift/adapter-fs';
|
|
12
|
-
import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_PRESETS } from '@snapdrift/manifest';
|
|
13
|
+
import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_PRESETS, viewportHash } from '@snapdrift/manifest';
|
|
13
14
|
|
|
14
15
|
/** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
|
|
15
16
|
/** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
|
|
@@ -71,6 +72,47 @@ function repoSlugToProjectId(repoSlug) {
|
|
|
71
72
|
return repoSlug.replace(/[/.]/g, '--');
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Run a git command, returning undefined outside a repository or when git is
|
|
77
|
+
* unavailable. execFileSync (not execSync) so no argument reaches a shell.
|
|
78
|
+
*
|
|
79
|
+
* @param {string[]} args
|
|
80
|
+
* @returns {string | undefined}
|
|
81
|
+
*/
|
|
82
|
+
function gitOutput(args) {
|
|
83
|
+
try {
|
|
84
|
+
const out = execFileSync('git', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
85
|
+
const trimmed = out.trim();
|
|
86
|
+
return trimmed === '' ? undefined : trimmed;
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the branch and commit a baseline should be attributed to.
|
|
94
|
+
*
|
|
95
|
+
* GitHub Actions exports these; a developer running `snapdrift baseline` from a
|
|
96
|
+
* terminal does not. Without the git fallback every locally seeded baseline was
|
|
97
|
+
* recorded as branch `main` at SHA `unknown`, which misattributes it in the
|
|
98
|
+
* dashboard and in exported metadata — and makes two baselines cut from
|
|
99
|
+
* different commits indistinguishable. Detached HEAD yields no branch name, so
|
|
100
|
+
* `main` remains the last resort.
|
|
101
|
+
*
|
|
102
|
+
* @returns {{ refBranch: string, refSha: string }}
|
|
103
|
+
*/
|
|
104
|
+
export function resolveGitRef() {
|
|
105
|
+
const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
106
|
+
return {
|
|
107
|
+
refBranch:
|
|
108
|
+
process.env.GITHUB_REF_NAME ||
|
|
109
|
+
process.env.GITHUB_HEAD_REF ||
|
|
110
|
+
(branch && branch !== 'HEAD' ? branch : undefined) ||
|
|
111
|
+
'main',
|
|
112
|
+
refSha: process.env.GITHUB_SHA || gitOutput(['rev-parse', 'HEAD']) || 'unknown'
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
74
116
|
/**
|
|
75
117
|
* Read the package version from package.json.
|
|
76
118
|
* @returns {string}
|
|
@@ -85,6 +127,146 @@ function getPackageVersion() {
|
|
|
85
127
|
}
|
|
86
128
|
}
|
|
87
129
|
|
|
130
|
+
const TAR_BLOCK_SIZE = 512;
|
|
131
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Strips characters that could cause path traversal or produce invalid
|
|
135
|
+
* filenames when a route id is used as a screenshot filename. Mirrors the
|
|
136
|
+
* sanitisation applied by `@snapdrift/adapter-fs` during local capture so
|
|
137
|
+
* imported baselines land on the same filenames a local capture would produce.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} id
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function sanitizeRouteId(id) {
|
|
143
|
+
return id
|
|
144
|
+
.replace(/\.\./g, '_')
|
|
145
|
+
.replace(/[/\\]/g, '_')
|
|
146
|
+
// eslint-disable-next-line no-control-regex
|
|
147
|
+
.replace(/[\x00-\x1f\x7f]/g, '');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse a plain ustar-style tar archive (512-byte headers, bodies padded to
|
|
152
|
+
* 512-byte blocks, terminated by zero blocks) into a map of entry name →
|
|
153
|
+
* entry bytes. This is the exact layout Snap's export endpoint produces;
|
|
154
|
+
* long-name extensions (GNU/pax) are not needed and not supported.
|
|
155
|
+
*
|
|
156
|
+
* @param {Buffer} archive
|
|
157
|
+
* @returns {Map<string, Buffer>}
|
|
158
|
+
*/
|
|
159
|
+
function parseTarEntries(archive) {
|
|
160
|
+
/** @type {Map<string, Buffer>} */
|
|
161
|
+
const entries = new Map();
|
|
162
|
+
let offset = 0;
|
|
163
|
+
|
|
164
|
+
while (offset + TAR_BLOCK_SIZE <= archive.length) {
|
|
165
|
+
const header = archive.subarray(offset, offset + TAR_BLOCK_SIZE);
|
|
166
|
+
if (header.every((byte) => byte === 0)) {
|
|
167
|
+
break; // end-of-archive marker
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const nameField = header.subarray(0, 100);
|
|
171
|
+
const nameEnd = nameField.indexOf(0);
|
|
172
|
+
const name = nameField.toString('utf-8', 0, nameEnd === -1 ? 100 : nameEnd);
|
|
173
|
+
const size = parseInt(header.toString('ascii', 124, 136).replace(/\0/g, '').trim(), 8);
|
|
174
|
+
|
|
175
|
+
if (!name || !Number.isFinite(size) || size < 0) {
|
|
176
|
+
throw new Error(`Snap export archive is corrupt: invalid tar header at offset ${offset}.`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const bodyStart = offset + TAR_BLOCK_SIZE;
|
|
180
|
+
if (bodyStart + size > archive.length) {
|
|
181
|
+
throw new Error(`Snap export archive is truncated: entry "${name}" extends past the end of the archive.`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
entries.set(name, archive.subarray(bodyStart, bodyStart + size));
|
|
185
|
+
offset = bodyStart + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return entries;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read the pixel dimensions from a PNG's IHDR chunk. Returns null when the
|
|
193
|
+
* bytes are not a PNG (e.g. a JPEG export) or are too short.
|
|
194
|
+
*
|
|
195
|
+
* @param {Buffer} bytes
|
|
196
|
+
* @returns {{ width: number, height: number } | null}
|
|
197
|
+
*/
|
|
198
|
+
function readPngDimensions(bytes) {
|
|
199
|
+
if (bytes.length < 24 || !bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param {string | undefined} json
|
|
207
|
+
* @returns {any}
|
|
208
|
+
*/
|
|
209
|
+
function safeJsonParse(json) {
|
|
210
|
+
if (!json) return null;
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(json);
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Sort key for picking the newest baseline. Snap serialises createdAt as an
|
|
220
|
+
* ISO string, but tolerate epoch numbers and treat unparsable values as
|
|
221
|
+
* oldest so a legacy row never shadows a well-formed one.
|
|
222
|
+
*
|
|
223
|
+
* @param {{ createdAt?: unknown }} baseline
|
|
224
|
+
* @returns {number}
|
|
225
|
+
*/
|
|
226
|
+
function baselineTimestamp(baseline) {
|
|
227
|
+
const createdAt = baseline?.createdAt;
|
|
228
|
+
if (typeof createdAt === 'number') return createdAt;
|
|
229
|
+
const parsed = Date.parse(typeof createdAt === 'string' ? createdAt : '');
|
|
230
|
+
return Number.isNaN(parsed) ? -Infinity : parsed;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Derive the capture engine from a baseline's capture profile. A baseline
|
|
235
|
+
* published by SnapDrift records `{ engine: { name: "snapdrift-local" } }`;
|
|
236
|
+
* a baseline rendered by Snap's hosted worker records Snap's own engine.
|
|
237
|
+
* When nothing is recorded (legacy hosted baselines export `{}`), fall back
|
|
238
|
+
* to "snap-hosted" so the CLI's cross-engine flow engages truthfully.
|
|
239
|
+
*
|
|
240
|
+
* @param {any} captureProfile
|
|
241
|
+
* @returns {{ name: string, version: string }}
|
|
242
|
+
*/
|
|
243
|
+
function resolveExportEngine(captureProfile) {
|
|
244
|
+
const engine = captureProfile?.engine;
|
|
245
|
+
if (engine && typeof engine.name === 'string' && engine.name) {
|
|
246
|
+
return {
|
|
247
|
+
name: engine.name,
|
|
248
|
+
version: typeof engine.version === 'string' && engine.version ? engine.version : 'unknown'
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return { name: 'snap-hosted', version: 'unknown' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Extract a human-readable detail string from a Snap API error response.
|
|
256
|
+
*
|
|
257
|
+
* @param {{ text: () => Promise<string> }} response
|
|
258
|
+
* @returns {Promise<string>}
|
|
259
|
+
*/
|
|
260
|
+
async function readErrorDetail(response) {
|
|
261
|
+
const errorBody = await response.text().catch(() => '');
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(errorBody);
|
|
264
|
+
return parsed.error || parsed.message || '';
|
|
265
|
+
} catch {
|
|
266
|
+
return '';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
88
270
|
/**
|
|
89
271
|
* SnapProvider — VisualProvider that delegates capture + diff to Snap's
|
|
90
272
|
* hosted /v1/visual/* API.
|
|
@@ -291,11 +473,30 @@ export class SnapProvider {
|
|
|
291
473
|
// create a baseline from the rendered S3 keys.
|
|
292
474
|
if (resultsPath) {
|
|
293
475
|
let metadata;
|
|
476
|
+
/** @type {unknown} */
|
|
477
|
+
let readError;
|
|
294
478
|
try {
|
|
295
479
|
metadata = JSON.parse(await fs.readFile(resultsPath, 'utf-8'));
|
|
296
|
-
} catch {
|
|
480
|
+
} catch (error) {
|
|
481
|
+
readError = error;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!metadata?.runId) {
|
|
485
|
+
// Previously this fell through to a "legacy" branch that POSTed
|
|
486
|
+
// `manifest`/`results` as objects. Snap has never read those fields, so
|
|
487
|
+
// the request could only ever fail — with an opaque 500 before, and with
|
|
488
|
+
// `400 unsupported_baseline_body` since i2Dev-com/snap#653. Reporting the
|
|
489
|
+
// real problem (no run id) beats emitting a request guaranteed to fail
|
|
490
|
+
// and surfacing its body-shape error. See #109.
|
|
491
|
+
throw new Error(
|
|
492
|
+
`Cannot publish a Snap baseline: no run id found in ${resultsPath}. ` +
|
|
493
|
+
'A baseline is created from the object keys of a completed Snap run, so the capture step ' +
|
|
494
|
+
'must have recorded one. Run `snapdrift baseline` (or actions/baseline) with ' +
|
|
495
|
+
`provider "snap".${readError ? ` Could not read that file: ${readError instanceof Error ? readError.message : String(readError)}` : ''}`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
297
498
|
|
|
298
|
-
|
|
499
|
+
{
|
|
299
500
|
const { runId } = metadata;
|
|
300
501
|
const idempotencyKey = `baseline-${runId}`;
|
|
301
502
|
|
|
@@ -347,8 +548,7 @@ export class SnapProvider {
|
|
|
347
548
|
`/v1/visual/projects/${this.#projectId}/baselines`,
|
|
348
549
|
{
|
|
349
550
|
id: baselineId,
|
|
350
|
-
|
|
351
|
-
refSha: process.env.GITHUB_SHA || 'unknown',
|
|
551
|
+
...resolveGitRef(),
|
|
352
552
|
manifestJson,
|
|
353
553
|
captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
|
|
354
554
|
},
|
|
@@ -360,37 +560,12 @@ export class SnapProvider {
|
|
|
360
560
|
}
|
|
361
561
|
}
|
|
362
562
|
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const bResultsPath = options.resultsPath || path.join(bundleDir, 'results.json');
|
|
370
|
-
const manifestPath = options.manifestPath || path.join(bundleDir, 'manifest.json');
|
|
371
|
-
|
|
372
|
-
try {
|
|
373
|
-
const results = JSON.parse(await fs.readFile(bResultsPath, 'utf-8'));
|
|
374
|
-
body.results = results;
|
|
375
|
-
} catch { /* results.json may not exist */ }
|
|
376
|
-
|
|
377
|
-
try {
|
|
378
|
-
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
|
379
|
-
body.manifest = manifest;
|
|
380
|
-
} catch { /* manifest.json may not exist */ }
|
|
381
|
-
|
|
382
|
-
if (body.results?.runId) {
|
|
383
|
-
body.idempotencyKey = `baseline-${body.results.runId}`;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
|
|
388
|
-
|
|
389
|
-
const bundleDir = options.bundleDir
|
|
390
|
-
? path.resolve(options.bundleDir)
|
|
391
|
-
: await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
392
|
-
|
|
393
|
-
return { bundleDir };
|
|
563
|
+
// No resultsPath and no bundleDir: there is no run to build a baseline from.
|
|
564
|
+
throw new Error(
|
|
565
|
+
'Cannot publish a Snap baseline: neither resultsPath nor bundleDir was provided, so there ' +
|
|
566
|
+
'is no Snap run to harvest object keys from. Run `snapdrift baseline` (or actions/baseline) ' +
|
|
567
|
+
'with provider "snap", which captures first and passes the run results through.'
|
|
568
|
+
);
|
|
394
569
|
}
|
|
395
570
|
|
|
396
571
|
/**
|
|
@@ -469,64 +644,198 @@ export class SnapProvider {
|
|
|
469
644
|
}
|
|
470
645
|
}
|
|
471
646
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
* @param {{
|
|
481
|
-
* results: object,
|
|
482
|
-
* manifest: object,
|
|
483
|
-
* screenshots: Array<{ filename: string, data: string }>,
|
|
484
|
-
* headSha: string
|
|
485
|
-
* }} options
|
|
486
|
-
* @returns {Promise<{ uploaded: number, skipped: number, baselineId: string }>}
|
|
487
|
-
*/
|
|
488
|
-
async migrateBaselineFromLocal(options) {
|
|
489
|
-
const { results, manifest, screenshots, headSha } = options;
|
|
490
|
-
const idempotencyKey = `baseline-migrate-${headSha}`;
|
|
491
|
-
|
|
492
|
-
const body = {
|
|
493
|
-
idempotencyKey,
|
|
494
|
-
results,
|
|
495
|
-
manifest,
|
|
496
|
-
screenshots,
|
|
497
|
-
headSha,
|
|
498
|
-
captureProfile: this.#buildCaptureProfile()
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
const response = await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
|
|
502
|
-
|
|
503
|
-
const screenshotCount = screenshots?.length ?? 0;
|
|
504
|
-
return {
|
|
505
|
-
uploaded: screenshotCount,
|
|
506
|
-
skipped: 0,
|
|
507
|
-
baselineId: response.id || idempotencyKey
|
|
508
|
-
};
|
|
509
|
-
}
|
|
647
|
+
// `migrateBaselineFromLocal()` was removed in 0.7.0. It POSTed a pre-built
|
|
648
|
+
// local bundle (`manifest`/`results`/`screenshots` as objects) to the
|
|
649
|
+
// baselines API, but the server never read those fields — the PNGs were never
|
|
650
|
+
// stored and the manifest referenced local filenames rather than Snap object
|
|
651
|
+
// keys, so the call could only ever produce an unexportable baseline. Snap now
|
|
652
|
+
// rejects that body with `400 unsupported_baseline_body` (i2Dev-com/snap#653).
|
|
653
|
+
// Use `publishBaseline()` after `capture()` — or the `snapdrift baseline`
|
|
654
|
+
// command, which wires both together. See ranacseruet/snapdrift#106.
|
|
510
655
|
|
|
511
656
|
/**
|
|
512
657
|
* Export baselines from Snap for local import.
|
|
513
658
|
* Used by `snapdrift migrate-baselines --to local --from snap`.
|
|
514
659
|
*
|
|
515
|
-
*
|
|
660
|
+
* Downloads the project's export archive from
|
|
661
|
+
* `GET /v1/visual/projects/:id/export` (a plain ustar tar containing a
|
|
662
|
+
* `manifest.json`, per-baseline screenshot images, and per-baseline
|
|
663
|
+
* capture profiles; requires an API key with the `visual:export` scope),
|
|
664
|
+
* selects the most recent accepted baseline, and maps it back to the
|
|
665
|
+
* local baseline layout (`results.json` + `manifest.json` +
|
|
666
|
+
* `screenshots/<routeId>.png`) that `LocalProvider` reads.
|
|
667
|
+
*
|
|
668
|
+
* @param {{ tag?: string }} [_options]
|
|
516
669
|
* @returns {Promise<{
|
|
517
670
|
* results: object,
|
|
518
671
|
* manifest: object,
|
|
519
672
|
* screenshots: Array<{ filename: string, data: Buffer }>,
|
|
520
673
|
* engine: { name: string, version: string }
|
|
521
674
|
* }>}
|
|
522
|
-
* @throws {Error}
|
|
675
|
+
* @throws {Error} When the project has no accepted baselines, the selected
|
|
676
|
+
* baseline predates manifest tracking, or the export request fails.
|
|
523
677
|
*/
|
|
524
678
|
async exportBaselines(_options) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
679
|
+
const exportPath = `/v1/visual/projects/${this.#projectId}/export`;
|
|
680
|
+
let archive;
|
|
681
|
+
try {
|
|
682
|
+
archive = await this.#requestBinary('GET', exportPath);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (error instanceof SnapApiError) {
|
|
685
|
+
if (error.status === 403) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`Snap refused the export (403): the API key does not have the "visual:export" scope. ` +
|
|
688
|
+
`Issue a key with visual:export access for project "${this.#projectId}" and retry.`,
|
|
689
|
+
{ cause: error }
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (error.status === 404) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`Snap project "${this.#projectId}" was not found (404). ` +
|
|
695
|
+
`Check snap.projectId in snapdrift.json (or GITHUB_REPOSITORY when projectId is "auto").`,
|
|
696
|
+
{ cause: error }
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
if (error.status === 413) {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`Snap export for project "${this.#projectId}" is too large (413): ${error.message}. ` +
|
|
702
|
+
`The export endpoint caps the number of accepted baselines and total archive bytes.`,
|
|
703
|
+
{ cause: error }
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const entries = parseTarEntries(archive);
|
|
711
|
+
const manifestBytes = entries.get('manifest.json');
|
|
712
|
+
if (!manifestBytes) {
|
|
713
|
+
throw new Error('Snap export archive is missing manifest.json.');
|
|
714
|
+
}
|
|
715
|
+
const exportManifest = JSON.parse(manifestBytes.toString('utf-8'));
|
|
716
|
+
const baselines = Array.isArray(exportManifest.baselines) ? exportManifest.baselines : [];
|
|
717
|
+
if (baselines.length === 0) {
|
|
718
|
+
throw new Error(
|
|
719
|
+
`Snap project "${this.#projectId}" has no accepted baselines to export. ` +
|
|
720
|
+
`Publish a baseline first (snapdrift baseline with provider: "snap").`
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Most recent accepted baseline wins; `>=` makes the later array entry win
|
|
725
|
+
// a createdAt tie (the server appends newer rows last).
|
|
726
|
+
let baseline = baselines[0];
|
|
727
|
+
for (const candidate of baselines.slice(1)) {
|
|
728
|
+
if (baselineTimestamp(candidate) >= baselineTimestamp(baseline)) {
|
|
729
|
+
baseline = candidate;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!baseline.sourceManifest) {
|
|
734
|
+
throw new Error(
|
|
735
|
+
`Snap baseline "${baseline.id}" predates manifest tracking (no source manifest was recorded ` +
|
|
736
|
+
`at publish time) and cannot be migrated. Publish a fresh baseline with a current SnapDrift ` +
|
|
737
|
+
`version, then re-run the migration.`
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
const routes = Array.isArray(baseline.sourceManifest.routes) ? baseline.sourceManifest.routes : null;
|
|
741
|
+
if (!routes) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Snap baseline "${baseline.id}" has an unrecognised source manifest format ` +
|
|
744
|
+
`(expected a "routes" array) and cannot be migrated.`
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const archivePathByKey = new Map(
|
|
749
|
+
(baseline.objects || []).map((object) => [object.sourceKey, object.archivePath])
|
|
529
750
|
);
|
|
751
|
+
|
|
752
|
+
/** @type {Array<{ filename: string, data: Buffer }>} */
|
|
753
|
+
const screenshots = [];
|
|
754
|
+
/** @type {Array<object>} */
|
|
755
|
+
const manifestEntries = [];
|
|
756
|
+
|
|
757
|
+
for (const route of routes) {
|
|
758
|
+
const archivePath = route.objectKey ? archivePathByKey.get(route.objectKey) : undefined;
|
|
759
|
+
const bytes = archivePath ? entries.get(archivePath) : undefined;
|
|
760
|
+
if (!bytes) {
|
|
761
|
+
process.stderr.write(
|
|
762
|
+
`[SnapDrift] Warning: Snap export has no image for route "${route.routeId}" ` +
|
|
763
|
+
`in baseline ${baseline.id}. Skipping the route.\n`
|
|
764
|
+
);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const descriptor = safeJsonParse(route.viewportDescriptorJson) ?? {};
|
|
769
|
+
const hasDescriptorDims = typeof descriptor.width === 'number' && typeof descriptor.height === 'number';
|
|
770
|
+
// Map the viewport descriptor back to a preset name when it matches one
|
|
771
|
+
// exactly; otherwise keep custom dimensions. This round-trips with the
|
|
772
|
+
// descriptor expansion capture() applies on the way up.
|
|
773
|
+
const presetName = hasDescriptorDims ? viewportHash(descriptor) : null;
|
|
774
|
+
const dimensions = readPngDimensions(bytes)
|
|
775
|
+
?? (hasDescriptorDims ? { width: descriptor.width, height: descriptor.height } : { width: 1, height: 1 });
|
|
776
|
+
const viewport = presetName && VIEWPORT_PRESETS[presetName]
|
|
777
|
+
? presetName
|
|
778
|
+
: hasDescriptorDims
|
|
779
|
+
? { width: descriptor.width, height: descriptor.height }
|
|
780
|
+
: { width: dimensions.width, height: dimensions.height };
|
|
781
|
+
|
|
782
|
+
const extension = path.extname(archivePath) || '.png';
|
|
783
|
+
const filename = `${sanitizeRouteId(route.routeId)}${extension}`;
|
|
784
|
+
screenshots.push({ filename, data: Buffer.from(bytes) });
|
|
785
|
+
manifestEntries.push({
|
|
786
|
+
id: route.routeId,
|
|
787
|
+
path: route.routePath ?? '',
|
|
788
|
+
viewport,
|
|
789
|
+
imagePath: `screenshots/${filename}`,
|
|
790
|
+
width: dimensions.width,
|
|
791
|
+
height: dimensions.height
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (screenshots.length === 0) {
|
|
796
|
+
throw new Error(
|
|
797
|
+
`Snap baseline "${baseline.id}" contained no exportable screenshots — ` +
|
|
798
|
+
`the export archive had no image bytes for any of its routes.`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const captureProfile = safeJsonParse(
|
|
803
|
+
entries.get(`${baseline.id}/capture_profile.json`)?.toString('utf-8')
|
|
804
|
+
) ?? {};
|
|
805
|
+
const engine = resolveExportEngine(captureProfile);
|
|
806
|
+
|
|
807
|
+
const generatedAt = new Date().toISOString();
|
|
808
|
+
const manifest = {
|
|
809
|
+
schemaVersion: 1,
|
|
810
|
+
generatedAt,
|
|
811
|
+
baseUrl: typeof baseline.sourceManifest.baseUrl === 'string' ? baseline.sourceManifest.baseUrl : '',
|
|
812
|
+
screenshots: manifestEntries,
|
|
813
|
+
captureProfile: { ...captureProfile, engine }
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const results = {
|
|
817
|
+
startedAt: typeof baseline.createdAt === 'string' ? baseline.createdAt : generatedAt,
|
|
818
|
+
suite: 'snap-export',
|
|
819
|
+
provider: 'snap',
|
|
820
|
+
projectId: this.#projectId,
|
|
821
|
+
baselineId: baseline.id,
|
|
822
|
+
refBranch: baseline.refBranch,
|
|
823
|
+
// readLocalBaselines() picks this up as the commit SHA if the baseline is
|
|
824
|
+
// ever migrated back to Snap, keeping the idempotency key stable.
|
|
825
|
+
headSha: baseline.refSha,
|
|
826
|
+
routes: manifestEntries.map((entry) => ({
|
|
827
|
+
id: entry.id,
|
|
828
|
+
path: entry.path,
|
|
829
|
+
viewport: entry.viewport,
|
|
830
|
+
status: 'passed',
|
|
831
|
+
durationMs: 0,
|
|
832
|
+
imagePath: entry.imagePath,
|
|
833
|
+
width: entry.width,
|
|
834
|
+
height: entry.height
|
|
835
|
+
}))
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
return { results, manifest, screenshots, engine };
|
|
530
839
|
}
|
|
531
840
|
|
|
532
841
|
// ---------------------------------------------------------------------------
|
|
@@ -811,6 +1120,67 @@ export class SnapProvider {
|
|
|
811
1120
|
return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
|
|
812
1121
|
}
|
|
813
1122
|
|
|
1123
|
+
/**
|
|
1124
|
+
* Make an HTTP request to the Snap API that returns a binary body (e.g. the
|
|
1125
|
+
* export tar). Mirrors #request's retry and error semantics — 4xx never
|
|
1126
|
+
* retries and throws SnapApiError, 5xx/network errors retry with backoff and
|
|
1127
|
+
* consult onUnavailable when exhausted — but returns the raw bytes instead
|
|
1128
|
+
* of parsed JSON.
|
|
1129
|
+
*
|
|
1130
|
+
* @param {string} method
|
|
1131
|
+
* @param {string} path
|
|
1132
|
+
* @returns {Promise<any>} Raw response bytes as a Buffer
|
|
1133
|
+
*/
|
|
1134
|
+
async #requestBinary(method, path) {
|
|
1135
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
1136
|
+
try {
|
|
1137
|
+
const response = await this.#fetchFn(`${this.#apiUrl}${path}`, {
|
|
1138
|
+
method,
|
|
1139
|
+
headers: { 'Authorization': `Bearer ${this.#apiKey}` }
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
if (response.ok) {
|
|
1143
|
+
return Buffer.from(await response.arrayBuffer());
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// 4xx — never retry
|
|
1147
|
+
if (response.status >= 400 && response.status < 500) {
|
|
1148
|
+
const detail = await readErrorDetail(response);
|
|
1149
|
+
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
1150
|
+
throw new SnapApiError(response.status, errorMessage, path);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// 5xx — retry with backoff (unless this is the last attempt)
|
|
1154
|
+
if (attempt < MAX_RETRIES) {
|
|
1155
|
+
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
1156
|
+
await this.#sleepFn(delay);
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Exhausted retries
|
|
1161
|
+
const detail = await readErrorDetail(response);
|
|
1162
|
+
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
1163
|
+
return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
if (error instanceof SnapApiError) {
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Network error — retry
|
|
1170
|
+
if (attempt < MAX_RETRIES) {
|
|
1171
|
+
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
1172
|
+
await this.#sleepFn(delay);
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Unreachable, but satisfies the type checker
|
|
1181
|
+
return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
814
1184
|
/**
|
|
815
1185
|
* Handle unavailable behavior based on onUnavailable config.
|
|
816
1186
|
* @param {Error} error
|