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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/bin/tuffgal.mjs +2 -0
- package/package.json +70 -0
- package/src/.gitkeep +0 -0
- package/src/cli.ts +158 -0
- package/src/commands/init.ts +140 -0
- package/src/commands/supervise.ts +267 -0
- package/src/config.ts +222 -0
- package/src/coverage/flows.ts +90 -0
- package/src/coverage/screens.ts +52 -0
- package/src/index.ts +28 -0
- package/src/reporter/assets/report.css +510 -0
- package/src/reporter/assets/report.js +45 -0
- package/src/reporter/template.ts +355 -0
- package/src/reporter/writeReport.ts +37 -0
- package/src/runner/approve.ts +65 -0
- package/src/runner/bridges/database.ts +34 -0
- package/src/runner/bridges/devServers.ts +174 -0
- package/src/runner/coverage.ts +76 -0
- package/src/runner/interpolate.ts +36 -0
- package/src/runner/resolveLocator.ts +47 -0
- package/src/runner/run.ts +177 -0
- package/src/runner/runAction.ts +422 -0
- package/src/runner/runStory.ts +195 -0
- package/src/runner/scheduler.ts +223 -0
- package/src/runner/steps/click.ts +16 -0
- package/src/runner/steps/input.ts +17 -0
- package/src/runner/steps/intercept.ts +28 -0
- package/src/runner/steps/navigate.ts +14 -0
- package/src/runner/steps/read.ts +20 -0
- package/src/runner/steps/scroll.ts +12 -0
- package/src/runner/steps/type.ts +11 -0
- package/src/runner/steps/wait.ts +5 -0
- package/src/runner/steps/waitFor.ts +16 -0
- package/src/schema/action.ts +176 -0
- package/src/schema/load.ts +94 -0
- package/src/schema/result.ts +83 -0
- package/src/schema/story.ts +58 -0
- package/src/screenshots/baselineStore.ts +114 -0
- package/src/screenshots/capture.ts +19 -0
- 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
|
+
}
|