snapdrift 0.8.2 → 0.10.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/lib/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import { execSync } from 'node:child_process';
3
+ import { spawnSync } from 'node:child_process';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
 
@@ -49,6 +49,11 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
49
49
  * --accept-cross-engine Allow importing baselines from a different capture engine
50
50
  * --from-snap-action <path> Snap action workflow YAML path (init command)
51
51
  *
52
+ * Values may be given inline as `--flag=value` (so values beginning with a hyphen,
53
+ * e.g. `--routes=-mobile`, work) or after the `--` end-of-options marker. Unknown
54
+ * flags and missing option values are rejected with a non-zero error; invalid
55
+ * *values* for --to/--from remain silently ignored (validated downstream).
56
+ *
52
57
  * @param {string[]} argv - process.argv
53
58
  * @returns {CliOptions}
54
59
  */
@@ -71,41 +76,105 @@ export function parseArgs(argv) {
71
76
  /** @type {string | undefined} */
72
77
  let fromSnapAction;
73
78
 
74
- for (let i = 1; i < args.length; i++) {
75
- const arg = args[i];
76
- if (arg === '--open') {
77
- open = true;
78
- } else if (arg === '--config' && i + 1 < args.length) {
79
- configPath = args[++i];
80
- } else if (arg === '--routes' && i + 1 < args.length) {
81
- routes.push(
82
- ...args[++i]
83
- .split(',')
84
- .map((r) => r.trim())
85
- .filter(Boolean)
86
- );
87
- } else if (arg === '--baseline-dir' && i + 1 < args.length) {
88
- baselineDir = path.resolve(args[++i]);
89
- } else if (arg === '--current-dir' && i + 1 < args.length) {
90
- currentDir = path.resolve(args[++i]);
91
- } else if (arg === '--diff-dir' && i + 1 < args.length) {
92
- diffDir = path.resolve(args[++i]);
93
- } else if (arg === '--to' && i + 1 < args.length) {
94
- const toVal = args[++i];
95
- if (toVal === 'snap' || toVal === 'local') {
96
- to = toVal;
97
- }
98
- } else if (arg === '--from' && i + 1 < args.length) {
99
- const fromVal = args[++i];
100
- if (fromVal === 'snap') {
101
- from = fromVal;
102
- }
103
- } else if (arg === '--accept-cross-engine') {
104
- acceptCrossEngine = true;
105
- } else if (arg === '--from-snap-action' && i + 1 < args.length) {
106
- fromSnapAction = args[++i];
107
- }
108
- }
79
+ // Flags that consume the following token as their value. Unknown flags and
80
+ // missing option values are rejected rather than silently ignored, so a typo
81
+ // cannot fall back to the default config/directories. Invalid *values* for
82
+ // --to/--from stay silently ignored to preserve the migrate-baselines contract
83
+ // (their validation happens downstream). Values that begin with a hyphen (e.g.
84
+ // a route id like `-mobile`) may be supplied inline as `--flag=value` or bare.
85
+ const VALUE_FLAGS = new Set([
86
+ '--config',
87
+ '--routes',
88
+ '--baseline-dir',
89
+ '--current-dir',
90
+ '--diff-dir',
91
+ '--to',
92
+ '--from',
93
+ '--from-snap-action'
94
+ ]);
95
+ const BOOLEAN_FLAGS = new Set(['--open', '--accept-cross-engine']);
96
+
97
+ /**
98
+ * Assign a parsed value (from an inline `--flag=value` or a following token)
99
+ * to the option named by `flag`.
100
+ *
101
+ * @param {string} flag
102
+ * @param {string} value
103
+ */
104
+ function assignFlag(flag, value) {
105
+ if (flag === '--routes') {
106
+ routes.push(...value.split(',').map((r) => r.trim()).filter(Boolean));
107
+ } else if (flag === '--config') {
108
+ configPath = value;
109
+ } else if (flag === '--baseline-dir') {
110
+ baselineDir = path.resolve(value);
111
+ } else if (flag === '--current-dir') {
112
+ currentDir = path.resolve(value);
113
+ } else if (flag === '--diff-dir') {
114
+ diffDir = path.resolve(value);
115
+ } else if (flag === '--to') {
116
+ if (value === 'snap' || value === 'local') to = value;
117
+ } else if (flag === '--from') {
118
+ if (value === 'snap') from = value;
119
+ } else if (flag === '--from-snap-action') {
120
+ fromSnapAction = value;
121
+ }
122
+ }
123
+
124
+ let endOfOptions = false;
125
+
126
+ for (let i = 1; i < args.length; i++) {
127
+ const raw = args[i];
128
+
129
+ if (raw === '--') {
130
+ endOfOptions = true;
131
+ continue;
132
+ }
133
+
134
+ // End-of-options: after `--`, tokens are values/positionals, never flags.
135
+ if (endOfOptions) {
136
+ if (VALUE_FLAGS.has(raw)) {
137
+ const value = args[i + 1];
138
+ if (value === undefined) {
139
+ throw new Error(`Missing value for flag ${raw}`);
140
+ }
141
+ assignFlag(raw, value);
142
+ i++; // consume the following token so it is not reprocessed as a flag
143
+ }
144
+ continue;
145
+ }
146
+
147
+ // Inline `--flag=value` form (also handles values that begin with a hyphen).
148
+ const eq = raw.indexOf('=');
149
+ if (raw.startsWith('--') && eq > 0) {
150
+ const flag = raw.slice(0, eq);
151
+ if (!VALUE_FLAGS.has(flag)) {
152
+ throw new Error(`Unknown flag: ${flag}`);
153
+ }
154
+ assignFlag(flag, raw.slice(eq + 1));
155
+ continue;
156
+ }
157
+
158
+ if (BOOLEAN_FLAGS.has(raw)) {
159
+ if (raw === '--open') {
160
+ open = true;
161
+ } else if (raw === '--accept-cross-engine') {
162
+ acceptCrossEngine = true;
163
+ }
164
+ } else if (VALUE_FLAGS.has(raw)) {
165
+ const value = args[i + 1];
166
+ // A hyphenated following token (e.g. a route id like `-mobile`) is a
167
+ // legitimate value; only a missing token, the `--` end-of-options marker,
168
+ // or a following *recognized flag* means the value is absent.
169
+ if (value === undefined || value === '--' || VALUE_FLAGS.has(value) || BOOLEAN_FLAGS.has(value)) {
170
+ throw new Error(`Missing value for flag ${raw}`);
171
+ }
172
+ assignFlag(raw, value);
173
+ i++; // consume the following token so it is not reprocessed as a flag
174
+ } else if (raw.startsWith('-')) {
175
+ throw new Error(`Unknown flag: ${raw}`);
176
+ }
177
+ }
109
178
 
110
179
  return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
111
180
  }
