staysfixed 0.1.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/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/staysfixed.js +18 -0
- package/examples/guards/the-sidebar-still-collapses.js +91 -0
- package/examples/staysfixed.config.electron.js +172 -0
- package/examples/staysfixed.config.web.js +277 -0
- package/package.json +61 -0
- package/src/cli/approve.js +126 -0
- package/src/cli/check.js +73 -0
- package/src/cli/doctor.js +379 -0
- package/src/cli/flake.js +61 -0
- package/src/cli/index.js +519 -0
- package/src/cli/init.js +564 -0
- package/src/cli/mark.js +69 -0
- package/src/cli/status.js +19 -0
- package/src/cli/trace.js +73 -0
- package/src/cli/walk.js +57 -0
- package/src/core/config.js +226 -0
- package/src/core/errors.js +48 -0
- package/src/core/git.js +90 -0
- package/src/core/hash.js +32 -0
- package/src/core/history.js +173 -0
- package/src/core/log.js +144 -0
- package/src/core/paths.js +135 -0
- package/src/drive/browser.js +540 -0
- package/src/drive/cdp.js +382 -0
- package/src/drive/electron.js +326 -0
- package/src/drive/find.js +331 -0
- package/src/drive/launch.js +263 -0
- package/src/drive/page.js +1042 -0
- package/src/freeze/clock.js +213 -0
- package/src/freeze/fonts.js +243 -0
- package/src/freeze/index.js +234 -0
- package/src/freeze/mask.js +187 -0
- package/src/freeze/motion.js +206 -0
- package/src/freeze/network.js +455 -0
- package/src/freeze/random.js +87 -0
- package/src/freeze/settle.js +178 -0
- package/src/guard/api.js +197 -0
- package/src/guard/load.js +324 -0
- package/src/guard/name.js +327 -0
- package/src/guard/run.js +224 -0
- package/src/index.js +61 -0
- package/src/marker/mark.js +260 -0
- package/src/marker/trace.js +293 -0
- package/src/mcp/server.js +377 -0
- package/src/mcp/tools.js +978 -0
- package/src/picture/capture.js +276 -0
- package/src/picture/compare.js +103 -0
- package/src/picture/run.js +284 -0
- package/src/picture/store.js +208 -0
- package/src/report/console.js +540 -0
- package/src/report/html.js +579 -0
- package/src/run.js +614 -0
- package/src/types.js +471 -0
- package/src/walk/run.js +541 -0
package/src/walk/run.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The click-through you do before you ship.
|
|
3
|
+
*
|
|
4
|
+
* A walk is not a test. It opens the real app, visits every screen in order and
|
|
5
|
+
* photographs each one into a fresh dated folder, so a person can scroll one page
|
|
6
|
+
* and see the whole app the way a user would. Nothing here compares against an
|
|
7
|
+
* approved picture and nothing here can ever write into `approved/` — a walk is
|
|
8
|
+
* evidence for a human, not a promise the tool is keeping.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fsp from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { captureScreen } from '../picture/capture.js';
|
|
14
|
+
import { settingsForScreen } from '../core/config.js';
|
|
15
|
+
import { gitInfo } from '../core/git.js';
|
|
16
|
+
import { StaysFixedError, messageOf } from '../core/errors.js';
|
|
17
|
+
import { safeName } from '../core/paths.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Progress handed to `opts.onStep`, once when a step starts and once when it is done.
|
|
21
|
+
* @typedef {object} WalkProgress
|
|
22
|
+
* @property {'start'|'done'} phase
|
|
23
|
+
* @property {number} index 1-based, matching the number on the photo file.
|
|
24
|
+
* @property {number} total
|
|
25
|
+
* @property {string} name
|
|
26
|
+
* @property {string} [describe]
|
|
27
|
+
* @property {import('../types.js').WalkStep} [step] The finished step, on 'done'.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Walk the app and photograph every screen in order.
|
|
32
|
+
*
|
|
33
|
+
* @param {import('../types.js').Project} project
|
|
34
|
+
* @param {import('../types.js').LaunchedApp} app
|
|
35
|
+
* @param {{
|
|
36
|
+
* onStep?: (update: WalkProgress) => void,
|
|
37
|
+
* only?: string|string[],
|
|
38
|
+
* signal?: AbortSignal,
|
|
39
|
+
* record?: boolean,
|
|
40
|
+
* }} [opts]
|
|
41
|
+
* @returns {Promise<import('../types.js').WalkReport>}
|
|
42
|
+
*/
|
|
43
|
+
export async function walkApp(project, app, opts = {}) {
|
|
44
|
+
const { config, paths } = project;
|
|
45
|
+
const chosen = chooseSteps(config, opts.only);
|
|
46
|
+
|
|
47
|
+
const id = walkId(new Date());
|
|
48
|
+
const dir = await makeWalkDir(paths.results, id);
|
|
49
|
+
|
|
50
|
+
// A walk drives the same page object the whole way through, on purpose: the
|
|
51
|
+
// point is to see the app as a person moving through it would, not to open a
|
|
52
|
+
// clean tab per screen.
|
|
53
|
+
const page = /** @type {import('../types.js').PageHandle} */ (app.page);
|
|
54
|
+
|
|
55
|
+
/** @type {import('../types.js').WalkStep[]} */
|
|
56
|
+
const steps = [];
|
|
57
|
+
let stopped = false;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < chosen.length; i++) {
|
|
60
|
+
if (opts.signal?.aborted) {
|
|
61
|
+
stopped = true;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
const screen = chosen[i];
|
|
65
|
+
const index = i + 1;
|
|
66
|
+
opts.onStep?.({
|
|
67
|
+
phase: 'start',
|
|
68
|
+
index,
|
|
69
|
+
total: chosen.length,
|
|
70
|
+
name: screen.name,
|
|
71
|
+
...(screen.describe !== undefined ? { describe: screen.describe } : {}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const step = await walkOneStep(page, screen, {
|
|
75
|
+
index,
|
|
76
|
+
dir,
|
|
77
|
+
settings: settingsForScreen(config, screen),
|
|
78
|
+
fixturesDir: paths.fixtures,
|
|
79
|
+
record: opts.record ?? false,
|
|
80
|
+
});
|
|
81
|
+
steps.push(step);
|
|
82
|
+
|
|
83
|
+
opts.onStep?.({
|
|
84
|
+
phase: 'done',
|
|
85
|
+
index,
|
|
86
|
+
total: chosen.length,
|
|
87
|
+
name: screen.name,
|
|
88
|
+
...(screen.describe !== undefined ? { describe: screen.describe } : {}),
|
|
89
|
+
step,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ok = !stopped && steps.every((s) => !s.error && (s.consoleErrors ?? []).length === 0);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
id,
|
|
97
|
+
dir,
|
|
98
|
+
steps,
|
|
99
|
+
ok,
|
|
100
|
+
git: await gitInfo(paths.root),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Photograph one screen. This never throws: a step that cannot be reached is
|
|
106
|
+
* recorded and the walk carries on. One broken screen must not hide the other
|
|
107
|
+
* eleven — hiding them is exactly how a release goes out with three things wrong
|
|
108
|
+
* instead of one.
|
|
109
|
+
*
|
|
110
|
+
* @param {import('../types.js').PageHandle} page
|
|
111
|
+
* @param {import('../types.js').ScreenConfig} screen
|
|
112
|
+
* @param {{
|
|
113
|
+
* index: number,
|
|
114
|
+
* dir: string,
|
|
115
|
+
* settings: ReturnType<typeof settingsForScreen>,
|
|
116
|
+
* fixturesDir: string,
|
|
117
|
+
* record: boolean,
|
|
118
|
+
* }} ctx
|
|
119
|
+
* @returns {Promise<import('../types.js').WalkStep>}
|
|
120
|
+
*/
|
|
121
|
+
async function walkOneStep(page, screen, ctx) {
|
|
122
|
+
const startedAt = Date.now();
|
|
123
|
+
const target = path.join(
|
|
124
|
+
ctx.dir,
|
|
125
|
+
`${String(ctx.index).padStart(2, '0')}-${safeName(screen.name)}.png`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
/** @type {string} */
|
|
129
|
+
let file = '';
|
|
130
|
+
/** @type {string|undefined} */
|
|
131
|
+
let error;
|
|
132
|
+
/** @type {string[]} */
|
|
133
|
+
let consoleErrors = [];
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const shot = await captureScreen(page, screen, ctx.settings, {
|
|
137
|
+
fixturesDir: ctx.fixturesDir,
|
|
138
|
+
record: ctx.record,
|
|
139
|
+
});
|
|
140
|
+
await fsp.writeFile(target, shot.png);
|
|
141
|
+
file = target;
|
|
142
|
+
consoleErrors = shot.consoleErrors;
|
|
143
|
+
} catch (cause) {
|
|
144
|
+
error = messageOf(cause);
|
|
145
|
+
consoleErrors = readConsole(page);
|
|
146
|
+
// Even a failed step is worth a photo — seeing where the app actually got to
|
|
147
|
+
// is usually the whole diagnosis. If the page is too far gone to photograph,
|
|
148
|
+
// the step simply has no picture and says so.
|
|
149
|
+
try {
|
|
150
|
+
await fsp.writeFile(target, await page.shoot());
|
|
151
|
+
file = target;
|
|
152
|
+
} catch {
|
|
153
|
+
file = '';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @type {import('../types.js').WalkStep} */
|
|
158
|
+
const step = {
|
|
159
|
+
index: ctx.index,
|
|
160
|
+
name: screen.name,
|
|
161
|
+
file,
|
|
162
|
+
durationMs: Date.now() - startedAt,
|
|
163
|
+
};
|
|
164
|
+
if (screen.describe !== undefined) step.describe = screen.describe;
|
|
165
|
+
if (error !== undefined) step.error = error;
|
|
166
|
+
if (consoleErrors.length > 0) step.consoleErrors = consoleErrors;
|
|
167
|
+
|
|
168
|
+
const url = await quietly(() => page.url());
|
|
169
|
+
if (url) step.url = url;
|
|
170
|
+
const title = await quietly(() => page.title());
|
|
171
|
+
if (title) step.title = title;
|
|
172
|
+
|
|
173
|
+
return step;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Which screens the walk visits: `walk.steps` when the project spelled one out,
|
|
178
|
+
* otherwise every screen it already checks, in the order they are written.
|
|
179
|
+
*
|
|
180
|
+
* @param {import('../types.js').ResolvedConfig} config
|
|
181
|
+
* @param {string|string[]} [only]
|
|
182
|
+
* @returns {import('../types.js').ScreenConfig[]}
|
|
183
|
+
*/
|
|
184
|
+
function chooseSteps(config, only) {
|
|
185
|
+
const spelledOut = config.walk?.steps;
|
|
186
|
+
const source = spelledOut && spelledOut.length > 0 ? spelledOut : config.screens ?? [];
|
|
187
|
+
if (source.length === 0) {
|
|
188
|
+
throw new StaysFixedError('There is nothing to walk through — no screens are set up yet.', {
|
|
189
|
+
hint: 'Add screens to your config, or a `walk: { steps: [ ... ] }` list of the ones to walk before a release.',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const wanted = only === undefined ? null : new Set((Array.isArray(only) ? only : [only]).map(safeName));
|
|
194
|
+
|
|
195
|
+
const chosen = source
|
|
196
|
+
.map((s, i) => normaliseStep(s, i))
|
|
197
|
+
.filter((s) => !s.skip)
|
|
198
|
+
.filter((s) => wanted === null || wanted.has(safeName(s.name)));
|
|
199
|
+
|
|
200
|
+
if (chosen.length === 0) {
|
|
201
|
+
throw new StaysFixedError(
|
|
202
|
+
wanted === null
|
|
203
|
+
? 'Every screen is switched off, so the walkthrough has nothing to show.'
|
|
204
|
+
: 'None of the screens you asked for are in this project.',
|
|
205
|
+
{ hint: 'Run `staysfixed status` to see the screen names this project knows about.' },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return chosen;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `walk.steps` comes straight off the raw config, so unlike `screens` its `url`
|
|
213
|
+
* shorthand has not been turned into a first step yet. Expand it here, but only
|
|
214
|
+
* when it is not already there, or a screen borrowed from `screens` would be
|
|
215
|
+
* told to navigate twice.
|
|
216
|
+
*
|
|
217
|
+
* @param {import('../types.js').ScreenConfig} screen
|
|
218
|
+
* @param {number} i
|
|
219
|
+
* @returns {import('../types.js').ScreenConfig}
|
|
220
|
+
*/
|
|
221
|
+
function normaliseStep(screen, i) {
|
|
222
|
+
if (!screen || typeof screen !== 'object') {
|
|
223
|
+
throw new StaysFixedError(`walk.steps[${i}] is not a screen.`, {
|
|
224
|
+
hint: "Each one looks like { name: 'sessions', url: '/sessions' }.",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const named = screen.name ? screen : { ...screen, name: `step-${i + 1}` };
|
|
228
|
+
if (!named.url) return named;
|
|
229
|
+
const steps = named.steps ?? [];
|
|
230
|
+
if (steps.length > 0 && steps[0]?.goto === named.url) return named;
|
|
231
|
+
return { ...named, steps: [{ goto: named.url }, ...steps] };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {import('../types.js').PageHandle} page
|
|
236
|
+
* @returns {string[]}
|
|
237
|
+
*/
|
|
238
|
+
function readConsole(page) {
|
|
239
|
+
try {
|
|
240
|
+
return page.consoleErrors();
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @template T
|
|
248
|
+
* @param {() => Promise<T>} fn
|
|
249
|
+
* @returns {Promise<T|null>}
|
|
250
|
+
*/
|
|
251
|
+
async function quietly(fn) {
|
|
252
|
+
try {
|
|
253
|
+
return await fn();
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Sortable, readable, and the same shape a run id uses: 20260829-014530.
|
|
261
|
+
* @param {Date} at
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
function walkId(at) {
|
|
265
|
+
const p = (/** @type {number} */ n) => String(n).padStart(2, '0');
|
|
266
|
+
return (
|
|
267
|
+
`${at.getFullYear()}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
|
268
|
+
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Two walks in the same second must not photograph into each other's folder.
|
|
274
|
+
* @param {string} resultsDir
|
|
275
|
+
* @param {string} id
|
|
276
|
+
* @returns {Promise<string>}
|
|
277
|
+
*/
|
|
278
|
+
async function makeWalkDir(resultsDir, id) {
|
|
279
|
+
await fsp.mkdir(resultsDir, { recursive: true });
|
|
280
|
+
for (let n = 0; n < 50; n++) {
|
|
281
|
+
const dir = path.join(resultsDir, n === 0 ? `walk-${id}` : `walk-${id}-${n + 1}`);
|
|
282
|
+
try {
|
|
283
|
+
// Deliberately not recursive: a clash must be caught, not silently shared.
|
|
284
|
+
await fsp.mkdir(dir, { recursive: false });
|
|
285
|
+
return dir;
|
|
286
|
+
} catch (cause) {
|
|
287
|
+
if (/** @type {NodeJS.ErrnoException} */ (cause).code !== 'EEXIST') throw cause;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
throw new StaysFixedError('Could not make a folder for this walkthrough.', {
|
|
291
|
+
hint: `Too many walks already sit in ${resultsDir}.`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// The contact sheet
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Write one self-contained HTML page showing every photo in order.
|
|
301
|
+
*
|
|
302
|
+
* Everything is inlined — the pictures as data URIs, the styling in a <style>
|
|
303
|
+
* block — so the file can be dragged into a chat, attached to a release, or
|
|
304
|
+
* opened months later on a machine that never had this project on it.
|
|
305
|
+
*
|
|
306
|
+
* @param {import('../types.js').WalkReport} report
|
|
307
|
+
* @param {string} file Absolute path to write.
|
|
308
|
+
* @returns {Promise<string>} the file written
|
|
309
|
+
*/
|
|
310
|
+
export async function writeWalkContactSheet(report, file) {
|
|
311
|
+
/** @type {string[]} */
|
|
312
|
+
const cards = [];
|
|
313
|
+
for (const step of report.steps) {
|
|
314
|
+
cards.push(await cardHtml(step));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const problems = report.steps.filter(
|
|
318
|
+
(s) => s.error || (s.consoleErrors ?? []).length > 0,
|
|
319
|
+
).length;
|
|
320
|
+
const where = [report.git.branch, report.git.shortSha].filter(Boolean).join(' · ');
|
|
321
|
+
|
|
322
|
+
const html = `<!doctype html>
|
|
323
|
+
<html lang="en">
|
|
324
|
+
<meta charset="utf-8">
|
|
325
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
326
|
+
<title>Walkthrough ${escapeHtml(report.id)}</title>
|
|
327
|
+
<style>
|
|
328
|
+
:root {
|
|
329
|
+
color-scheme: light dark;
|
|
330
|
+
--ground: #f6f6f4;
|
|
331
|
+
--card: #ffffff;
|
|
332
|
+
--ink: #1a1a19;
|
|
333
|
+
--soft: #6b6b66;
|
|
334
|
+
--line: #e2e2dd;
|
|
335
|
+
--bad: #b02a1e;
|
|
336
|
+
--good: #2f6f43;
|
|
337
|
+
}
|
|
338
|
+
@media (prefers-color-scheme: dark) {
|
|
339
|
+
:root {
|
|
340
|
+
--ground: #14140f;
|
|
341
|
+
--card: #1e1e1a;
|
|
342
|
+
--ink: #ecece6;
|
|
343
|
+
--soft: #9a9a92;
|
|
344
|
+
--line: #33332c;
|
|
345
|
+
--bad: #ff8a7a;
|
|
346
|
+
--good: #7fd39b;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
* { box-sizing: border-box; }
|
|
350
|
+
body {
|
|
351
|
+
margin: 0;
|
|
352
|
+
background: var(--ground);
|
|
353
|
+
color: var(--ink);
|
|
354
|
+
font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
355
|
+
padding: 32px 24px 64px;
|
|
356
|
+
}
|
|
357
|
+
header { max-width: 1100px; margin: 0 auto 28px; }
|
|
358
|
+
h1 { font-size: 22px; margin: 0 0 6px; letter-spacing: -0.01em; }
|
|
359
|
+
.meta { color: var(--soft); font-size: 13px; }
|
|
360
|
+
.meta b { color: var(--ink); font-weight: 600; }
|
|
361
|
+
.meta b.ok { color: var(--good); }
|
|
362
|
+
.grid {
|
|
363
|
+
max-width: 1100px;
|
|
364
|
+
margin: 0 auto;
|
|
365
|
+
display: grid;
|
|
366
|
+
gap: 22px;
|
|
367
|
+
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
|
368
|
+
}
|
|
369
|
+
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
|
|
370
|
+
.card {
|
|
371
|
+
background: var(--card);
|
|
372
|
+
border: 1px solid var(--line);
|
|
373
|
+
border-radius: 16px;
|
|
374
|
+
padding: 14px;
|
|
375
|
+
overflow: hidden;
|
|
376
|
+
}
|
|
377
|
+
.head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
|
|
378
|
+
.num {
|
|
379
|
+
font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
380
|
+
color: var(--soft);
|
|
381
|
+
background: var(--ground);
|
|
382
|
+
border: 1px solid var(--line);
|
|
383
|
+
border-radius: 999px;
|
|
384
|
+
padding: 5px 8px;
|
|
385
|
+
}
|
|
386
|
+
.name { font-weight: 600; }
|
|
387
|
+
.desc { color: var(--soft); font-size: 13px; margin: 2px 0 10px; }
|
|
388
|
+
.where {
|
|
389
|
+
color: var(--soft);
|
|
390
|
+
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
391
|
+
margin: 10px 0 0;
|
|
392
|
+
word-break: break-all;
|
|
393
|
+
}
|
|
394
|
+
.zoomer { position: absolute; opacity: 0; pointer-events: none; }
|
|
395
|
+
.frame { display: block; cursor: zoom-in; }
|
|
396
|
+
.frame img {
|
|
397
|
+
display: block;
|
|
398
|
+
width: 100%;
|
|
399
|
+
height: auto;
|
|
400
|
+
border-radius: 10px;
|
|
401
|
+
border: 1px solid var(--line);
|
|
402
|
+
background: var(--ground);
|
|
403
|
+
}
|
|
404
|
+
.zoomer:checked + .frame {
|
|
405
|
+
position: fixed;
|
|
406
|
+
inset: 0;
|
|
407
|
+
z-index: 50;
|
|
408
|
+
display: flex;
|
|
409
|
+
align-items: center;
|
|
410
|
+
justify-content: center;
|
|
411
|
+
padding: 24px;
|
|
412
|
+
background: rgba(0, 0, 0, 0.88);
|
|
413
|
+
cursor: zoom-out;
|
|
414
|
+
overflow: auto;
|
|
415
|
+
}
|
|
416
|
+
.zoomer:checked + .frame img {
|
|
417
|
+
width: auto;
|
|
418
|
+
max-width: 100%;
|
|
419
|
+
max-height: 100%;
|
|
420
|
+
border-radius: 4px;
|
|
421
|
+
}
|
|
422
|
+
.nopic {
|
|
423
|
+
border: 1px dashed var(--line);
|
|
424
|
+
border-radius: 10px;
|
|
425
|
+
padding: 34px 14px;
|
|
426
|
+
text-align: center;
|
|
427
|
+
color: var(--soft);
|
|
428
|
+
font-size: 13px;
|
|
429
|
+
}
|
|
430
|
+
.problem {
|
|
431
|
+
margin-top: 12px;
|
|
432
|
+
border-left: 3px solid var(--bad);
|
|
433
|
+
padding: 6px 0 6px 10px;
|
|
434
|
+
color: var(--bad);
|
|
435
|
+
font-size: 13px;
|
|
436
|
+
}
|
|
437
|
+
.problem h3 { font-size: 13px; margin: 0 0 4px; }
|
|
438
|
+
.problem ul { margin: 0; padding-left: 16px; }
|
|
439
|
+
.problem li { word-break: break-word; }
|
|
440
|
+
footer { max-width: 1100px; margin: 34px auto 0; color: var(--soft); font-size: 12px; }
|
|
441
|
+
</style>
|
|
442
|
+
<header>
|
|
443
|
+
<h1>Walkthrough — ${escapeHtml(readableId(report.id))}</h1>
|
|
444
|
+
<p class="meta">
|
|
445
|
+
<b>${report.steps.length}</b> screen${report.steps.length === 1 ? '' : 's'} ·
|
|
446
|
+
${problems === 0 ? '<b class="ok">nothing went wrong</b>' : `<b>${problems}</b> with something to look at`}
|
|
447
|
+
${where ? ` · ${escapeHtml(where)}` : ''}${report.git.dirty ? ' · uncommitted changes' : ''}
|
|
448
|
+
</p>
|
|
449
|
+
<p class="meta">Click any picture to see it full size. Click it again to come back.</p>
|
|
450
|
+
</header>
|
|
451
|
+
<main class="grid">
|
|
452
|
+
${cards.join('\n')}
|
|
453
|
+
</main>
|
|
454
|
+
<footer>Taken by Stays Fixed. The pictures are inside this file, so it works anywhere.</footer>
|
|
455
|
+
<script>
|
|
456
|
+
// Escape closes whichever picture is open. That is the whole script.
|
|
457
|
+
document.addEventListener('keydown', function (e) {
|
|
458
|
+
if (e.key !== 'Escape') return;
|
|
459
|
+
document.querySelectorAll('.zoomer:checked').forEach(function (box) { box.checked = false; });
|
|
460
|
+
});
|
|
461
|
+
</script>
|
|
462
|
+
</html>
|
|
463
|
+
`;
|
|
464
|
+
|
|
465
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
466
|
+
await fsp.writeFile(file, html);
|
|
467
|
+
report.reportFile = file;
|
|
468
|
+
return file;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* @param {import('../types.js').WalkStep} step
|
|
473
|
+
* @returns {Promise<string>}
|
|
474
|
+
*/
|
|
475
|
+
async function cardHtml(step) {
|
|
476
|
+
const id = `zoom-${step.index}`;
|
|
477
|
+
const src = await dataUri(step.file);
|
|
478
|
+
const picture = src
|
|
479
|
+
? `<input class="zoomer" type="checkbox" id="${id}">` +
|
|
480
|
+
`<label class="frame" for="${id}"><img alt="${escapeHtml(step.name)}" src="${src}"></label>`
|
|
481
|
+
: `<div class="nopic">No picture — the app never got this far.</div>`;
|
|
482
|
+
|
|
483
|
+
/** @type {string[]} */
|
|
484
|
+
const problems = [];
|
|
485
|
+
if (step.error) problems.push(`<h3>This screen could not be reached</h3><p>${escapeHtml(step.error)}</p>`);
|
|
486
|
+
if ((step.consoleErrors ?? []).length > 0) {
|
|
487
|
+
const list = (step.consoleErrors ?? [])
|
|
488
|
+
.slice(0, 20)
|
|
489
|
+
.map((e) => `<li>${escapeHtml(e)}</li>`)
|
|
490
|
+
.join('');
|
|
491
|
+
problems.push(`<h3>The app complained while this screen was open</h3><ul>${list}</ul>`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return ` <section class="card">
|
|
495
|
+
<div class="head"><span class="num">${String(step.index).padStart(2, '0')}</span><span class="name">${escapeHtml(step.name)}</span></div>
|
|
496
|
+
${step.describe ? `<p class="desc">${escapeHtml(step.describe)}</p>` : ''}
|
|
497
|
+
${picture}
|
|
498
|
+
${step.url || step.title ? `<p class="where">${escapeHtml([step.title, step.url].filter(Boolean).join(' — '))}</p>` : ''}
|
|
499
|
+
${problems.length ? `<div class="problem">${problems.join('')}</div>` : ''}
|
|
500
|
+
</section>`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* @param {string} file
|
|
505
|
+
* @returns {Promise<string|null>}
|
|
506
|
+
*/
|
|
507
|
+
async function dataUri(file) {
|
|
508
|
+
if (!file) return null;
|
|
509
|
+
try {
|
|
510
|
+
const png = await fsp.readFile(file);
|
|
511
|
+
return `data:image/png;base64,${png.toString('base64')}`;
|
|
512
|
+
} catch {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* '20260829-014530' -> '29 Aug 2026 at 01:45'.
|
|
519
|
+
* @param {string} id
|
|
520
|
+
* @returns {string}
|
|
521
|
+
*/
|
|
522
|
+
function readableId(id) {
|
|
523
|
+
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/.exec(id);
|
|
524
|
+
if (!m) return id;
|
|
525
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
526
|
+
const month = months[Number(m[2]) - 1] ?? m[2];
|
|
527
|
+
return `${Number(m[3])} ${month} ${m[1]} at ${m[4]}:${m[5]}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* @param {string} s
|
|
532
|
+
* @returns {string}
|
|
533
|
+
*/
|
|
534
|
+
function escapeHtml(s) {
|
|
535
|
+
return String(s)
|
|
536
|
+
.replace(/&/g, '&')
|
|
537
|
+
.replace(/</g, '<')
|
|
538
|
+
.replace(/>/g, '>')
|
|
539
|
+
.replace(/"/g, '"')
|
|
540
|
+
.replace(/'/g, ''');
|
|
541
|
+
}
|