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,355 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import type {
|
|
3
|
+
ActionResult,
|
|
4
|
+
ActionStatus,
|
|
5
|
+
RunResult,
|
|
6
|
+
StoryResult,
|
|
7
|
+
} from '../schema/result.ts';
|
|
8
|
+
|
|
9
|
+
const STATUS_LABELS: Record<ActionStatus, string> = {
|
|
10
|
+
pass: 'pass',
|
|
11
|
+
changed: 'changed',
|
|
12
|
+
failed: 'failed',
|
|
13
|
+
skipped: 'skipped',
|
|
14
|
+
new: 'new baseline',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const STATUS_LETTERS: Record<ActionStatus, string> = {
|
|
18
|
+
pass: 'P',
|
|
19
|
+
changed: 'C',
|
|
20
|
+
failed: 'F',
|
|
21
|
+
skipped: '·',
|
|
22
|
+
new: 'N',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Static HTML report. Console / dev-tool aesthetic: dark by default, mono for
|
|
27
|
+
* data, sans for prose, sharp 1px borders, tree branches in box-drawing
|
|
28
|
+
* characters that are hidden from assistive tech. Stories + actions are
|
|
29
|
+
* conveyed semantically through nested `<ol>` elements; the glyphs are
|
|
30
|
+
* pure presentation.
|
|
31
|
+
*/
|
|
32
|
+
export function renderReport(result: RunResult, reportDir: string): string {
|
|
33
|
+
const failures = collectFailures(result);
|
|
34
|
+
const dateLabel = formatDate(result.finishedAt);
|
|
35
|
+
return `<!doctype html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="utf-8" />
|
|
39
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
40
|
+
<title>tuffgal report — ${dateLabel}</title>
|
|
41
|
+
<link rel="stylesheet" href="assets/report.css" />
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<a class="skip-link" href="#main">Skip to report</a>
|
|
45
|
+
<header class="report-header">
|
|
46
|
+
<h1>tuffgal report</h1>
|
|
47
|
+
<p class="report-meta">
|
|
48
|
+
<time datetime="${result.finishedAt}">${dateLabel}</time>
|
|
49
|
+
<span aria-hidden="true">·</span>
|
|
50
|
+
duration ${formatDuration(result.durationMs)}
|
|
51
|
+
</p>
|
|
52
|
+
</header>
|
|
53
|
+
<main id="main" tabindex="-1">
|
|
54
|
+
${renderSummary(result)}
|
|
55
|
+
${renderStories(result, reportDir)}
|
|
56
|
+
${renderFailures(failures, reportDir)}
|
|
57
|
+
</main>
|
|
58
|
+
<script src="assets/report.js"></script>
|
|
59
|
+
</body>
|
|
60
|
+
</html>
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderSummary(result: RunResult): string {
|
|
65
|
+
const screens = result.customCoverage.screens;
|
|
66
|
+
const flows = result.customCoverage.flows;
|
|
67
|
+
return `
|
|
68
|
+
<section class="summary" aria-labelledby="summary-heading">
|
|
69
|
+
<h2 id="summary-heading">summary</h2>
|
|
70
|
+
<ul class="summary-list" aria-label="Run totals">
|
|
71
|
+
${summaryItem('stories', result.totals.stories)}
|
|
72
|
+
${summaryItem('pass', result.totals.passed, 'pass')}
|
|
73
|
+
${summaryItem('changed', result.totals.changed, 'changed')}
|
|
74
|
+
${summaryItem('failed', result.totals.failed, 'failed')}
|
|
75
|
+
${coverageItem('screens', screens)}
|
|
76
|
+
${coverageItem('flows', flows)}
|
|
77
|
+
</ul>
|
|
78
|
+
</section>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function coverageItem(
|
|
82
|
+
label: string,
|
|
83
|
+
metric: { total: number; covered: number; ratio: number },
|
|
84
|
+
): string {
|
|
85
|
+
const pct = `${(metric.ratio * 100).toFixed(0)}%`;
|
|
86
|
+
return `<li class="summary-item coverage"><span class="count">${pct}</span><span class="label">${label}</span><span class="coverage-detail" aria-hidden="true">${metric.covered}/${metric.total}</span><span class="sr-only">${metric.covered} of ${metric.total} ${label} covered</span></li>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function summaryItem(
|
|
90
|
+
label: string,
|
|
91
|
+
value: number,
|
|
92
|
+
statusKey?: ActionStatus,
|
|
93
|
+
): string {
|
|
94
|
+
const indicator = statusKey
|
|
95
|
+
? `<span class="indicator" data-status="${statusKey}" aria-hidden="true"></span>`
|
|
96
|
+
: '';
|
|
97
|
+
return `<li class="summary-item"${
|
|
98
|
+
statusKey ? ` data-status="${statusKey}"` : ''
|
|
99
|
+
}><span class="count">${value}</span>${indicator}<span class="label">${label}</span></li>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderStories(result: RunResult, reportDir: string): string {
|
|
103
|
+
const items = result.stories
|
|
104
|
+
.map((story, index) => renderStory(story, index, reportDir))
|
|
105
|
+
.join('\n');
|
|
106
|
+
return `
|
|
107
|
+
<section aria-labelledby="stories-heading">
|
|
108
|
+
<h2 id="stories-heading">stories</h2>
|
|
109
|
+
<ol class="stories" aria-label="Stories executed in dependency order">
|
|
110
|
+
${items}
|
|
111
|
+
</ol>
|
|
112
|
+
</section>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderStory(
|
|
116
|
+
story: StoryResult,
|
|
117
|
+
storyIndex: number,
|
|
118
|
+
reportDir: string,
|
|
119
|
+
): string {
|
|
120
|
+
const actions = story.actions
|
|
121
|
+
.map((action, actionIndex, all) =>
|
|
122
|
+
renderAction(
|
|
123
|
+
action,
|
|
124
|
+
`s${storyIndex}-a${actionIndex}`,
|
|
125
|
+
actionIndex === all.length - 1,
|
|
126
|
+
reportDir,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
.join('\n');
|
|
130
|
+
return `
|
|
131
|
+
<li class="story" data-status="${story.status}">
|
|
132
|
+
<div class="story-row">
|
|
133
|
+
${storyBadge(story.status)}
|
|
134
|
+
<code class="story-file">${escapeHtml(story.file)}</code>
|
|
135
|
+
<span class="story-duration">${formatDuration(story.durationMs)}</span>
|
|
136
|
+
</div>
|
|
137
|
+
<p class="story-prose">${escapeHtml(story.story)}</p>
|
|
138
|
+
<ol class="actions" aria-label="Actions">
|
|
139
|
+
${actions}
|
|
140
|
+
</ol>
|
|
141
|
+
</li>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderAction(
|
|
145
|
+
action: ActionResult,
|
|
146
|
+
actionId: string,
|
|
147
|
+
isLast: boolean,
|
|
148
|
+
reportDir: string,
|
|
149
|
+
): string {
|
|
150
|
+
const branch = isLast ? '└─' : '├─';
|
|
151
|
+
const screenshots = renderScreenshots(action, actionId, reportDir);
|
|
152
|
+
const errorBlock =
|
|
153
|
+
action.status === 'failed'
|
|
154
|
+
? `<pre class="action-error">${escapeHtml(action.failureMessage ?? 'unknown error')}</pre>`
|
|
155
|
+
: '';
|
|
156
|
+
const parameters = renderParameters(action.parameters);
|
|
157
|
+
return `
|
|
158
|
+
<li class="action" data-status="${action.status}">
|
|
159
|
+
<div class="action-row">
|
|
160
|
+
<span class="branch" aria-hidden="true">${branch}</span>
|
|
161
|
+
${statusBadge(action.status)}
|
|
162
|
+
<code class="action-name">${escapeHtml(action.action)}</code>
|
|
163
|
+
<span class="action-duration">${formatDuration(action.durationMs)}</span>
|
|
164
|
+
${screenshots ? `<span class="action-shots">${screenshots}</span>` : ''}
|
|
165
|
+
</div>
|
|
166
|
+
${parameters}
|
|
167
|
+
${errorBlock}
|
|
168
|
+
</li>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderParameters(
|
|
172
|
+
parameters: Record<string, string> | undefined,
|
|
173
|
+
): string {
|
|
174
|
+
if (!parameters || Object.keys(parameters).length === 0) {
|
|
175
|
+
return '';
|
|
176
|
+
}
|
|
177
|
+
const entries = Object.entries(parameters)
|
|
178
|
+
.map(
|
|
179
|
+
([key, value]) =>
|
|
180
|
+
`<dt>${escapeHtml(key)}</dt><dd>${escapeHtml(value)}</dd>`,
|
|
181
|
+
)
|
|
182
|
+
.join('');
|
|
183
|
+
return `<dl class="action-parameters">${entries}</dl>`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderScreenshots(
|
|
187
|
+
action: ActionResult,
|
|
188
|
+
actionId: string,
|
|
189
|
+
reportDir: string,
|
|
190
|
+
): string {
|
|
191
|
+
if (!action.actualPath && !action.baselinePath) {
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
const baseline = action.baselinePath
|
|
195
|
+
? toReportRelative(reportDir, action.baselinePath)
|
|
196
|
+
: undefined;
|
|
197
|
+
const actual = action.actualPath
|
|
198
|
+
? toReportRelative(reportDir, action.actualPath)
|
|
199
|
+
: undefined;
|
|
200
|
+
const diff = action.diffPath
|
|
201
|
+
? toReportRelative(reportDir, action.diffPath)
|
|
202
|
+
: undefined;
|
|
203
|
+
const defaultTab = diff ? 'diff' : 'actual';
|
|
204
|
+
const diffStatsId = `${actionId}-diff-stats`;
|
|
205
|
+
const diffStats =
|
|
206
|
+
action.diffRatio !== undefined
|
|
207
|
+
? `<p class="diff-stats" id="${diffStatsId}">${action.diffPixels} pixels differ (${(action.diffRatio * 100).toFixed(3)}%)</p>`
|
|
208
|
+
: '';
|
|
209
|
+
return `
|
|
210
|
+
<details class="shots">
|
|
211
|
+
<summary><span aria-hidden="true">[</span>view<span aria-hidden="true">]</span></summary>
|
|
212
|
+
<fieldset class="shot-radio" data-default-tab="${defaultTab}">
|
|
213
|
+
<legend class="sr-only">Screenshot to display</legend>
|
|
214
|
+
${shotRadio(actionId, 'baseline', baseline === undefined)}
|
|
215
|
+
${shotRadio(actionId, 'actual', actual === undefined)}
|
|
216
|
+
${shotRadio(actionId, 'diff', diff === undefined)}
|
|
217
|
+
</fieldset>
|
|
218
|
+
${shotPanel(actionId, 'baseline', baseline, `${action.action} baseline screenshot`)}
|
|
219
|
+
${shotPanel(actionId, 'actual', actual, `${action.action} actual screenshot from this run`)}
|
|
220
|
+
${shotPanel(actionId, 'diff', diff, `Pixel diff overlay for ${action.action}: red pixels mark changed regions`, action.diffRatio !== undefined ? diffStatsId : undefined)}
|
|
221
|
+
${diffStats}
|
|
222
|
+
</details>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function shotRadio(actionId: string, name: string, disabled: boolean): string {
|
|
226
|
+
const inputId = `${actionId}-radio-${name}`;
|
|
227
|
+
return `<label for="${inputId}" class="shot-radio-label">
|
|
228
|
+
<input
|
|
229
|
+
type="radio"
|
|
230
|
+
name="${actionId}-shot"
|
|
231
|
+
id="${inputId}"
|
|
232
|
+
value="${name}"
|
|
233
|
+
data-tab="${name}"
|
|
234
|
+
${disabled ? 'disabled' : ''}
|
|
235
|
+
/>
|
|
236
|
+
${name}
|
|
237
|
+
</label>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function shotPanel(
|
|
241
|
+
actionId: string,
|
|
242
|
+
name: string,
|
|
243
|
+
src: string | undefined,
|
|
244
|
+
alt: string,
|
|
245
|
+
describedById?: string,
|
|
246
|
+
): string {
|
|
247
|
+
return `<div
|
|
248
|
+
class="shot-panel"
|
|
249
|
+
id="panel-${actionId}-${name}"
|
|
250
|
+
data-tab="${name}"
|
|
251
|
+
hidden
|
|
252
|
+
>
|
|
253
|
+
${
|
|
254
|
+
src
|
|
255
|
+
? `<img src="${escapeAttribute(src)}" alt="${escapeAttribute(alt)}" loading="lazy"${describedById ? ` aria-describedby="${describedById}"` : ''} />`
|
|
256
|
+
: `<p class="shot-missing">no ${name} screenshot for this action</p>`
|
|
257
|
+
}
|
|
258
|
+
</div>`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function renderFailures(failures: FailureRecord[], reportDir: string): string {
|
|
262
|
+
if (failures.length === 0) {
|
|
263
|
+
return `
|
|
264
|
+
<section aria-labelledby="failures-heading">
|
|
265
|
+
<h2 id="failures-heading">failures</h2>
|
|
266
|
+
<p class="prose-block empty">(none)</p>
|
|
267
|
+
</section>`;
|
|
268
|
+
}
|
|
269
|
+
const items = failures
|
|
270
|
+
.map(
|
|
271
|
+
(failure) => `
|
|
272
|
+
<article class="failure">
|
|
273
|
+
<header class="failure-head">
|
|
274
|
+
<code>${escapeHtml(failure.storyFile)}</code>
|
|
275
|
+
<span aria-hidden="true">·</span>
|
|
276
|
+
<code>${escapeHtml(failure.actionName)}</code>
|
|
277
|
+
</header>
|
|
278
|
+
<pre class="failure-message">${escapeHtml(failure.message)}</pre>
|
|
279
|
+
${
|
|
280
|
+
failure.tracePath
|
|
281
|
+
? `<p class="failure-trace">trace: <a href="${escapeAttribute(toReportRelative(reportDir, failure.tracePath))}">${escapeHtml(toReportRelative(reportDir, failure.tracePath))}</a></p>`
|
|
282
|
+
: ''
|
|
283
|
+
}
|
|
284
|
+
</article>`,
|
|
285
|
+
)
|
|
286
|
+
.join('\n');
|
|
287
|
+
return `
|
|
288
|
+
<section aria-labelledby="failures-heading">
|
|
289
|
+
<h2 id="failures-heading">failures</h2>
|
|
290
|
+
<ol class="failures" aria-label="Failed actions">
|
|
291
|
+
${items}
|
|
292
|
+
</ol>
|
|
293
|
+
</section>`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface FailureRecord {
|
|
297
|
+
storyFile: string;
|
|
298
|
+
actionName: string;
|
|
299
|
+
message: string;
|
|
300
|
+
tracePath?: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function collectFailures(result: RunResult): FailureRecord[] {
|
|
304
|
+
const out: FailureRecord[] = [];
|
|
305
|
+
for (const story of result.stories) {
|
|
306
|
+
for (const action of story.actions) {
|
|
307
|
+
if (action.status === 'failed') {
|
|
308
|
+
out.push({
|
|
309
|
+
storyFile: story.file,
|
|
310
|
+
actionName: action.action,
|
|
311
|
+
message: action.failureMessage ?? 'unknown error',
|
|
312
|
+
tracePath: story.tracePath,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function statusBadge(status: ActionStatus): string {
|
|
321
|
+
return `<span class="status" data-status="${status}">
|
|
322
|
+
<span class="status-letter" aria-hidden="true">${STATUS_LETTERS[status]}</span><span class="sr-only">${STATUS_LABELS[status]}</span><span class="status-label" aria-hidden="true">${STATUS_LABELS[status]}</span>
|
|
323
|
+
</span>`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function storyBadge(status: ActionStatus): string {
|
|
327
|
+
return statusBadge(status);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function toReportRelative(reportDir: string, absolute: string): string {
|
|
331
|
+
return relative(reportDir, absolute);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function formatDate(iso: string): string {
|
|
335
|
+
const date = new Date(iso);
|
|
336
|
+
return date.toISOString().replace('T', ' ').slice(0, 19);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function formatDuration(ms: number): string {
|
|
340
|
+
if (ms < 1000) return `${ms}ms`;
|
|
341
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function escapeHtml(value: string): string {
|
|
345
|
+
return value
|
|
346
|
+
.replaceAll('&', '&')
|
|
347
|
+
.replaceAll('<', '<')
|
|
348
|
+
.replaceAll('>', '>')
|
|
349
|
+
.replaceAll('"', '"')
|
|
350
|
+
.replaceAll("'", ''');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function escapeAttribute(value: string): string {
|
|
354
|
+
return escapeHtml(value);
|
|
355
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { RunResult } from '../schema/result.ts';
|
|
5
|
+
import { renderReport } from './template.ts';
|
|
6
|
+
|
|
7
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const ASSETS_SOURCE_DIR = join(moduleDir, 'assets');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Writes the HTML report, the static CSS/JS assets, and the raw `results.json`
|
|
12
|
+
* to the configured report directory. The JSON file is what `tuffgal approve`
|
|
13
|
+
* reads to locate actuals that should be promoted to baselines.
|
|
14
|
+
*/
|
|
15
|
+
export async function writeReport(
|
|
16
|
+
reportDir: string,
|
|
17
|
+
result: RunResult,
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
await mkdir(join(reportDir, 'assets'), { recursive: true });
|
|
20
|
+
const html = renderReport(result, reportDir);
|
|
21
|
+
const htmlPath = join(reportDir, 'index.html');
|
|
22
|
+
await writeFile(htmlPath, html, 'utf8');
|
|
23
|
+
await writeFile(
|
|
24
|
+
join(reportDir, 'results.json'),
|
|
25
|
+
JSON.stringify(result, null, 2),
|
|
26
|
+
'utf8',
|
|
27
|
+
);
|
|
28
|
+
await copyFile(
|
|
29
|
+
join(ASSETS_SOURCE_DIR, 'report.css'),
|
|
30
|
+
join(reportDir, 'assets', 'report.css'),
|
|
31
|
+
);
|
|
32
|
+
await copyFile(
|
|
33
|
+
join(ASSETS_SOURCE_DIR, 'report.js'),
|
|
34
|
+
join(reportDir, 'assets', 'report.js'),
|
|
35
|
+
);
|
|
36
|
+
return htmlPath;
|
|
37
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { ResolvedConfig } from '../config.ts';
|
|
4
|
+
import { copyToBaseline } from '../screenshots/baselineStore.ts';
|
|
5
|
+
import type { ActionResult, RunResult } from '../schema/result.ts';
|
|
6
|
+
|
|
7
|
+
export interface ApproveOptions {
|
|
8
|
+
storyFilter?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ApproveSummary {
|
|
12
|
+
approved: number;
|
|
13
|
+
skipped: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reads `<report>/results.json` from the previous run and promotes every
|
|
18
|
+
* `changed` or `new` action's actual screenshot to its baseline. Optional
|
|
19
|
+
* `storyFilter` limits the approval to one story file.
|
|
20
|
+
*/
|
|
21
|
+
export async function approveAll(
|
|
22
|
+
config: ResolvedConfig,
|
|
23
|
+
options: ApproveOptions,
|
|
24
|
+
): Promise<ApproveSummary> {
|
|
25
|
+
const resultsPath = join(config.paths.report, 'results.json');
|
|
26
|
+
const raw = await readFile(resultsPath, 'utf8').catch(() => {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`No prior run found at ${resultsPath}. Run \`tuffgal run\` first.`,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
const result = JSON.parse(raw) as RunResult;
|
|
32
|
+
let approved = 0;
|
|
33
|
+
let skipped = 0;
|
|
34
|
+
for (const story of result.stories) {
|
|
35
|
+
if (
|
|
36
|
+
options.storyFilter &&
|
|
37
|
+
story.file !== options.storyFilter &&
|
|
38
|
+
story.file !== `${options.storyFilter}.json`
|
|
39
|
+
) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
for (const action of story.actions) {
|
|
43
|
+
if (!isApprovable(action)) {
|
|
44
|
+
skipped += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
await copyToBaseline(action.actualPath, action.baselinePath);
|
|
48
|
+
approved += 1;
|
|
49
|
+
process.stdout.write(` approved ${action.action}\n`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { approved, skipped };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ApprovableAction = ActionResult & {
|
|
56
|
+
actualPath: string;
|
|
57
|
+
baselinePath: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function isApprovable(action: ActionResult): action is ApprovableAction {
|
|
61
|
+
if (action.status !== 'changed' && action.status !== 'new') {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return Boolean(action.actualPath && action.baselinePath);
|
|
65
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResolvedConfig } from '../../config.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Invokes the consumer-supplied database reset. Called once at the start of
|
|
5
|
+
* a run, before any story dispatches. No-op when the consumer did not supply
|
|
6
|
+
* a `database.reset` function (e.g., a static site with no backend).
|
|
7
|
+
*/
|
|
8
|
+
export async function resetDatabase(config: ResolvedConfig): Promise<void> {
|
|
9
|
+
const reset = config.database?.reset;
|
|
10
|
+
if (!reset) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
await reset();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Looks up and invokes a named fixture. Called once per story per fixture
|
|
18
|
+
* declaration, before the browser launches. Throws with a discoverable
|
|
19
|
+
* error when the consumer's config does not register the named fixture so
|
|
20
|
+
* a typo in a story file fails loudly.
|
|
21
|
+
*/
|
|
22
|
+
export async function applyFixture(
|
|
23
|
+
config: ResolvedConfig,
|
|
24
|
+
name: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const fixtures = config.database?.fixtures;
|
|
27
|
+
if (!fixtures || !fixtures[name]) {
|
|
28
|
+
const known = fixtures ? Object.keys(fixtures).join(', ') : '(none)';
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Unknown fixture: "${name}". Known fixtures in tuffgal.config.ts: ${known}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
await fixtures[name]();
|
|
34
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { createWriteStream, mkdirSync } from 'node:fs';
|
|
3
|
+
import { createConnection } from 'node:net';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import type { ResolvedConfig } from '../../config.ts';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_READY_TIMEOUT_MS = 60_000;
|
|
8
|
+
const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
export interface ManagedDevServers {
|
|
11
|
+
stop(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Spawns the consumer-declared `devServers.command` in a new process group,
|
|
16
|
+
* tees its combined output to `<report>/dev-servers.log`, and waits until
|
|
17
|
+
* every declared health-check URL accepts a TCP connection on its port.
|
|
18
|
+
* Returns a handle whose `stop()` method tears the whole tree down with
|
|
19
|
+
* the configured signal (default SIGTERM) followed by SIGKILL after the
|
|
20
|
+
* grace window.
|
|
21
|
+
*
|
|
22
|
+
* Used by the CLI's `--manage-servers` flag.
|
|
23
|
+
*/
|
|
24
|
+
export async function startManagedDevServers(
|
|
25
|
+
config: ResolvedConfig,
|
|
26
|
+
): Promise<ManagedDevServers> {
|
|
27
|
+
if (!config.devServers) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'--manage-servers passed but tuffgal.config.ts has no `devServers` block. ' +
|
|
30
|
+
'Either remove the flag or declare `devServers: { command, healthCheck: [...] }`.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const logsDirectory = config.paths.report;
|
|
35
|
+
mkdirSync(logsDirectory, { recursive: true });
|
|
36
|
+
const logPath = join(logsDirectory, 'dev-servers.log');
|
|
37
|
+
const logStream = createWriteStream(logPath, { flags: 'w' });
|
|
38
|
+
|
|
39
|
+
process.stdout.write(`Spawning dev servers (output → ${logPath})…\n`);
|
|
40
|
+
const cwd = config.devServers.cwd
|
|
41
|
+
? resolve(config.rootDir, config.devServers.cwd)
|
|
42
|
+
: config.rootDir;
|
|
43
|
+
const child = spawn('sh', ['-c', config.devServers.command], {
|
|
44
|
+
cwd,
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
detached: true,
|
|
47
|
+
});
|
|
48
|
+
if (child.stdout) child.stdout.pipe(logStream);
|
|
49
|
+
if (child.stderr) child.stderr.pipe(logStream);
|
|
50
|
+
|
|
51
|
+
let earlyExit: Error | undefined;
|
|
52
|
+
child.once('exit', (code, signal) => {
|
|
53
|
+
if (code !== null && code !== 0) {
|
|
54
|
+
earlyExit = new Error(
|
|
55
|
+
`Dev server command exited early with code ${code}${
|
|
56
|
+
signal ? ` (${signal})` : ''
|
|
57
|
+
}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await Promise.all(
|
|
64
|
+
config.devServers.healthCheck.map((check) =>
|
|
65
|
+
waitForUrl(
|
|
66
|
+
check.url,
|
|
67
|
+
check.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
68
|
+
() => earlyExit,
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await stopChild(child, config);
|
|
74
|
+
logStream.end();
|
|
75
|
+
if (earlyExit) throw earlyExit;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
async stop(): Promise<void> {
|
|
81
|
+
await stopChild(child, config);
|
|
82
|
+
logStream.end();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function stopChild(
|
|
88
|
+
child: ChildProcess,
|
|
89
|
+
config: ResolvedConfig,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
if (!child.pid || child.exitCode !== null) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const pid = child.pid;
|
|
95
|
+
const signal = config.devServers?.shutdownSignal ?? 'SIGTERM';
|
|
96
|
+
const graceMs = config.devServers?.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
|
|
97
|
+
process.stdout.write('Stopping dev servers…\n');
|
|
98
|
+
await new Promise<void>((resolveOuter) => {
|
|
99
|
+
let killed = false;
|
|
100
|
+
const finish = (): void => {
|
|
101
|
+
if (killed) return;
|
|
102
|
+
killed = true;
|
|
103
|
+
resolveOuter();
|
|
104
|
+
};
|
|
105
|
+
child.once('exit', finish);
|
|
106
|
+
try {
|
|
107
|
+
// Negative pid targets the entire process group created by detached: true.
|
|
108
|
+
process.kill(-pid, signal);
|
|
109
|
+
} catch {
|
|
110
|
+
// The group might already be gone; treat as resolved.
|
|
111
|
+
finish();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
if (child.exitCode === null) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(-pid, 'SIGKILL');
|
|
118
|
+
} catch {
|
|
119
|
+
// Already exited or unreachable; finishing is fine either way.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}, graceMs);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function waitForUrl(
|
|
127
|
+
url: string,
|
|
128
|
+
timeoutMs: number,
|
|
129
|
+
getEarlyExit: () => Error | undefined,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const parsed = new URL(url);
|
|
132
|
+
const port = parsed.port
|
|
133
|
+
? Number(parsed.port)
|
|
134
|
+
: parsed.protocol === 'https:'
|
|
135
|
+
? 443
|
|
136
|
+
: 80;
|
|
137
|
+
const host = parsed.hostname;
|
|
138
|
+
const deadline = Date.now() + timeoutMs;
|
|
139
|
+
while (Date.now() < deadline) {
|
|
140
|
+
const earlyExit = getEarlyExit();
|
|
141
|
+
if (earlyExit) {
|
|
142
|
+
throw earlyExit;
|
|
143
|
+
}
|
|
144
|
+
const open = await probePort(host, port);
|
|
145
|
+
if (open) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await sleep(500);
|
|
149
|
+
}
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Health-check URL ${url} (TCP ${host}:${port}) did not respond within ${timeoutMs}ms`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function probePort(host: string, port: number): Promise<boolean> {
|
|
156
|
+
return new Promise((resolveProbe) => {
|
|
157
|
+
const socket = createConnection({ port, host });
|
|
158
|
+
const cleanup = (result: boolean): void => {
|
|
159
|
+
socket.removeAllListeners();
|
|
160
|
+
socket.destroy();
|
|
161
|
+
resolveProbe(result);
|
|
162
|
+
};
|
|
163
|
+
socket.once('connect', () => cleanup(true));
|
|
164
|
+
socket.once('error', () => cleanup(false));
|
|
165
|
+
socket.once('timeout', () => cleanup(false));
|
|
166
|
+
socket.setTimeout(1_500);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sleep(ms: number): Promise<void> {
|
|
171
|
+
return new Promise((resolveSleep) => {
|
|
172
|
+
setTimeout(resolveSleep, ms);
|
|
173
|
+
});
|
|
174
|
+
}
|