snapdrift 0.8.1 → 0.9.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 +61 -0
- package/README.md +12 -4
- package/action.yml +16 -9
- package/actions/baseline/action.yml +68 -47
- package/actions/pr-diff/action.yml +208 -107
- package/actions/resolve-baseline/action.yml +36 -50
- package/actions/scope/action.yml +24 -1
- package/bin/snapdrift.mjs +6 -3
- package/docs/contracts.md +141 -9
- package/docs/integration-guide.md +54 -13
- package/docs/local-cli.md +6 -5
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +25 -13
- package/lib/cli.mjs +243 -168
- package/lib/init-from-action.mjs +24 -13
- package/lib/outage-policy.mjs +278 -0
- package/lib/provider.mjs +20 -2
- package/lib/resolve-baseline-artifact.mjs +147 -0
- package/lib/resolve-baseline-paths.mjs +40 -0
- package/lib/snap-provider.mjs +1246 -303
- package/package.json +15 -9
- package/types/visual-diff-types.d.ts +28 -0
|
@@ -71,6 +71,9 @@ outputs:
|
|
|
71
71
|
baseline-found:
|
|
72
72
|
description: Whether a usable baseline was found.
|
|
73
73
|
value: ${{ steps.finalize.outputs.baseline_found }}
|
|
74
|
+
baseline-resolution-status:
|
|
75
|
+
description: 'Baseline lookup status: found, missing, or error.'
|
|
76
|
+
value: ${{ steps.finalize.outputs.baseline_resolution_status }}
|
|
74
77
|
status:
|
|
75
78
|
description: Final SnapDrift status.
|
|
76
79
|
value: ${{ steps.finalize.outputs.status }}
|
|
@@ -182,10 +185,33 @@ runs:
|
|
|
182
185
|
per_page: 100
|
|
183
186
|
});
|
|
184
187
|
|
|
188
|
+
if (!Array.isArray(files)) {
|
|
189
|
+
throw new Error('Malformed GitHub changed-file response: expected an array of file records.');
|
|
190
|
+
}
|
|
191
|
+
for (const [index, file] of files.entries()) {
|
|
192
|
+
if (!file || typeof file !== 'object' || typeof file.filename !== 'string' || file.filename.length === 0) {
|
|
193
|
+
throw new Error(`Malformed GitHub changed-file response: file record ${index} must include a non-empty string filename.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (files.length >= 3000) {
|
|
197
|
+
core.warning('GitHub returned the maximum 3000 changed-file records; the list may be truncated, so running all configured captures.');
|
|
198
|
+
setOutputs({
|
|
199
|
+
shouldRun: true,
|
|
200
|
+
reason: 'changed_files_truncated',
|
|
201
|
+
selectedRouteIds: allRouteIds
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const changedFiles = [...new Set(files.flatMap((file) => [
|
|
206
|
+
file.filename,
|
|
207
|
+
...(file.status === 'renamed' && typeof file.previous_filename === 'string' && file.previous_filename
|
|
208
|
+
? [file.previous_filename]
|
|
209
|
+
: [])
|
|
210
|
+
]))];
|
|
185
211
|
setOutputs(
|
|
186
212
|
selectRoutesForChangedFiles(
|
|
187
213
|
config,
|
|
188
|
-
|
|
214
|
+
changedFiles
|
|
189
215
|
)
|
|
190
216
|
);
|
|
191
217
|
} catch (error) {
|
|
@@ -212,61 +238,40 @@ runs:
|
|
|
212
238
|
const { pathToFileURL } = require('url');
|
|
213
239
|
|
|
214
240
|
const repository = process.env.INPUT_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;
|
|
215
|
-
const [owner, repo] = repository.split('/');
|
|
216
241
|
const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
242
|
+
const resolverModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/resolve-baseline-artifact.mjs');
|
|
217
243
|
const { loadSnapdriftConfig } = await import(pathToFileURL(modulePath));
|
|
218
|
-
const {
|
|
219
|
-
|
|
244
|
+
const { resolveBaselineArtifact } = await import(pathToFileURL(resolverModulePath));
|
|
245
|
+
let artifactName = '';
|
|
246
|
+
|
|
247
|
+
const setLookupError = (error) => {
|
|
248
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
249
|
+
const message = `Unable to resolve the SnapDrift baseline artifact: ${detail}`;
|
|
250
|
+
core.setOutput('found', 'false');
|
|
251
|
+
core.setOutput('resolution_status', 'error');
|
|
252
|
+
core.setOutput('artifact_name', artifactName);
|
|
253
|
+
core.setOutput('message', message);
|
|
254
|
+
core.warning(message);
|
|
255
|
+
};
|
|
220
256
|
|
|
221
257
|
try {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
258
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
259
|
+
artifactName = config.baselineArtifactName;
|
|
260
|
+
const result = await resolveBaselineArtifact({
|
|
261
|
+
github,
|
|
262
|
+
repository,
|
|
263
|
+
workflowId: process.env.INPUT_WORKFLOW_ID || 'ci.yml',
|
|
226
264
|
branch: process.env.INPUT_BRANCH || 'main',
|
|
227
|
-
|
|
228
|
-
per_page: 20
|
|
265
|
+
artifactName
|
|
229
266
|
});
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
|
240
|
-
owner,
|
|
241
|
-
repo,
|
|
242
|
-
run_id: run.id,
|
|
243
|
-
per_page: 100
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
const match = artifacts.data.artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
|
247
|
-
if (match) {
|
|
248
|
-
selectedRun = run;
|
|
249
|
-
selectedArtifact = match;
|
|
250
|
-
break;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (!selectedRun || !selectedArtifact) {
|
|
255
|
-
core.setOutput('found', 'false');
|
|
256
|
-
core.setOutput('artifact_name', artifactName);
|
|
257
|
-
core.setOutput('message', `No non-expired SnapDrift baseline artifact named ${artifactName} was found.`);
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
core.setOutput('found', 'true');
|
|
262
|
-
core.setOutput('artifact_name', selectedArtifact.name);
|
|
263
|
-
core.setOutput('head_sha', selectedRun.head_sha);
|
|
264
|
-
core.setOutput('run_id', String(selectedRun.id));
|
|
265
|
-
core.setOutput('message', '');
|
|
267
|
+
core.setOutput('found', String(result.found));
|
|
268
|
+
core.setOutput('resolution_status', result.resolutionStatus);
|
|
269
|
+
core.setOutput('artifact_name', result.artifactName);
|
|
270
|
+
core.setOutput('head_sha', result.headSha || '');
|
|
271
|
+
core.setOutput('run_id', result.runId || '');
|
|
272
|
+
core.setOutput('message', result.message);
|
|
266
273
|
} catch (error) {
|
|
267
|
-
|
|
268
|
-
core.setOutput('artifact_name', artifactName);
|
|
269
|
-
core.setOutput('message', `Unable to resolve the SnapDrift baseline artifact: ${error.message}`);
|
|
274
|
+
setLookupError(error);
|
|
270
275
|
}
|
|
271
276
|
|
|
272
277
|
- if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found == 'true'
|
|
@@ -320,6 +325,16 @@ runs:
|
|
|
320
325
|
await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
|
|
321
326
|
EOF
|
|
322
327
|
|
|
328
|
+
- id: baseline_resolution_guard
|
|
329
|
+
if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.resolution_status == 'error' && steps.config.outputs.provider != 'snap'
|
|
330
|
+
shell: bash
|
|
331
|
+
env:
|
|
332
|
+
BASELINE_MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
333
|
+
run: |
|
|
334
|
+
set -euo pipefail
|
|
335
|
+
printf 'SnapDrift baseline lookup failed. %s\n' "$BASELINE_MESSAGE" >&2
|
|
336
|
+
exit 1
|
|
337
|
+
|
|
323
338
|
- name: Install Playwright Chromium
|
|
324
339
|
if: steps.scope.outputs.should_run == 'true' && (steps.config.outputs.provider != 'snap' || steps.config.outputs.on_unavailable == 'fallback-local' || steps.config.outputs.snap_local_capture == 'true')
|
|
325
340
|
shell: bash
|
|
@@ -332,6 +347,8 @@ runs:
|
|
|
332
347
|
env:
|
|
333
348
|
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
334
349
|
ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
|
|
350
|
+
BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status }}
|
|
351
|
+
BASELINE_RESOLUTION_MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
335
352
|
run: |
|
|
336
353
|
node --input-type=module <<'EOF'
|
|
337
354
|
import fs from 'node:fs/promises';
|
|
@@ -340,38 +357,56 @@ runs:
|
|
|
340
357
|
|
|
341
358
|
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
342
359
|
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
343
|
-
const
|
|
360
|
+
const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
|
|
361
|
+
const summaryModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
|
|
362
|
+
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
344
363
|
const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
|
|
364
|
+
const { captureWithPolicy, SNAP_UNAVAILABLE_REASON } = await import(pathToFileURL(policyModulePath));
|
|
365
|
+
const { writeDriftSummary } = await import(pathToFileURL(summaryModulePath));
|
|
345
366
|
|
|
346
367
|
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
368
|
+
const providerName = config.provider || 'local';
|
|
369
|
+
const routeIds = splitCommaList(process.env.ROUTE_IDS);
|
|
370
|
+
|
|
371
|
+
const captured = await captureWithPolicy({
|
|
372
|
+
provider: createProvider(providerName, config),
|
|
373
|
+
providerName,
|
|
374
|
+
config,
|
|
375
|
+
captureOptions: {
|
|
352
376
|
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
353
|
-
routeIds
|
|
377
|
+
routeIds
|
|
378
|
+
},
|
|
379
|
+
baselineResolutionStatus: process.env.BASELINE_RESOLUTION_STATUS,
|
|
380
|
+
baselineResolutionMessage: process.env.BASELINE_RESOLUTION_MESSAGE,
|
|
381
|
+
onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable — skipping visual regression (warn-and-skip).\n'),
|
|
382
|
+
onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable — falling back to local provider.\n')
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (captured.outcome === 'skipped') {
|
|
386
|
+
// warn-and-skip is documented as "write a skipped summary and exit 0".
|
|
387
|
+
// Without the summary the report step falls through to its
|
|
388
|
+
// "Capture Failed" body, which is not what happened. See #125.
|
|
389
|
+
const skipped = await writeDriftSummary({
|
|
390
|
+
reason: SNAP_UNAVAILABLE_REASON,
|
|
391
|
+
selectedRouteIds: routeIds
|
|
354
392
|
});
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const { createProvider: cp2 } = await import(pathToFileURL(providerModulePath));
|
|
364
|
-
result = await cp2('local', config).capture({
|
|
365
|
-
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
366
|
-
routeIds: splitCommaList(process.env.ROUTE_IDS)
|
|
367
|
-
});
|
|
368
|
-
} else {
|
|
369
|
-
throw err;
|
|
370
|
-
}
|
|
393
|
+
const skipOutputs = [
|
|
394
|
+
`provider=skipped`,
|
|
395
|
+
`summary_path=${skipped.summaryPath}`,
|
|
396
|
+
`markdown_path=${skipped.markdownPath}`,
|
|
397
|
+
`status=${skipped.summary.status || ''}`
|
|
398
|
+
].join('\n') + '\n';
|
|
399
|
+
await fs.appendFile(process.env.GITHUB_OUTPUT, skipOutputs);
|
|
400
|
+
process.exit(0);
|
|
371
401
|
}
|
|
372
402
|
|
|
403
|
+
const result = captured.result;
|
|
373
404
|
const outputs = [
|
|
374
|
-
|
|
405
|
+
// The *effective* provider, not the configured one: after a
|
|
406
|
+
// fallback-local capture the compare step must not rebuild
|
|
407
|
+
// SnapProvider around local results that carry no run id.
|
|
408
|
+
`provider=${captured.providerName}`,
|
|
409
|
+
`local_screenshots=${captured.localScreenshots ? 'true' : 'false'}`,
|
|
375
410
|
`results_file=${result.resultsPath}`,
|
|
376
411
|
`manifest_file=${result.manifestPath}`,
|
|
377
412
|
`screenshots_root=${result.screenshotsRoot}`,
|
|
@@ -394,6 +429,12 @@ runs:
|
|
|
394
429
|
BASELINE_SOURCE_SHA: ${{ steps.baseline.outputs.head_sha }}
|
|
395
430
|
CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
|
|
396
431
|
CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
|
|
432
|
+
CURRENT_SCREENSHOTS_ROOT: ${{ steps.capture.outputs.screenshots_root }}
|
|
433
|
+
CAPTURE_PROVIDER: ${{ steps.capture.outputs.provider }}
|
|
434
|
+
LOCAL_SCREENSHOTS: ${{ steps.capture.outputs.local_screenshots }}
|
|
435
|
+
BASELINE_FOUND: ${{ steps.baseline.outputs.found }}
|
|
436
|
+
BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status }}
|
|
437
|
+
BASELINE_RESOLUTION_MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
397
438
|
run: |
|
|
398
439
|
node --input-type=module <<'EOF'
|
|
399
440
|
import fs from 'node:fs/promises';
|
|
@@ -402,45 +443,83 @@ runs:
|
|
|
402
443
|
|
|
403
444
|
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
404
445
|
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
405
|
-
const
|
|
446
|
+
const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
|
|
447
|
+
const summaryModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
|
|
448
|
+
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
406
449
|
const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
|
|
450
|
+
const { diffWithPolicy, MISSING_BASELINE_REASON, SNAP_UNAVAILABLE_REASON } = await import(pathToFileURL(policyModulePath));
|
|
451
|
+
const { writeDriftSummary } = await import(pathToFileURL(summaryModulePath));
|
|
407
452
|
|
|
408
453
|
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
409
|
-
|
|
454
|
+
// The capture step reports the provider that actually ran, which is
|
|
455
|
+
// "local" once a fallback-local capture has happened.
|
|
456
|
+
const providerName = process.env.CAPTURE_PROVIDER || config.provider || 'local';
|
|
457
|
+
const routeIds = splitCommaList(process.env.ROUTE_IDS);
|
|
410
458
|
|
|
411
459
|
const resolvedOutDir = path.resolve('qa-artifacts/snapdrift/drift/current');
|
|
412
460
|
const resolvedSummaryPath = path.join(resolvedOutDir, 'summary.json');
|
|
413
461
|
const resolvedMarkdownPath = path.join(resolvedOutDir, 'summary.md');
|
|
414
462
|
|
|
463
|
+
const captureOptions = {
|
|
464
|
+
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
465
|
+
routeIds
|
|
466
|
+
};
|
|
467
|
+
|
|
415
468
|
const diffOptions = {
|
|
416
469
|
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
417
470
|
baselineResultsPath: process.env.BASELINE_RESULTS_PATH || undefined,
|
|
418
471
|
baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
|
|
419
472
|
baselineRunDir: process.env.BASELINE_RUN_DIR || undefined,
|
|
420
|
-
routeIds
|
|
473
|
+
routeIds,
|
|
421
474
|
baselineArtifactName: process.env.BASELINE_ARTIFACT_NAME || undefined,
|
|
422
475
|
baselineSourceSha: process.env.BASELINE_SOURCE_SHA || undefined,
|
|
423
476
|
currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
|
|
424
|
-
currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined
|
|
477
|
+
currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
|
|
478
|
+
currentRunDir: process.env.CURRENT_SCREENSHOTS_ROOT || undefined
|
|
425
479
|
};
|
|
426
480
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
481
|
+
const diffed = await diffWithPolicy({
|
|
482
|
+
provider: createProvider(providerName, config),
|
|
483
|
+
providerName,
|
|
484
|
+
diffOptions,
|
|
485
|
+
captureOptions,
|
|
486
|
+
localScreenshots: process.env.LOCAL_SCREENSHOTS !== 'false',
|
|
487
|
+
baselineAvailable: process.env.BASELINE_FOUND === 'true',
|
|
488
|
+
baselineResolutionStatus: process.env.BASELINE_RESOLUTION_STATUS,
|
|
489
|
+
baselineResolutionMessage: process.env.BASELINE_RESOLUTION_MESSAGE,
|
|
490
|
+
onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — skipping (warn-and-skip).\n'),
|
|
491
|
+
onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — falling back to local provider.\n'),
|
|
492
|
+
onRecapture: () => process.stderr.write('[SnapDrift] The current capture was rendered by Snap, so it has no local screenshots — recapturing locally before diffing.\n'),
|
|
493
|
+
onBaselineUnavailable: () => {
|
|
494
|
+
if (process.env.BASELINE_RESOLUTION_STATUS === 'error') {
|
|
495
|
+
process.stderr.write(`[SnapDrift] ${process.env.BASELINE_RESOLUTION_MESSAGE || 'The GitHub baseline lookup failed before local diff fallback.'}\n`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
process.stderr.write('[SnapDrift] No baseline artifact is available, so the local diff cannot run.\n');
|
|
441
499
|
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Every non-throwing outcome writes a summary. A wrapper run that ends
|
|
503
|
+
// without one makes the report step claim "Capture Failed". See #125.
|
|
504
|
+
if (diffed.outcome !== 'diffed') {
|
|
505
|
+
const skipped = await writeDriftSummary({
|
|
506
|
+
reason: diffed.outcome === 'skipped' ? SNAP_UNAVAILABLE_REASON : MISSING_BASELINE_REASON,
|
|
507
|
+
baselineAvailable: diffed.outcome === 'skipped' ? undefined : false,
|
|
508
|
+
currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
|
|
509
|
+
selectedRouteIds: routeIds,
|
|
510
|
+
outDir: resolvedOutDir
|
|
511
|
+
});
|
|
512
|
+
const skipOutputs = [
|
|
513
|
+
`skipped=true`,
|
|
514
|
+
`summary_path=${skipped.summaryPath}`,
|
|
515
|
+
`markdown_path=${skipped.markdownPath}`,
|
|
516
|
+
`status=${skipped.summary.status || ''}`
|
|
517
|
+
].join('\n') + '\n';
|
|
518
|
+
await fs.appendFile(process.env.GITHUB_OUTPUT, skipOutputs);
|
|
519
|
+
process.exit(0);
|
|
442
520
|
}
|
|
443
|
-
|
|
521
|
+
|
|
522
|
+
const { summary, markdown } = diffed.result;
|
|
444
523
|
|
|
445
524
|
await fs.mkdir(resolvedOutDir, { recursive: true });
|
|
446
525
|
await Promise.all([
|
|
@@ -449,9 +528,16 @@ runs:
|
|
|
449
528
|
]);
|
|
450
529
|
|
|
451
530
|
const outputs = [
|
|
531
|
+
`skipped=false`,
|
|
452
532
|
`summary_path=${resolvedSummaryPath}`,
|
|
453
533
|
`markdown_path=${resolvedMarkdownPath}`,
|
|
454
|
-
`status=${summary.status || ''}
|
|
534
|
+
`status=${summary.status || ''}`,
|
|
535
|
+
// A fallback-local diff of a remote Snap capture recaptures on the
|
|
536
|
+
// runner; the staged bundle has to carry those PNGs, not the empty
|
|
537
|
+
// placeholder paths the Snap capture step emitted.
|
|
538
|
+
`current_results_file=${diffed.recapture?.resultsPath || ''}`,
|
|
539
|
+
`current_manifest_file=${diffed.recapture?.manifestPath || ''}`,
|
|
540
|
+
`current_screenshots_root=${diffed.recapture?.screenshotsRoot || ''}`
|
|
455
541
|
].join('\n') + '\n';
|
|
456
542
|
|
|
457
543
|
await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
|
|
@@ -486,7 +572,7 @@ runs:
|
|
|
486
572
|
EOF
|
|
487
573
|
|
|
488
574
|
- id: skipped_baseline
|
|
489
|
-
if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.
|
|
575
|
+
if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.resolution_status == 'missing' && steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
|
|
490
576
|
shell: bash
|
|
491
577
|
env:
|
|
492
578
|
MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
@@ -521,14 +607,14 @@ runs:
|
|
|
521
607
|
- id: stage
|
|
522
608
|
shell: bash
|
|
523
609
|
env:
|
|
524
|
-
SUMMARY_JSON_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
525
|
-
SUMMARY_MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
|
|
610
|
+
SUMMARY_JSON_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
611
|
+
SUMMARY_MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
|
|
526
612
|
BASELINE_RESULTS_PATH: ${{ steps.baseline_paths.outputs.results_file }}
|
|
527
|
-
CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
|
|
613
|
+
CURRENT_RESULTS_PATH: ${{ steps.compare.outputs.current_results_file || steps.capture.outputs.results_file }}
|
|
528
614
|
BASELINE_MANIFEST_PATH: ${{ steps.baseline_paths.outputs.manifest_file }}
|
|
529
|
-
CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
|
|
615
|
+
CURRENT_MANIFEST_PATH: ${{ steps.compare.outputs.current_manifest_file || steps.capture.outputs.manifest_file }}
|
|
530
616
|
BASELINE_SCREENSHOTS_DIR: ${{ steps.baseline_paths.outputs.screenshots_dir }}
|
|
531
|
-
|
|
617
|
+
CURRENT_SCREENSHOTS_ROOT: ${{ steps.compare.outputs.current_screenshots_root || steps.capture.outputs.screenshots_root }}
|
|
532
618
|
run: |
|
|
533
619
|
node --input-type=module <<'EOF'
|
|
534
620
|
import fs from 'node:fs/promises';
|
|
@@ -538,6 +624,8 @@ runs:
|
|
|
538
624
|
const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/stage-artifacts.mjs');
|
|
539
625
|
const { stageArtifacts } = await import(pathToFileURL(modulePath));
|
|
540
626
|
|
|
627
|
+
const currentScreenshotsRoot = process.env.CURRENT_SCREENSHOTS_ROOT || '';
|
|
628
|
+
|
|
541
629
|
const result = await stageArtifacts({
|
|
542
630
|
artifactType: 'diff',
|
|
543
631
|
summaryJsonPath: process.env.SUMMARY_JSON_PATH || undefined,
|
|
@@ -547,7 +635,7 @@ runs:
|
|
|
547
635
|
baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
|
|
548
636
|
currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
|
|
549
637
|
baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
|
|
550
|
-
currentScreenshotsDir:
|
|
638
|
+
currentScreenshotsDir: currentScreenshotsRoot ? path.join(currentScreenshotsRoot, 'screenshots') : undefined
|
|
551
639
|
});
|
|
552
640
|
|
|
553
641
|
await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
|
|
@@ -563,7 +651,7 @@ runs:
|
|
|
563
651
|
- if: always()
|
|
564
652
|
shell: bash
|
|
565
653
|
env:
|
|
566
|
-
SUMMARY_FILE: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
|
|
654
|
+
SUMMARY_FILE: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
|
|
567
655
|
run: |
|
|
568
656
|
if [ -f "$SUMMARY_FILE" ]; then
|
|
569
657
|
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
|
|
@@ -573,12 +661,14 @@ runs:
|
|
|
573
661
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
|
574
662
|
env:
|
|
575
663
|
PR_NUMBER: ${{ steps.context.outputs.pr_number }}
|
|
576
|
-
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
664
|
+
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
577
665
|
ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
|
|
578
666
|
RUN_URL: ${{ steps.context.outputs.run_url }}
|
|
579
667
|
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
580
668
|
MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
|
|
581
669
|
MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
|
|
670
|
+
BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status }}
|
|
671
|
+
BASELINE_RESOLUTION_MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
582
672
|
with:
|
|
583
673
|
github-token: ${{ inputs.github-token }}
|
|
584
674
|
script: |
|
|
@@ -590,6 +680,8 @@ runs:
|
|
|
590
680
|
const PR_COMMENT_MARKERS = [PR_COMMENT_MARKER];
|
|
591
681
|
const iconUrl = 'https://raw.githubusercontent.com/ranacseruet/snapdrift/main/assets/snapdrift-logo-icon.png';
|
|
592
682
|
const repoUrl = 'https://github.com/ranacseruet/snapdrift';
|
|
683
|
+
const prCommentModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
|
|
684
|
+
const { escapeMarkdown } = await import(pathToFileURL(prCommentModulePath));
|
|
593
685
|
|
|
594
686
|
const summaryPath = process.env.SUMMARY_PATH;
|
|
595
687
|
const issueNumber = Number(process.env.PR_NUMBER || context.payload.pull_request?.number || context.issue?.number || '');
|
|
@@ -600,7 +692,12 @@ runs:
|
|
|
600
692
|
|
|
601
693
|
if (!summaryPath || !fs.existsSync(summaryPath)) {
|
|
602
694
|
const runUrl = process.env.RUN_URL || '';
|
|
603
|
-
const
|
|
695
|
+
const lookupFailure = process.env.BASELINE_RESOLUTION_STATUS === 'error';
|
|
696
|
+
const heading = lookupFailure ? 'Baseline Lookup Failed' : 'Capture Failed';
|
|
697
|
+
const detail = escapeMarkdown(lookupFailure
|
|
698
|
+
? process.env.BASELINE_RESOLUTION_MESSAGE || 'The GitHub baseline lookup failed before comparison could run.'
|
|
699
|
+
: 'SnapDrift did not produce a summary. This usually means capture failed before the report could be assembled.');
|
|
700
|
+
const body = `${PR_COMMENT_MARKER}\n<img src="${iconUrl}" alt="SnapDrift" width="20" height="20" />\n\n### ⚠️ SnapDrift Report — ${heading}\n\n${detail}\n\n[View run](${runUrl}) for details.\n\n<div align="right"><sub>Powered by <a href="${repoUrl}">SnapDrift</a></sub></div>`;
|
|
604
701
|
await github.rest.issues.createComment({
|
|
605
702
|
owner: context.repo.owner,
|
|
606
703
|
repo: context.repo.repo,
|
|
@@ -674,7 +771,9 @@ runs:
|
|
|
674
771
|
});
|
|
675
772
|
}
|
|
676
773
|
|
|
677
|
-
|
|
774
|
+
# A skipped compare writes a summary with no diff counters, so enforcement
|
|
775
|
+
# has nothing to evaluate — `warn-and-skip` means "do not fail my build".
|
|
776
|
+
- if: steps.scope.outputs.should_run == 'true' && steps.capture.outputs.provider != 'skipped' && steps.compare.outputs.skipped != 'true' && (steps.baseline.outputs.found == 'true' || steps.capture.outputs.provider == 'snap')
|
|
678
777
|
shell: bash
|
|
679
778
|
run: |
|
|
680
779
|
node --input-type=module <<'EOF'
|
|
@@ -700,9 +799,10 @@ runs:
|
|
|
700
799
|
SCOPE_REASON: ${{ steps.scope.outputs.reason }}
|
|
701
800
|
SELECTED_ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
|
|
702
801
|
BASELINE_FOUND: ${{ steps.baseline.outputs.found || 'false' }}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
802
|
+
BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status || '' }}
|
|
803
|
+
STATUS: ${{ steps.compare.outputs.status || steps.capture.outputs.status || steps.skipped_baseline.outputs.status || steps.skipped_scope.outputs.status || '' }}
|
|
804
|
+
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path || '' }}
|
|
805
|
+
MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path || '' }}
|
|
706
806
|
BUNDLE_DIR: ${{ steps.stage.outputs.bundle_dir }}
|
|
707
807
|
run: |
|
|
708
808
|
{
|
|
@@ -710,6 +810,7 @@ runs:
|
|
|
710
810
|
echo "scope_reason=${SCOPE_REASON:-}"
|
|
711
811
|
echo "selected_route_ids=${SELECTED_ROUTE_IDS:-}"
|
|
712
812
|
echo "baseline_found=${BASELINE_FOUND:-false}"
|
|
813
|
+
echo "baseline_resolution_status=${BASELINE_RESOLUTION_STATUS:-}"
|
|
713
814
|
echo "status=${STATUS:-}"
|
|
714
815
|
echo "summary_path=${SUMMARY_PATH:-}"
|
|
715
816
|
echo "markdown_path=${MARKDOWN_PATH:-}"
|
|
@@ -38,6 +38,9 @@ outputs:
|
|
|
38
38
|
found:
|
|
39
39
|
description: Whether a non-expired baseline was found.
|
|
40
40
|
value: ${{ steps.resolve.outputs.found }}
|
|
41
|
+
resolution-status:
|
|
42
|
+
description: 'Baseline lookup status: found, missing, or error.'
|
|
43
|
+
value: ${{ steps.resolve.outputs.resolution_status }}
|
|
41
44
|
artifact-name:
|
|
42
45
|
description: Resolved artifact name.
|
|
43
46
|
value: ${{ steps.resolve.outputs.artifact_name }}
|
|
@@ -48,7 +51,7 @@ outputs:
|
|
|
48
51
|
description: Workflow run id that produced the baseline.
|
|
49
52
|
value: ${{ steps.resolve.outputs.run_id }}
|
|
50
53
|
message:
|
|
51
|
-
description:
|
|
54
|
+
description: Resolution message for a missing or failed lookup.
|
|
52
55
|
value: ${{ steps.resolve.outputs.message }}
|
|
53
56
|
results-file:
|
|
54
57
|
description: Path to the downloaded baseline results JSON.
|
|
@@ -81,61 +84,40 @@ runs:
|
|
|
81
84
|
const { pathToFileURL } = require('url');
|
|
82
85
|
|
|
83
86
|
const repository = process.env.INPUT_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;
|
|
84
|
-
const [owner, repo] = repository.split('/');
|
|
85
87
|
const modulePath = path.resolve(process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH, '..', '..', 'lib', 'snapdrift-config.mjs');
|
|
88
|
+
const resolverModulePath = path.resolve(process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH, '..', '..', 'lib', 'resolve-baseline-artifact.mjs');
|
|
86
89
|
const { loadSnapdriftConfig } = await import(pathToFileURL(modulePath));
|
|
87
|
-
const {
|
|
88
|
-
|
|
90
|
+
const { resolveBaselineArtifact } = await import(pathToFileURL(resolverModulePath));
|
|
91
|
+
let artifactName = process.env.INPUT_ARTIFACT_NAME || '';
|
|
92
|
+
|
|
93
|
+
const setLookupError = (error) => {
|
|
94
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
95
|
+
const message = `Unable to resolve the SnapDrift baseline artifact: ${detail}`;
|
|
96
|
+
core.setOutput('found', 'false');
|
|
97
|
+
core.setOutput('resolution_status', 'error');
|
|
98
|
+
core.setOutput('artifact_name', artifactName);
|
|
99
|
+
core.setOutput('message', message);
|
|
100
|
+
core.setFailed(message);
|
|
101
|
+
};
|
|
89
102
|
|
|
90
103
|
try {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
105
|
+
artifactName = process.env.INPUT_ARTIFACT_NAME || config.baselineArtifactName;
|
|
106
|
+
const result = await resolveBaselineArtifact({
|
|
107
|
+
github,
|
|
108
|
+
repository,
|
|
109
|
+
workflowId: process.env.INPUT_WORKFLOW_ID || 'ci.yml',
|
|
95
110
|
branch: process.env.INPUT_BRANCH || 'main',
|
|
96
|
-
|
|
97
|
-
per_page: 20
|
|
111
|
+
artifactName
|
|
98
112
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
|
109
|
-
owner,
|
|
110
|
-
repo,
|
|
111
|
-
run_id: run.id,
|
|
112
|
-
per_page: 100
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
const match = artifacts.data.artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
|
116
|
-
if (match) {
|
|
117
|
-
selectedRun = run;
|
|
118
|
-
selectedArtifact = match;
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (!selectedRun || !selectedArtifact) {
|
|
124
|
-
core.setOutput('found', 'false');
|
|
125
|
-
core.setOutput('artifact_name', artifactName);
|
|
126
|
-
core.setOutput('message', `No non-expired SnapDrift baseline artifact named ${artifactName} was found.`);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
core.setOutput('found', 'true');
|
|
131
|
-
core.setOutput('artifact_name', selectedArtifact.name);
|
|
132
|
-
core.setOutput('head_sha', selectedRun.head_sha);
|
|
133
|
-
core.setOutput('run_id', String(selectedRun.id));
|
|
134
|
-
core.setOutput('message', '');
|
|
113
|
+
core.setOutput('found', String(result.found));
|
|
114
|
+
core.setOutput('resolution_status', result.resolutionStatus);
|
|
115
|
+
core.setOutput('artifact_name', result.artifactName);
|
|
116
|
+
core.setOutput('head_sha', result.headSha || '');
|
|
117
|
+
core.setOutput('run_id', result.runId || '');
|
|
118
|
+
core.setOutput('message', result.message);
|
|
135
119
|
} catch (error) {
|
|
136
|
-
|
|
137
|
-
core.setOutput('artifact_name', artifactName);
|
|
138
|
-
core.setOutput('message', `Unable to resolve the SnapDrift baseline artifact: ${error.message}`);
|
|
120
|
+
setLookupError(error);
|
|
139
121
|
}
|
|
140
122
|
|
|
141
123
|
- if: steps.resolve.outputs.found == 'true'
|
|
@@ -164,6 +146,10 @@ runs:
|
|
|
164
146
|
{
|
|
165
147
|
echo "results_file=$RESULTS_FILE"
|
|
166
148
|
echo "manifest_file=$MANIFEST_FILE"
|
|
167
|
-
|
|
149
|
+
case "$DOWNLOAD_PATH" in
|
|
150
|
+
/*) RUN_DIR="$DOWNLOAD_PATH" ;;
|
|
151
|
+
*) RUN_DIR="${GITHUB_WORKSPACE}/${DOWNLOAD_PATH}" ;;
|
|
152
|
+
esac
|
|
153
|
+
echo "run_dir=$RUN_DIR"
|
|
168
154
|
echo "screenshots_dir=$(dirname "$MANIFEST_FILE")/screenshots"
|
|
169
155
|
} >> "$GITHUB_OUTPUT"
|