tuffgal 0.1.0-alpha.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/bin/tuffgal.mjs +2 -0
  4. package/package.json +70 -0
  5. package/src/.gitkeep +0 -0
  6. package/src/cli.ts +158 -0
  7. package/src/commands/init.ts +140 -0
  8. package/src/commands/supervise.ts +267 -0
  9. package/src/config.ts +222 -0
  10. package/src/coverage/flows.ts +90 -0
  11. package/src/coverage/screens.ts +52 -0
  12. package/src/index.ts +28 -0
  13. package/src/reporter/assets/report.css +510 -0
  14. package/src/reporter/assets/report.js +45 -0
  15. package/src/reporter/template.ts +355 -0
  16. package/src/reporter/writeReport.ts +37 -0
  17. package/src/runner/approve.ts +65 -0
  18. package/src/runner/bridges/database.ts +34 -0
  19. package/src/runner/bridges/devServers.ts +174 -0
  20. package/src/runner/coverage.ts +76 -0
  21. package/src/runner/interpolate.ts +36 -0
  22. package/src/runner/resolveLocator.ts +47 -0
  23. package/src/runner/run.ts +177 -0
  24. package/src/runner/runAction.ts +422 -0
  25. package/src/runner/runStory.ts +195 -0
  26. package/src/runner/scheduler.ts +223 -0
  27. package/src/runner/steps/click.ts +16 -0
  28. package/src/runner/steps/input.ts +17 -0
  29. package/src/runner/steps/intercept.ts +28 -0
  30. package/src/runner/steps/navigate.ts +14 -0
  31. package/src/runner/steps/read.ts +20 -0
  32. package/src/runner/steps/scroll.ts +12 -0
  33. package/src/runner/steps/type.ts +11 -0
  34. package/src/runner/steps/wait.ts +5 -0
  35. package/src/runner/steps/waitFor.ts +16 -0
  36. package/src/schema/action.ts +176 -0
  37. package/src/schema/load.ts +94 -0
  38. package/src/schema/result.ts +83 -0
  39. package/src/schema/story.ts +58 -0
  40. package/src/screenshots/baselineStore.ts +114 -0
  41. package/src/screenshots/capture.ts +19 -0
  42. package/src/screenshots/diff.ts +101 -0
