snapdrift 0.6.0 → 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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
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
+
3
19
  ## 0.6.0 - 2026-07-17
4
20
 
5
21
  ### Features
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
- # Capture a baseline
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 it after making UI changes
78
+ # Compare against the baseline after making UI changes
76
79
  snapdrift diff --open
77
80
 
78
- # Migrate local baselines to the hosted Snap backend
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/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
- Move baselines between the local filesystem and the hosted Snap backend. Requires a config with `provider: "snap"` (or a `snap` block) for the direction being targeted.
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
- **Upload local baselines to Snap:**
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
- Reads `results.json`, `manifest.json`, and `screenshots/*.png` from the local baseline directory, then POSTs them to Snap as the initial accepted baseline for the current commit. Idempotent: if a baseline already exists for the same commit SHA the upload is skipped.
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 diff was intentionally skipped (e.g. `SnapSkipError` from `onUnavailable: "warn-and-skip"`), or the command was a `capture` / `migrate-baselines` / `init` that completed |
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
- * @param {import('../types/visual-diff-types').VisualRegressionConfig} config
93
- * @param {import('../types/visual-diff-types').CliOptions} opts
94
- * @returns {Promise<void>}
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(config, opts) {
97
- process.stdout.write(`Migrating baselines to Snap ...\n`);
98
-
99
- requireSnapConfig(config);
100
- const provider = createProvider('snap', config);
101
- const baselineDir = opts.baselineDir;
102
-
103
- const { results, manifest, screenshots, headSha } = await readLocalBaselines(baselineDir);
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
  /**
@@ -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';
@@ -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}
@@ -431,11 +473,30 @@ export class SnapProvider {
431
473
  // create a baseline from the rendered S3 keys.
432
474
  if (resultsPath) {
433
475
  let metadata;
476
+ /** @type {unknown} */
477
+ let readError;
434
478
  try {
435
479
  metadata = JSON.parse(await fs.readFile(resultsPath, 'utf-8'));
436
- } catch { /* fall through to legacy path */ }
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
+ }
437
498
 
438
- if (metadata?.runId) {
499
+ {
439
500
  const { runId } = metadata;
440
501
  const idempotencyKey = `baseline-${runId}`;
441
502
 
@@ -487,8 +548,7 @@ export class SnapProvider {
487
548
  `/v1/visual/projects/${this.#projectId}/baselines`,
488
549
  {
489
550
  id: baselineId,
490
- refBranch: process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || 'main',
491
- refSha: process.env.GITHUB_SHA || 'unknown',
551
+ ...resolveGitRef(),
492
552
  manifestJson,
493
553
  captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
494
554
  },
@@ -500,37 +560,12 @@ export class SnapProvider {
500
560
  }
501
561
  }
502
562
 
503
- // Legacy / migration path: bundle dir with pre-built results/manifest
504
- const idempotencyKey = this.#generateIdempotencyKey();
505
- const body = { idempotencyKey };
506
-
507
- if (options.bundleDir) {
508
- const bundleDir = path.resolve(options.bundleDir);
509
- const bResultsPath = options.resultsPath || path.join(bundleDir, 'results.json');
510
- const manifestPath = options.manifestPath || path.join(bundleDir, 'manifest.json');
511
-
512
- try {
513
- const results = JSON.parse(await fs.readFile(bResultsPath, 'utf-8'));
514
- body.results = results;
515
- } catch { /* results.json may not exist */ }
516
-
517
- try {
518
- const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
519
- body.manifest = manifest;
520
- } catch { /* manifest.json may not exist */ }
521
-
522
- if (body.results?.runId) {
523
- body.idempotencyKey = `baseline-${body.results.runId}`;
524
- }
525
- }
526
-
527
- await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
528
-
529
- const bundleDir = options.bundleDir
530
- ? path.resolve(options.bundleDir)
531
- : await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
532
-
533
- 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
+ );
534
569
  }
535
570
 
536
571
  /**
@@ -609,44 +644,14 @@ export class SnapProvider {
609
644
  }
610
645
  }
611
646
 
612
- /**
613
- * Upload local baselines to Snap as the initial accepted baseline.
614
- * Used by `snapdrift migrate-baselines --to snap`.
615
- *
616
- * Reads local baseline files and POSTs them to the Snap baselines API.
617
- * Screenshots are base64-encoded and included in the request body.
618
- * Idempotent: uses a commit-SHA-derived idempotency key.
619
- *
620
- * @param {{
621
- * results: object,
622
- * manifest: object,
623
- * screenshots: Array<{ filename: string, data: string }>,
624
- * headSha: string
625
- * }} options
626
- * @returns {Promise<{ uploaded: number, skipped: number, baselineId: string }>}
627
- */
628
- async migrateBaselineFromLocal(options) {
629
- const { results, manifest, screenshots, headSha } = options;
630
- const idempotencyKey = `baseline-migrate-${headSha}`;
631
-
632
- const body = {
633
- idempotencyKey,
634
- results,
635
- manifest,
636
- screenshots,
637
- headSha,
638
- captureProfile: this.#buildCaptureProfile()
639
- };
640
-
641
- const response = await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
642
-
643
- const screenshotCount = screenshots?.length ?? 0;
644
- return {
645
- uploaded: screenshotCount,
646
- skipped: 0,
647
- baselineId: response.id || idempotencyKey
648
- };
649
- }
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.
650
655
 
651
656
  /**
652
657
  * Export baselines from Snap for local import.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snapdrift",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",