@@ -163,15 +232,30 @@ export function printSummary(summary) {
163
232
  * @returns {void}
164
233
  */
165
234
  function openFile(filePath) {
166
- const cmd =
167
- process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
168
- try {
169
- execSync(`${cmd} "${filePath}"`);
170
- } catch {
235
+ const { cmd, args } = buildOpenCommand(filePath);
236
+ const result = spawnSync(cmd, args);
237
+ if (result.error) {
171
238
  process.stderr.write(`Could not open file: ${filePath}\n`);
172
239
  }
173
240
  }
174
241
 
242
+ /**
243
+ * Build the platform-correct invocation to open a file with the OS default
244
+ * viewer. The path is passed as a separate argument (never shell-interpolated)
245
+ * so metacharacters in a local path cannot execute commands. On Windows, `start`
246
+ * is a cmd.exe built-in rather than an executable, so spawnSync (which does not
247
+ * use a shell by default) returns ENOENT; open the registered handler through
248
+ * rundll32, a real executable, keeping the path as a separate argument.
249
+ *
250
+ * @param {string} filePath
251
+ * @returns {{ cmd: string, args: string[] }}
252
+ */
253
+ export function buildOpenCommand(filePath, platform = process.platform) {
254
+ if (platform === 'darwin') return { cmd: 'open', args: [filePath] };
255
+ if (platform === 'linux') return { cmd: 'xdg-open', args: [filePath] };
256
+ return { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', filePath] };
257
+ }
258
+
175
259
  /**
176
260
  * Run the `capture` command — capture screenshots to a local baseline directory.
177
261
  *
@@ -346,21 +430,24 @@ export async function runDiffCommand(opts) {
346
430
  const markdownPath = path.join(opts.diffDir, 'summary.md');
347
431
  const htmlPath = path.join(opts.diffDir, 'report.html');
348
432
 
433
+ const diffOptions = /** @type {import('../types/visual-diff-types').ProviderDiffOptions & { diffImagesDir: string }} */ ({
434
+ configPath: opts.configPath,
435
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
436
+ baselineResultsPath,
437
+ baselineManifestPath,
438
+ currentResultsPath: currentCapture.resultsPath,
439
+ currentManifestPath: currentCapture.manifestPath,
440
+ baselineRunDir: opts.baselineDir,
441
+ currentRunDir: currentCapture.screenshotsRoot,
442
+ diffImagesDir: path.join(opts.diffDir, 'diffs')
443
+ });
444
+
349
445
  const diffed = await diffWithPolicy({
350
446
  provider: captured.providerName === 'local' ? createProvider('local') : provider,
351
447
  providerName: captured.providerName,
352
448
  localScreenshots: captured.localScreenshots,
353
449
  captureOptions,
354
- diffOptions: {
355
- configPath: opts.configPath,
356
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
357
- baselineResultsPath,
358
- baselineManifestPath,
359
- currentResultsPath: currentCapture.resultsPath,
360
- currentManifestPath: currentCapture.manifestPath,
361
- baselineRunDir: opts.baselineDir,
362
- currentRunDir: currentCapture.screenshotsRoot
363
- },
450
+ diffOptions,
364
451
  onSkip: () =>
365
452
  process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
366
453
  onFallback: () => process.stdout.write('Falling back to local provider for the diff...\n'),
@@ -377,7 +464,8 @@ export async function runDiffCommand(opts) {
377
464
 
378
465
  const html = await generateHtmlReport(summary, {
379
466
  baselineRunDir: opts.baselineDir,
380
- currentRunDir
467
+ currentRunDir,
468
+ diffRunDir: opts.diffDir
381
469
  });
382
470
 
383
471
  await Promise.all([
@@ -87,19 +87,30 @@ function translateStepToConfig(step) {
87
87
  });
88
88
  }
89
89
 
90
- // Snap provider config (if present in inputs)
91
- /** @type {JsonObject | undefined} */
92
- let snap;
93
- const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
94
- const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
95
- const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
96
-
97
- if (snapApiKeyEnv || snapProjectId) {
98
- snap = {};
99
- if (snapApiUrl) snap.apiUrl = snapApiUrl;
100
- if (snapApiKeyEnv) snap.apiKeyEnv = snapApiKeyEnv;
101
- if (snapProjectId) snap.projectId = snapProjectId;
102
- }
90
+ // Snap provider config (if present in inputs). A hosted Snap provider requires
91
+ // an API-key source; only enable provider: "snap" when one is present. When a
92
+ // project ID exists without an API-key source, keep a valid (local) config and
93
+ // emit a clear migration warning rather than emitting provider: "snap" with no
94
+ // API-key source, which later fails validation.
95
+ /** @type {JsonObject | undefined} */
96
+ let snap;
97
+ const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
98
+ const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
99
+ const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
100
+
101
+ if (snapApiKeyEnv) {
102
+ snap = {};
103
+ if (snapApiUrl) snap.apiUrl = snapApiUrl;
104
+ snap.apiKeyEnv = snapApiKeyEnv;
105
+ if (snapProjectId) snap.projectId = snapProjectId;
106
+ } else if (snapProjectId) {
107
+ warnings.push({
108
+ field: 'snap.projectId',
109
+ originalValue: snapProjectId,
110
+ message: `Snap project ID "${snapProjectId}" is present but no API-key source (snap-api-key-env) was configured. SnapDrift keeps the local provider; add a Snap API key source to enable hosted baselines.`,
111
+ severity: 'warning'
112
+ });
113
+ }
103
114
 
104
115
  const config = {
105
116
  baselineArtifactName: 'snapdrift-baseline',
@@ -41,6 +41,25 @@ export const MISSING_BASELINE_REASON = 'missing_main_baseline_artifact';
41
41
  /** @returns {void} */
42
42
  function noop() {}
43
43
 
44
+ /**
45
+ * @param {unknown} outageError
46
+ * @param {'capture' | 'diff'} phase
47
+ * @param {string | undefined} lookupMessage
48
+ * @returns {Error}
49
+ */
50
+ function createBaselineLookupFallbackError(outageError, phase, lookupMessage) {
51
+ const lookupDetail = typeof lookupMessage === 'string' && lookupMessage.trim().length > 0
52
+ ? ` ${lookupMessage.trim()}`
53
+ : '';
54
+ const outageDetail = outageError instanceof Error && outageError.message
55
+ ? ` Snap fallback reason: ${outageError.message}`
56
+ : '';
57
+ return new Error(
58
+ `Cannot fall back to a local ${phase} because the GitHub baseline lookup failed.${lookupDetail}${outageDetail}`,
59
+ { cause: outageError }
60
+ );
61
+ }
62
+
44
63
  /**
45
64
  * Whether the current capture leaves real PNGs on the runner's filesystem.
46
65
  *
@@ -70,7 +89,9 @@ export function hasLocalScreenshots(providerName, config) {
70
89
  * config?: VisualRegressionConfig,
71
90
  * createLocalProvider?: () => VisualProvider,
72
91
  * onSkip?: (error: SnapSkipError) => void,
73
- * onFallback?: (error: SnapFallbackError) => void
92
+ * onFallback?: (error: SnapFallbackError) => void,
93
+ * baselineResolutionStatus?: string,
94
+ * baselineResolutionMessage?: string
74
95
  * }} options
75
96
  * @returns {Promise<{
76
97
  * outcome: 'captured' | 'skipped',
@@ -100,6 +121,9 @@ export async function captureWithPolicy(options) {
100
121
  }
101
122
  if (error instanceof SnapFallbackError) {
102
123
  onFallback(error);
124
+ if (options.baselineResolutionStatus === 'error') {
125
+ throw createBaselineLookupFallbackError(error, 'capture', options.baselineResolutionMessage);
126
+ }
103
127
  const result = await createLocalProvider().capture(options.captureOptions);
104
128
  // The rest of the pipeline must be told the *effective* provider, not the
105
129
  // configured one — otherwise it rebuilds SnapProvider around local output.
@@ -134,7 +158,9 @@ export async function captureWithPolicy(options) {
134
158
  * onSkip?: (error: SnapSkipError) => void,
135
159
  * onFallback?: (error: SnapFallbackError) => void,
136
160
  * onRecapture?: () => void,
137
- * onBaselineUnavailable?: () => void
161
+ * onBaselineUnavailable?: () => void,
162
+ * baselineResolutionStatus?: string,
163
+ * baselineResolutionMessage?: string
138
164
  * }} options
139
165
  * @returns {Promise<{
140
166
  * outcome: 'diffed' | 'skipped' | 'baseline-unavailable',
@@ -170,9 +196,16 @@ export async function diffWithPolicy(options) {
170
196
  : Boolean(options.diffOptions.baselineResultsPath);
171
197
  if (!baselineAvailable) {
172
198
  onBaselineUnavailable();
199
+ if (options.baselineResolutionStatus === 'error') {
200
+ throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
201
+ }
173
202
  return { outcome: 'baseline-unavailable', providerName: 'local' };
174
203
  }
175
204
 
205
+ if (options.baselineResolutionStatus === 'error') {
206
+ throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
207
+ }
208
+
176
209
  const localProvider = createLocalProvider();
177
210
  let diffOptions = options.diffOptions;
178
211
  /** @type {CaptureResult | undefined} */
package/lib/provider.mjs CHANGED
@@ -1,8 +1,16 @@
1
1
  // @ts-check
2
2
 
3
- import { runBaselineCapture, generateDriftReport, stageArtifacts } from '@snapdrift/adapter-fs';
3
+ import { loadSnapdriftConfig, runBaselineCapture, generateDriftReport, stageArtifacts } from '@snapdrift/adapter-fs';
4
4
  import { buildReportCommentBody } from '@snapdrift/adapter-report-md';
5
- import { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl } from './snap-provider.mjs';
5
+ import {
6
+ SnapProvider,
7
+ SnapApiError,
8
+ SnapUpgradeRequiredError,
9
+ SnapUnavailableError,
10
+ SnapFallbackError,
11
+ SnapSkipError,
12
+ isLocalBaseUrl
13
+ } from './snap-provider.mjs';
6
14
 
7
15
  /** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
8
16
  /** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
@@ -35,7 +43,12 @@ export class LocalProvider {
35
43
  * @returns {Promise<DiffResult>}
36
44
  */
37
45
  async diff(options) {
38
- return generateDriftReport(options);
46
+ const { config } = await loadSnapdriftConfig(options.configPath);
47
+ const comparisonPolicy = config.diff.comparisonPolicy ?? {
48
+ version: 1,
49
+ threshold: config.diff.threshold
50
+ };
51
+ return generateDriftReport({ ...options, comparisonPolicy });
39
52
  }
40
53
 
41
54
  /**
@@ -55,8 +68,7 @@ export class LocalProvider {
55
68
  */
56
69
  async fetchLatestBaseline(_options) {
57
70
  throw new Error(
58
- 'LocalProvider.fetchLatestBaseline is not yet implemented. ' +
59
- 'Use the pr-diff action for baseline resolution, or wait for a future release.'
71
+ 'LocalProvider.fetchLatestBaseline is not yet implemented. ' + 'Use the pr-diff action for baseline resolution, or wait for a future release.'
60
72
  );
61
73
  }
62
74
 
@@ -64,7 +76,7 @@ export class LocalProvider {
64
76
  * Build a PR comment body from a diff summary.
65
77
  * LocalProvider omits the dashboard link (no dashboard exists).
66
78
  *
67
- * @param {Record<string, unknown>} summary
79
+ * @param {import('@snapdrift/manifest').VisualReportSummary} summary
68
80
  * @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
69
81
  * @returns {string}
70
82
  */
@@ -73,7 +85,7 @@ export class LocalProvider {
73
85
  }
74
86
  }
75
87
 
76
- /** @type {Record<string, (config?: VisualRegressionConfig) => VisualProvider>} */
88
+ /** @type {{ local: (config?: VisualRegressionConfig) => LocalProvider, snap: (config: VisualRegressionConfig) => SnapProvider }} */
77
89
  const PROVIDER_FACTORIES = {
78
90
  local: () => new LocalProvider(),
79
91
  snap: (config) => {
@@ -84,6 +96,24 @@ const PROVIDER_FACTORIES = {
84
96
  }
85
97
  };
86
98
 
99
+ /**
100
+ * @overload
101
+ * @param {'snap'} providerName
102
+ * @param {VisualRegressionConfig} config
103
+ * @returns {SnapProvider}
104
+ */
105
+ /**
106
+ * @overload
107
+ * @param {'local'} providerName
108
+ * @param {VisualRegressionConfig} [config]
109
+ * @returns {LocalProvider}
110
+ */
111
+ /**
112
+ * @overload
113
+ * @param {string} providerName
114
+ * @param {VisualRegressionConfig} [config]
115
+ * @returns {VisualProvider}
116
+ */
87
117
  /**
88
118
  * Create a VisualProvider instance by name.
89
119
  *
@@ -95,12 +125,9 @@ const PROVIDER_FACTORIES = {
95
125
  export function createProvider(providerName, config) {
96
126
  const factory = PROVIDER_FACTORIES[providerName];
97
127
  if (!factory) {
98
- throw new Error(
99
- `Unknown SnapDrift provider: "${providerName}". ` +
100
- `Available providers: ${Object.keys(PROVIDER_FACTORIES).join(', ')}.`
101
- );
128
+ throw new Error(`Unknown SnapDrift provider: "${providerName}". ` + `Available providers: ${Object.keys(PROVIDER_FACTORIES).join(', ')}.`);
102
129
  }
103
130
  return factory(config);
104
131
  }
105
132
 
106
- export { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl };
133
+ export { SnapProvider, SnapApiError, SnapUpgradeRequiredError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl };
package/lib/report.mjs CHANGED
@@ -26,14 +26,15 @@ async function defaultImageReader(runDir, imagePath) {
26
26
  * @param {{
27
27
  * baselineRunDir?: string,
28
28
  * currentRunDir?: string,
29
+ * diffRunDir?: string,
29
30
  * imageReader?: (runDir: string, imagePath: string) => Promise<string | null>
30
31
  * }} [options]
31
32
  * @returns {Promise<string>}
32
33
  */
33
34
  export async function generateHtmlReport(summary, options = {}) {
34
35
  const enrichedOptions = { ...options };
35
- if (!enrichedOptions.imageReader && (options.baselineRunDir || options.currentRunDir)) {
36
+ if (!enrichedOptions.imageReader && (options.baselineRunDir || options.currentRunDir || options.diffRunDir)) {
36
37
  enrichedOptions.imageReader = defaultImageReader;
37
38
  }
38
39
  return _generateHtmlReport(summary, enrichedOptions);
39
- }
40
+ }
@@ -0,0 +1,147 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {{
5
+ * found: boolean,
6
+ * resolutionStatus: 'found' | 'missing',
7
+ * artifactName: string,
8
+ * headSha?: string,
9
+ * runId?: string,
10
+ * message: string
11
+ * }} BaselineResolution
12
+ */
13
+
14
+ /**
15
+ * Resolve the most recent successful, non-expired SnapDrift baseline artifact.
16
+ *
17
+ * The GitHub API calls are kept here so the standalone resolver and the PR
18
+ * wrapper share the same response validation and pagination behavior.
19
+ *
20
+ * @param {{
21
+ * github: {
22
+ * paginate: Function,
23
+ * rest: { actions: {
24
+ * listWorkflowRuns: Function,
25
+ * listWorkflowRunArtifacts: Function
26
+ * }}
27
+ * },
28
+ * repository: string,
29
+ * workflowId: string,
30
+ * branch: string,
31
+ * artifactName: string
32
+ * }} options
33
+ * @returns {Promise<BaselineResolution>}
34
+ */
35
+ export async function resolveBaselineArtifact({
36
+ github,
37
+ repository,
38
+ workflowId,
39
+ branch,
40
+ artifactName
41
+ }) {
42
+ const { owner, repo } = parseBaselineRepository(repository);
43
+ if (typeof artifactName !== 'string' || artifactName.trim().length === 0) {
44
+ throw new Error('Invalid baseline artifact name. Expected a non-empty name.');
45
+ }
46
+
47
+ // Search the complete successful-run history so an older, still-valid
48
+ // baseline remains discoverable; 100 keeps that intentional search to the
49
+ // fewest API pages GitHub allows.
50
+ const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
51
+ owner,
52
+ repo,
53
+ workflow_id: workflowId,
54
+ branch,
55
+ event: 'push',
56
+ per_page: 100
57
+ });
58
+
59
+ if (!Array.isArray(runs)) {
60
+ throw new Error('GitHub returned an invalid workflow run list.');
61
+ }
62
+
63
+ for (const run of runs) {
64
+ validateWorkflowRun(run);
65
+
66
+ // GitHub returns a null conclusion for queued and in-progress runs. They
67
+ // are valid records and must be ignored before inspecting terminal fields.
68
+ if (run.status !== 'completed') {
69
+ continue;
70
+ }
71
+ if (typeof run.conclusion !== 'string') {
72
+ throw new Error(`GitHub returned a malformed completed workflow run record for run ${run.id}.`);
73
+ }
74
+ if (run.conclusion !== 'success') {
75
+ continue;
76
+ }
77
+
78
+ const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
79
+ owner,
80
+ repo,
81
+ run_id: run.id,
82
+ per_page: 100
83
+ });
84
+ if (!Array.isArray(artifacts)) {
85
+ throw new Error(`GitHub returned a malformed artifact list for workflow run ${run.id}.`);
86
+ }
87
+
88
+ // Only the artifact SnapDrift depends on needs strict field validation.
89
+ // Other artifacts in the same run belong to the consumer workflow and may
90
+ // legitimately have a different shape.
91
+ const namedArtifacts = artifacts.filter((artifact) => (
92
+ artifact && typeof artifact === 'object' && artifact.name === artifactName
93
+ ));
94
+ for (const artifact of namedArtifacts) {
95
+ if (typeof artifact.expired !== 'boolean') {
96
+ throw new Error(`GitHub returned a malformed baseline artifact record for workflow run ${run.id}.`);
97
+ }
98
+ }
99
+
100
+ const match = namedArtifacts.find((artifact) => !artifact.expired);
101
+ if (match) {
102
+ return {
103
+ found: true,
104
+ resolutionStatus: 'found',
105
+ artifactName: match.name,
106
+ headSha: run.head_sha,
107
+ runId: String(run.id),
108
+ message: ''
109
+ };
110
+ }
111
+ }
112
+
113
+ return {
114
+ found: false,
115
+ resolutionStatus: 'missing',
116
+ artifactName,
117
+ message: `No non-expired SnapDrift baseline artifact named ${artifactName} was found.`
118
+ };
119
+ }
120
+
121
+ /**
122
+ * @param {string} repository
123
+ * @returns {{owner: string, repo: string}}
124
+ */
125
+ export function parseBaselineRepository(repository) {
126
+ const value = String(repository || '').trim();
127
+ const parts = value.split('/');
128
+ if (parts.length !== 2 || parts.some((part) => part.trim().length === 0)) {
129
+ throw new Error(`Invalid baseline repository "${value}". Expected owner/name.`);
130
+ }
131
+ return { owner: parts[0].trim(), repo: parts[1].trim() };
132
+ }
133
+
134
+ /**
135
+ * @param {unknown} run
136
+ * @returns {asserts run is {id: number, status: string, head_sha: string, conclusion?: unknown}}
137
+ */
138
+ function validateWorkflowRun(run) {
139
+ if (!run || typeof run !== 'object') {
140
+ throw new Error('GitHub returned a malformed workflow run record.');
141
+ }
142
+ const record = /** @type {Record<string, unknown>} */ (run);
143
+ if (typeof record.id !== 'number' || !Number.isInteger(record.id) || record.id <= 0 ||
144
+ typeof record.status !== 'string' || typeof record.head_sha !== 'string' || record.head_sha.length === 0) {
145
+ throw new Error('GitHub returned a malformed workflow run record.');
146
+ }
147
+ }
@@ -0,0 +1,40 @@
1
+ // @ts-check
2
+
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * @typedef {{
7
+ * runDir: string,
8
+ * resultsFile: string,
9
+ * manifestFile: string,
10
+ * screenshotsDir: string,
11
+ * }} BaselinePaths
12
+ */
13
+
14
+ /**
15
+ * @typedef {{
16
+ * workspace: string,
17
+ * downloadPath?: string,
18
+ * }} ResolveBaselinePathsParams
19
+ */
20
+
21
+ const DEFAULT_DOWNLOAD_PATH = 'snapdrift-baseline-download';
22
+
23
+ /**
24
+ * Resolve the baseline download paths for the resolve-baseline composite action.
25
+ *
26
+ * `downloadPath` may be absolute (used directly) or relative to the Actions
27
+ * workspace (prefixed with `workspace`). Returns the run dir plus the results,
28
+ * manifest, and screenshots paths derived from it.
29
+ *
30
+ * @param {ResolveBaselinePathsParams} params
31
+ * @returns {BaselinePaths}
32
+ */
33
+ export function resolveBaselinePaths({ workspace, downloadPath }) {
34
+ const rel = downloadPath || DEFAULT_DOWNLOAD_PATH;
35
+ const runDir = path.isAbsolute(rel) ? rel : path.join(workspace, rel);
36
+ const resultsFile = path.join(runDir, 'results.json');
37
+ const manifestFile = path.join(runDir, 'manifest.json');
38
+ const screenshotsDir = path.join(path.dirname(manifestFile), 'screenshots');
39
+ return { runDir, resultsFile, manifestFile, screenshotsDir };
40
+ }