@@ -0,0 +1,422 @@
1
+ import type { Locator, Page } from 'playwright';
2
+ import type { Action, Hint, Step } from '../schema/action.ts';
3
+ import type { ActionResult, ActionStatus } from '../schema/result.ts';
4
+ import type { ResolvedConfig } from '../config.ts';
5
+ import { capturePage } from '../screenshots/capture.ts';
6
+ import { diffPngs, ScreenshotSizeMismatchError } from '../screenshots/diff.ts';
7
+ import {
8
+ deleteIfExists,
9
+ pathsFor,
10
+ readBaseline,
11
+ readJsonBaseline,
12
+ writePng,
13
+ writeText,
14
+ } from '../screenshots/baselineStore.ts';
15
+ import { interpolate, interpolateHint } from './interpolate.ts';
16
+ import { LocatorNotFoundError, resolveLocator } from './resolveLocator.ts';
17
+ import { runClick } from './steps/click.ts';
18
+ import { runInput } from './steps/input.ts';
19
+ import { runIntercept } from './steps/intercept.ts';
20
+ import { runNavigate } from './steps/navigate.ts';
21
+ import { runRead } from './steps/read.ts';
22
+ import { runScroll } from './steps/scroll.ts';
23
+ import { runType } from './steps/type.ts';
24
+ import { runWait } from './steps/wait.ts';
25
+ import { runWaitFor } from './steps/waitFor.ts';
26
+
27
+ export interface RunActionOptions {
28
+ page: Page;
29
+ action: Action;
30
+ parameters: Record<string, string>;
31
+ storyFile: string;
32
+ config: ResolvedConfig;
33
+ }
34
+
35
+ const DEFAULT_RETRY_ATTEMPTS = 2;
36
+ const DEFAULT_RETRY_BACKOFF_MS = 200;
37
+ const DEFAULT_EXPECT_TIMEOUT_MS = 10_000;
38
+
39
+ /**
40
+ * Runs every step of an action in order, applies optional step-level retry on
41
+ * transient locator misses, waits for at least one `expect.anyOf` hint to
42
+ * become visible, then captures a masked full-page screenshot. Fails fast on
43
+ * the first non-recoverable step error so the harness never compares a
44
+ * screenshot of a half-finished state.
45
+ */
46
+ export async function runAction(
47
+ options: RunActionOptions,
48
+ ): Promise<ActionResult> {
49
+ const { action, page, parameters, storyFile, config } = options;
50
+ validateParameters(action, parameters);
51
+ const startedAt = new Date();
52
+ const attempts = action.retry?.attempts ?? DEFAULT_RETRY_ATTEMPTS;
53
+ const backoffMs = action.retry?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
54
+
55
+ for (let index = 0; index < action.steps.length; index += 1) {
56
+ const step = action.steps[index];
57
+ if (!step) continue;
58
+ try {
59
+ await dispatchWithRetry(
60
+ page,
61
+ step,
62
+ parameters,
63
+ config,
64
+ attempts,
65
+ backoffMs,
66
+ );
67
+ } catch (error) {
68
+ return failedResult(action, parameters, startedAt, index, error);
69
+ }
70
+ }
71
+
72
+ if (action.expect) {
73
+ try {
74
+ await waitForExpectation(page, action.expect, parameters, config);
75
+ } catch (error) {
76
+ return failedResult(
77
+ action,
78
+ parameters,
79
+ startedAt,
80
+ action.steps.length,
81
+ error,
82
+ );
83
+ }
84
+ }
85
+
86
+ if (action.screenshot === false) {
87
+ return successResultWithoutScreenshot(action, parameters, startedAt);
88
+ }
89
+
90
+ return captureAndCompare({
91
+ action,
92
+ page,
93
+ parameters,
94
+ config,
95
+ storyFile,
96
+ startedAt,
97
+ });
98
+ }
99
+
100
+ async function dispatchWithRetry(
101
+ page: Page,
102
+ step: Step,
103
+ parameters: Record<string, string>,
104
+ config: ResolvedConfig,
105
+ attempts: number,
106
+ backoffMs: number,
107
+ ): Promise<void> {
108
+ let lastError: unknown;
109
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
110
+ try {
111
+ await dispatch(page, step, parameters, config);
112
+ return;
113
+ } catch (error) {
114
+ lastError = error;
115
+ if (!(error instanceof LocatorNotFoundError) || attempt === attempts) {
116
+ throw error;
117
+ }
118
+ await sleep(backoffMs * attempt);
119
+ }
120
+ }
121
+ throw lastError;
122
+ }
123
+
124
+ async function dispatch(
125
+ page: Page,
126
+ step: Step,
127
+ parameters: Record<string, string>,
128
+ config: ResolvedConfig,
129
+ ): Promise<void> {
130
+ switch (step.kind) {
131
+ case 'navigate':
132
+ return runNavigate(page, interpolate(step.path, parameters), config);
133
+ case 'click':
134
+ return runClick(
135
+ page,
136
+ interpolateHint(step.hint, parameters),
137
+ config.defaultTimeoutMs,
138
+ );
139
+ case 'input':
140
+ return runInput(
141
+ page,
142
+ interpolateHint(step.hint, parameters),
143
+ interpolate(step.value, parameters),
144
+ config.defaultTimeoutMs,
145
+ );
146
+ case 'scroll':
147
+ return runScroll(page, step.direction, step.amount);
148
+ case 'intercept':
149
+ return runIntercept(page, step.pattern, step.respond, step.method);
150
+ case 'waitFor':
151
+ return runWaitFor(
152
+ page,
153
+ interpolateHint(step.hint, parameters),
154
+ config.defaultTimeoutMs,
155
+ );
156
+ case 'read':
157
+ return runRead(page, interpolateHint(step.hint, parameters));
158
+ case 'type':
159
+ return runType(page, interpolate(step.value, parameters));
160
+ case 'wait':
161
+ return runWait(page, step.ms);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Polls every hint in `expect.anyOf` concurrently and resolves as soon as one
167
+ * becomes visible. Throws when none resolve within the configured timeout.
168
+ * Race semantics: any single match satisfies the expectation — this is what
169
+ * lets a single action declare "success looks like list-item OR toast OR
170
+ * status banner" without the story knowing which renderer the app picked.
171
+ */
172
+ async function waitForExpectation(
173
+ page: Page,
174
+ expectation: NonNullable<Action['expect']>,
175
+ parameters: Record<string, string>,
176
+ config: ResolvedConfig,
177
+ ): Promise<void> {
178
+ const timeoutMs = expectation.timeoutMs ?? DEFAULT_EXPECT_TIMEOUT_MS;
179
+ const resolvedCandidates = expectation.anyOf.map((hint) =>
180
+ interpolateHint(hint, parameters),
181
+ );
182
+ const candidates = resolvedCandidates.map((hint) =>
183
+ resolveLocator(page, hint)
184
+ .first()
185
+ .waitFor({ state: 'visible', timeout: timeoutMs })
186
+ .then(() => hint)
187
+ .catch((error: unknown) => {
188
+ throw new LocatorNotFoundError(hint, error);
189
+ }),
190
+ );
191
+ try {
192
+ await Promise.any(candidates);
193
+ } catch (error) {
194
+ const inner =
195
+ error instanceof AggregateError && error.errors.length > 0
196
+ ? error.errors[error.errors.length - 1]
197
+ : error;
198
+ throw new ExpectationTimedOutError(expectation, inner, timeoutMs);
199
+ }
200
+ // Suppress unused-variable warning for config — kept for future tuning.
201
+ void config;
202
+ }
203
+
204
+ interface CaptureOptions {
205
+ action: Action;
206
+ page: Page;
207
+ parameters: Record<string, string>;
208
+ config: ResolvedConfig;
209
+ storyFile: string;
210
+ startedAt: Date;
211
+ }
212
+
213
+ async function captureAndCompare(
214
+ options: CaptureOptions,
215
+ ): Promise<ActionResult> {
216
+ const { action, page, parameters, config, storyFile, startedAt } = options;
217
+ const paths = pathsFor({
218
+ baselinesDir: config.paths.baselines,
219
+ reportDir: config.paths.report,
220
+ storyFile,
221
+ actionName: action.action,
222
+ });
223
+ const masks = resolveMasks(page, action.mask, parameters);
224
+ const actualPng = await capturePage(page, masks);
225
+ await writePng(paths.actual, actualPng);
226
+ const a11yJson = await captureA11yTree(page);
227
+ await writeText(paths.a11yActual, a11yJson);
228
+
229
+ const baselinePng = await readBaseline(paths.baseline);
230
+ const baselineA11y = await readJsonBaseline(paths.a11yBaseline);
231
+ const baseResult = baseResultFor(action.action, parameters, startedAt);
232
+
233
+ if (baselinePng === undefined) {
234
+ await writePng(paths.baseline, actualPng);
235
+ await writeText(paths.a11yBaseline, a11yJson);
236
+ return finishResult(baseResult, {
237
+ status: 'new',
238
+ baselinePath: paths.baseline,
239
+ actualPath: paths.actual,
240
+ });
241
+ }
242
+
243
+ const a11yChanged = baselineA11y !== undefined && baselineA11y !== a11yJson;
244
+
245
+ try {
246
+ const pixelThreshold = action.diff?.pixelThreshold ?? 0.1;
247
+ const ssimThreshold = action.diff?.ssimThreshold ?? 0.99;
248
+ const legacyMaxDiffRatio = action.diff?.maxDiffRatio;
249
+ const outcome = diffPngs(baselinePng, actualPng, pixelThreshold);
250
+ const passesSsim = outcome.ssimScore >= ssimThreshold;
251
+ const passesLegacy =
252
+ legacyMaxDiffRatio === undefined ||
253
+ outcome.diffRatio <= legacyMaxDiffRatio;
254
+ if (passesSsim && passesLegacy) {
255
+ await deleteIfExists(paths.diff);
256
+ return finishResult(baseResult, {
257
+ status: 'pass',
258
+ baselinePath: paths.baseline,
259
+ actualPath: paths.actual,
260
+ diffPixels: outcome.diffPixels,
261
+ diffRatio: outcome.diffRatio,
262
+ ssimScore: outcome.ssimScore,
263
+ a11yChanged: a11yChanged || undefined,
264
+ });
265
+ }
266
+ await writePng(paths.diff, outcome.diffPng);
267
+ return finishResult(baseResult, {
268
+ status: 'changed',
269
+ baselinePath: paths.baseline,
270
+ actualPath: paths.actual,
271
+ diffPath: paths.diff,
272
+ diffPixels: outcome.diffPixels,
273
+ diffRatio: outcome.diffRatio,
274
+ ssimScore: outcome.ssimScore,
275
+ a11yChanged: a11yChanged || undefined,
276
+ });
277
+ } catch (error) {
278
+ if (error instanceof ScreenshotSizeMismatchError) {
279
+ return finishResult(baseResult, {
280
+ status: 'changed',
281
+ baselinePath: paths.baseline,
282
+ actualPath: paths.actual,
283
+ failureMessage: error.message,
284
+ });
285
+ }
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ function resolveMasks(
291
+ page: Page,
292
+ maskHints: Hint[] | undefined,
293
+ parameters: Record<string, string>,
294
+ ): Locator[] {
295
+ if (!maskHints || maskHints.length === 0) {
296
+ return [];
297
+ }
298
+ return maskHints.map((hint) =>
299
+ resolveLocator(page, interpolateHint(hint, parameters)),
300
+ );
301
+ }
302
+
303
+ /**
304
+ * Snapshots the page's accessibility tree as a YAML-shaped string. Two
305
+ * runs of the same page produce byte-identical output so a simple string
306
+ * comparison detects semantic changes (button label, role, structure)
307
+ * without flagging visual-only drift.
308
+ */
309
+ async function captureA11yTree(page: Page): Promise<string> {
310
+ return page.locator('body').ariaSnapshot();
311
+ }
312
+
313
+ function baseResultFor(
314
+ actionName: string,
315
+ parameters: Record<string, string>,
316
+ startedAt: Date,
317
+ ): ActionResult {
318
+ return {
319
+ action: actionName,
320
+ parameters,
321
+ status: 'pass',
322
+ startedAt: startedAt.toISOString(),
323
+ finishedAt: startedAt.toISOString(),
324
+ durationMs: 0,
325
+ };
326
+ }
327
+
328
+ function successResultWithoutScreenshot(
329
+ action: Action,
330
+ parameters: Record<string, string>,
331
+ startedAt: Date,
332
+ ): ActionResult {
333
+ const finishedAt = new Date();
334
+ return {
335
+ action: action.action,
336
+ parameters,
337
+ status: 'pass',
338
+ startedAt: startedAt.toISOString(),
339
+ finishedAt: finishedAt.toISOString(),
340
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
341
+ };
342
+ }
343
+
344
+ function failedResult(
345
+ action: Action,
346
+ parameters: Record<string, string>,
347
+ startedAt: Date,
348
+ failedIndex: number,
349
+ error: unknown,
350
+ ): ActionResult {
351
+ const finishedAt = new Date();
352
+ return {
353
+ action: action.action,
354
+ parameters,
355
+ status: 'failed',
356
+ startedAt: startedAt.toISOString(),
357
+ finishedAt: finishedAt.toISOString(),
358
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
359
+ failedStepIndex: failedIndex,
360
+ failureMessage: error instanceof Error ? error.message : String(error),
361
+ };
362
+ }
363
+
364
+ function finishResult(
365
+ base: ActionResult,
366
+ overrides: Partial<ActionResult> & { status: ActionStatus },
367
+ ): ActionResult {
368
+ const finishedAt = new Date();
369
+ return {
370
+ ...base,
371
+ ...overrides,
372
+ finishedAt: finishedAt.toISOString(),
373
+ durationMs: finishedAt.getTime() - new Date(base.startedAt).getTime(),
374
+ };
375
+ }
376
+
377
+ function validateParameters(
378
+ action: Action,
379
+ parameters: Record<string, string>,
380
+ ): void {
381
+ const declared = new Set(action.parameters ?? []);
382
+ for (const key of Object.keys(parameters)) {
383
+ if (!declared.has(key)) {
384
+ throw new Error(
385
+ `Action "${action.action}" received unknown parameter "${key}"`,
386
+ );
387
+ }
388
+ }
389
+ for (const required of declared) {
390
+ if (parameters[required] === undefined) {
391
+ throw new Error(
392
+ `Action "${action.action}" is missing parameter "${required}"`,
393
+ );
394
+ }
395
+ }
396
+ }
397
+
398
+ function sleep(ms: number): Promise<void> {
399
+ return new Promise((resolve) => {
400
+ setTimeout(resolve, ms);
401
+ });
402
+ }
403
+
404
+ export class ExpectationTimedOutError extends Error {
405
+ readonly expectation: NonNullable<Action['expect']>;
406
+ readonly innerError: unknown;
407
+ constructor(
408
+ expectation: NonNullable<Action['expect']>,
409
+ innerError: unknown,
410
+ timeoutMs: number,
411
+ ) {
412
+ const summary = expectation.anyOf
413
+ .map((hint) => JSON.stringify(hint))
414
+ .join(', ');
415
+ super(
416
+ `expect.anyOf did not resolve within ${timeoutMs}ms (candidates: ${summary})`,
417
+ );
418
+ this.name = 'ExpectationTimedOutError';
419
+ this.expectation = expectation;
420
+ this.innerError = innerError;
421
+ }
422
+ }
@@ -0,0 +1,195 @@
1
+ import { access, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { chromium, type Browser, type BrowserContext } from 'playwright';
4
+ import type { ResolvedConfig } from '../config.ts';
5
+ import type { Action } from '../schema/action.ts';
6
+ import type { Story } from '../schema/story.ts';
7
+ import type {
8
+ ActionResult,
9
+ StoryResult,
10
+ StoryStatus,
11
+ } from '../schema/result.ts';
12
+ import { applyFixture } from './bridges/database.ts';
13
+ import type { CoverageCollector } from './coverage.ts';
14
+ import { runAction } from './runAction.ts';
15
+
16
+ export interface RunStoryOptions {
17
+ story: Story;
18
+ file: string;
19
+ needs: string[];
20
+ produces: string[];
21
+ actions: Map<string, Action>;
22
+ config: ResolvedConfig;
23
+ headed: boolean;
24
+ coverage?: CoverageCollector;
25
+ }
26
+
27
+ const TRACE_SUBDIR = 'traces';
28
+
29
+ /**
30
+ * Drives one story end-to-end on a freshly launched browser. Wraps the
31
+ * context in Playwright tracing so a failed action leaves behind a
32
+ * full-fidelity `trace.zip` for post-mortem in the Playwright trace viewer.
33
+ * If the story declares `produces` labels, the post-run storage state is
34
+ * persisted under `<authState>/<label>.json` so consumer stories can attach
35
+ * to it without replaying the producer's actions.
36
+ */
37
+ export async function runStory(options: RunStoryOptions): Promise<StoryResult> {
38
+ const startedAt = new Date();
39
+ for (const fixture of options.story.fixtures ?? []) {
40
+ await applyFixture(options.config, fixture);
41
+ }
42
+ const browser = await chromium.launch({ headless: !options.headed });
43
+ try {
44
+ return await runStoryWithBrowser(browser, options, startedAt);
45
+ } finally {
46
+ await browser.close();
47
+ }
48
+ }
49
+
50
+ async function runStoryWithBrowser(
51
+ browser: Browser,
52
+ options: RunStoryOptions,
53
+ startedAt: Date,
54
+ ): Promise<StoryResult> {
55
+ const { story, file, needs, produces, actions, config, coverage } = options;
56
+ const storageStatePath = await resolveStorageStateForNeeds(config, needs);
57
+ const context = await browser.newContext({
58
+ baseURL: config.baseUrl,
59
+ viewport: config.viewport,
60
+ storageState: storageStatePath,
61
+ ignoreHTTPSErrors: true,
62
+ });
63
+ context.setDefaultTimeout(config.defaultTimeoutMs);
64
+ await context.tracing.start({
65
+ screenshots: true,
66
+ snapshots: true,
67
+ sources: true,
68
+ title: file,
69
+ });
70
+ const page = await context.newPage();
71
+ await page.clock.install({ time: config.frozenTime });
72
+ if (coverage) {
73
+ await coverage.startForPage(page);
74
+ }
75
+
76
+ const results: ActionResult[] = [];
77
+ let storyStatus: StoryStatus = 'pass';
78
+
79
+ for (const storyStep of story.actions) {
80
+ const action = actions.get(storyStep.action);
81
+ if (!action) {
82
+ results.push(skipped(storyStep.action, 'unknown action'));
83
+ storyStatus = 'failed';
84
+ continue;
85
+ }
86
+ if (storyStatus === 'failed') {
87
+ results.push(skipped(storyStep.action, 'earlier action failed'));
88
+ continue;
89
+ }
90
+ const result = await runAction({
91
+ page,
92
+ action,
93
+ parameters: storyStep.parameters ?? {},
94
+ storyFile: file,
95
+ config,
96
+ });
97
+ results.push(result);
98
+ if (result.status === 'failed') {
99
+ storyStatus = 'failed';
100
+ } else if (result.status === 'changed' || result.status === 'new') {
101
+ if (storyStatus === 'pass') {
102
+ storyStatus = 'changed';
103
+ }
104
+ }
105
+ }
106
+
107
+ if (coverage) {
108
+ await coverage.stopForPage(page);
109
+ }
110
+ const tracePath = await stopTracing(context, config, file, storyStatus);
111
+ if (storyStatus !== 'failed' && produces.length > 0) {
112
+ await persistProducedAuthState(context, config, produces);
113
+ }
114
+ await context.close();
115
+
116
+ const finishedAt = new Date();
117
+ return {
118
+ story: story.story,
119
+ file,
120
+ status: storyStatus,
121
+ startedAt: startedAt.toISOString(),
122
+ finishedAt: finishedAt.toISOString(),
123
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
124
+ actions: results,
125
+ tracePath,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Stops Playwright tracing. Writes the zip to `<report>/traces/<story>.zip`
131
+ * when the story failed (debugging payload); discards on success to keep
132
+ * the report directory small. Stories that need traces on every run can be
133
+ * tweaked here later — for now failure-only is the cheap default.
134
+ */
135
+ async function stopTracing(
136
+ context: BrowserContext,
137
+ config: ResolvedConfig,
138
+ storyFile: string,
139
+ storyStatus: StoryStatus,
140
+ ): Promise<string | undefined> {
141
+ if (storyStatus !== 'failed') {
142
+ await context.tracing.stop();
143
+ return undefined;
144
+ }
145
+ const traceDirectory = join(config.paths.report, TRACE_SUBDIR);
146
+ await mkdir(traceDirectory, { recursive: true });
147
+ const tracePath = join(
148
+ traceDirectory,
149
+ `${storyFile.replace(/\.json$/i, '')}.zip`,
150
+ );
151
+ await context.tracing.stop({ path: tracePath });
152
+ return tracePath;
153
+ }
154
+
155
+ async function resolveStorageStateForNeeds(
156
+ config: ResolvedConfig,
157
+ needs: string[],
158
+ ): Promise<string | undefined> {
159
+ for (const label of needs) {
160
+ const path = join(config.paths.authState, `${label}.json`);
161
+ try {
162
+ await access(path);
163
+ return path;
164
+ } catch {
165
+ // Producer ran but did not emit a storage state file. That is fine
166
+ // when the label only carries an ordering constraint, not an auth
167
+ // payload — fall through to the next label.
168
+ }
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ async function persistProducedAuthState(
174
+ context: BrowserContext,
175
+ config: ResolvedConfig,
176
+ produces: string[],
177
+ ): Promise<void> {
178
+ await mkdir(config.paths.authState, { recursive: true });
179
+ for (const label of produces) {
180
+ const path = join(config.paths.authState, `${label}.json`);
181
+ await context.storageState({ path });
182
+ }
183
+ }
184
+
185
+ function skipped(actionName: string, reason: string): ActionResult {
186
+ const now = new Date().toISOString();
187
+ return {
188
+ action: actionName,
189
+ status: 'skipped',
190
+ startedAt: now,
191
+ finishedAt: now,
192
+ durationMs: 0,
193
+ failureMessage: reason,
194
+ };
195
+ }