walkwright 0.0.1
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 +276 -0
- package/bin/walkwright.js +3 -0
- package/build/aria.d.ts +92 -0
- package/build/aria.js +215 -0
- package/build/aria.js.map +1 -0
- package/build/browser.d.ts +3 -0
- package/build/browser.js +18 -0
- package/build/browser.js.map +1 -0
- package/build/chunk-5CHKYNP7.js +507 -0
- package/build/chunk-5CHKYNP7.js.map +1 -0
- package/build/chunk-6YRKPGUO.js +1197 -0
- package/build/chunk-6YRKPGUO.js.map +1 -0
- package/build/chunk-A452S7R3.js +35 -0
- package/build/chunk-A452S7R3.js.map +1 -0
- package/build/chunk-BODO6HIH.js +60 -0
- package/build/chunk-BODO6HIH.js.map +1 -0
- package/build/chunk-FJI2CMCE.js +439 -0
- package/build/chunk-FJI2CMCE.js.map +1 -0
- package/build/chunk-JYHZVRB2.js +80 -0
- package/build/chunk-JYHZVRB2.js.map +1 -0
- package/build/chunk-KQN5IYN6.js +97 -0
- package/build/chunk-KQN5IYN6.js.map +1 -0
- package/build/chunk-KTXNERHT.js +72 -0
- package/build/chunk-KTXNERHT.js.map +1 -0
- package/build/chunk-MFTC7XL2.js +141 -0
- package/build/chunk-MFTC7XL2.js.map +1 -0
- package/build/chunk-N4SSLHMP.js +108 -0
- package/build/chunk-N4SSLHMP.js.map +1 -0
- package/build/chunk-RNNW7OW2.js +270 -0
- package/build/chunk-RNNW7OW2.js.map +1 -0
- package/build/chunk-UAWBPTDW.js +51 -0
- package/build/chunk-UAWBPTDW.js.map +1 -0
- package/build/chunk-Z7CW5CAY.js +64 -0
- package/build/chunk-Z7CW5CAY.js.map +1 -0
- package/build/cli.d.ts +1 -0
- package/build/cli.js +253 -0
- package/build/cli.js.map +1 -0
- package/build/config.d.ts +13 -0
- package/build/config.js +15 -0
- package/build/config.js.map +1 -0
- package/build/descriptor.d.ts +96 -0
- package/build/descriptor.js +27 -0
- package/build/descriptor.js.map +1 -0
- package/build/device-auth.d.ts +6 -0
- package/build/device-auth.js +11 -0
- package/build/device-auth.js.map +1 -0
- package/build/locator.d.ts +140 -0
- package/build/locator.js +92 -0
- package/build/locator.js.map +1 -0
- package/build/paths.d.ts +11 -0
- package/build/paths.js +22 -0
- package/build/paths.js.map +1 -0
- package/build/pom-kcSMxwX2.d.ts +349 -0
- package/build/pom.d.ts +6 -0
- package/build/pom.js +37 -0
- package/build/pom.js.map +1 -0
- package/build/project.d.ts +13 -0
- package/build/project.js +19 -0
- package/build/project.js.map +1 -0
- package/build/provisioner.d.ts +34 -0
- package/build/provisioner.js +14 -0
- package/build/provisioner.js.map +1 -0
- package/build/recorder.d.ts +6 -0
- package/build/recorder.js +15 -0
- package/build/recorder.js.map +1 -0
- package/build/reporter.d.ts +16 -0
- package/build/reporter.js +426 -0
- package/build/reporter.js.map +1 -0
- package/build/schema.d.ts +49 -0
- package/build/schema.js +16 -0
- package/build/schema.js.map +1 -0
- package/build/storage-state.d.ts +22 -0
- package/build/storage-state.js +42 -0
- package/build/storage-state.js.map +1 -0
- package/build/store.d.ts +17 -0
- package/build/store.js +9 -0
- package/build/store.js.map +1 -0
- package/build/ui.d.ts +49 -0
- package/build/ui.js +56 -0
- package/build/ui.js.map +1 -0
- package/build/upload.d.ts +10 -0
- package/build/upload.js +8 -0
- package/build/upload.js.map +1 -0
- package/build/wait.d.ts +14 -0
- package/build/wait.js +50 -0
- package/build/wait.js.map +1 -0
- package/package.json +52 -0
- package/src/aria.ts +326 -0
- package/src/browser.ts +15 -0
- package/src/cli.ts +295 -0
- package/src/config.ts +16 -0
- package/src/descriptor.ts +179 -0
- package/src/device-auth.ts +188 -0
- package/src/locator.ts +683 -0
- package/src/paths.ts +70 -0
- package/src/pom.ts +2363 -0
- package/src/project.ts +77 -0
- package/src/provisioner.ts +183 -0
- package/src/recorder.ts +877 -0
- package/src/reporter.ts +561 -0
- package/src/schema.ts +37 -0
- package/src/storage-state.ts +71 -0
- package/src/store.ts +96 -0
- package/src/ui.ts +333 -0
- package/src/upload.ts +116 -0
- package/src/wait.ts +63 -0
package/src/reporter.ts
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
FullResult,
|
|
6
|
+
Reporter,
|
|
7
|
+
TestCase,
|
|
8
|
+
TestResult,
|
|
9
|
+
} from "@playwright/test/reporter";
|
|
10
|
+
|
|
11
|
+
import { ensureWalkwrightDir, reportsDir } from "./paths.js";
|
|
12
|
+
import type { WalkwrightConfig } from "./project.js";
|
|
13
|
+
import {
|
|
14
|
+
currentBranch,
|
|
15
|
+
describeConfigPath,
|
|
16
|
+
readWalkwrightConfig,
|
|
17
|
+
repoName,
|
|
18
|
+
} from "./project.js";
|
|
19
|
+
import type { RecordedShots, ShotRecord } from "./recorder.js";
|
|
20
|
+
import { describeError, runDirVariable, shotsAttachment } from "./recorder.js";
|
|
21
|
+
import type { Manifest, State, Subject } from "./schema.js";
|
|
22
|
+
import { manifestSchema, manifestVersion } from "./schema.js";
|
|
23
|
+
import {
|
|
24
|
+
blank,
|
|
25
|
+
bold,
|
|
26
|
+
box,
|
|
27
|
+
columns,
|
|
28
|
+
count,
|
|
29
|
+
duration,
|
|
30
|
+
failure,
|
|
31
|
+
glyph,
|
|
32
|
+
line,
|
|
33
|
+
pad,
|
|
34
|
+
paint,
|
|
35
|
+
progress,
|
|
36
|
+
status,
|
|
37
|
+
transient,
|
|
38
|
+
width,
|
|
39
|
+
} from "./ui.js";
|
|
40
|
+
import { uploadRun } from "./upload.js";
|
|
41
|
+
|
|
42
|
+
export interface ReporterOptions {
|
|
43
|
+
appUrl: string;
|
|
44
|
+
reportsDir?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Recorded {
|
|
48
|
+
test: string;
|
|
49
|
+
lane: string;
|
|
50
|
+
shots: RecordedShots;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface WalkFailure {
|
|
54
|
+
step: string;
|
|
55
|
+
lane: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface TreePage {
|
|
59
|
+
name: string;
|
|
60
|
+
group?: string[];
|
|
61
|
+
shots: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Folder {
|
|
65
|
+
name: string;
|
|
66
|
+
folders: Folder[];
|
|
67
|
+
pages: TreePage[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default class WalkwrightReporter implements Reporter {
|
|
71
|
+
readonly #appUrl: string;
|
|
72
|
+
readonly #startedAt = new Date();
|
|
73
|
+
readonly #runDir: string;
|
|
74
|
+
readonly #recorded: Recorded[] = [];
|
|
75
|
+
readonly #failures = new Map<string, WalkFailure>();
|
|
76
|
+
|
|
77
|
+
public constructor(options: ReporterOptions) {
|
|
78
|
+
this.#appUrl = options.appUrl;
|
|
79
|
+
|
|
80
|
+
if (options.reportsDir === undefined) {
|
|
81
|
+
ensureWalkwrightDir();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.#runDir = join(
|
|
85
|
+
options.reportsDir ?? reportsDir(),
|
|
86
|
+
`run-${this.#startedAt.toISOString().replace(/[:.]/g, "-")}`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
process.env[runDirVariable] = this.#runDir;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public onTestEnd(test: TestCase, result: TestResult): void {
|
|
93
|
+
if (result.status !== "passed") {
|
|
94
|
+
if (result.status !== "skipped") {
|
|
95
|
+
this.#failures.set(test.id, {
|
|
96
|
+
step: test.title,
|
|
97
|
+
lane: laneOf(test),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.#failures.delete(test.id);
|
|
105
|
+
|
|
106
|
+
for (const attachment of result.attachments) {
|
|
107
|
+
if (
|
|
108
|
+
attachment.name !== shotsAttachment ||
|
|
109
|
+
attachment.body === undefined
|
|
110
|
+
) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.#recorded.push({
|
|
115
|
+
test: test.title,
|
|
116
|
+
lane: laneOf(test),
|
|
117
|
+
shots: JSON.parse(attachment.body.toString("utf8")) as RecordedShots,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public async onEnd(
|
|
123
|
+
result: FullResult,
|
|
124
|
+
): Promise<{ status: FullResult["status"] } | undefined> {
|
|
125
|
+
const failures = [...this.#failures.values()];
|
|
126
|
+
|
|
127
|
+
if (this.#recorded.length === 0 && failures.length === 0) {
|
|
128
|
+
blank();
|
|
129
|
+
line(paint("dim", " no pages walked"));
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const elapsedMs = Date.now() - this.#startedAt.getTime();
|
|
134
|
+
|
|
135
|
+
let manifest: Manifest;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
manifest = this.#manifest(elapsedMs);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
blank();
|
|
141
|
+
failure(
|
|
142
|
+
describeError(error),
|
|
143
|
+
"fix the lane and run walkwright walk again",
|
|
144
|
+
);
|
|
145
|
+
return { status: "failed" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const branch = currentBranch();
|
|
149
|
+
|
|
150
|
+
blank();
|
|
151
|
+
|
|
152
|
+
for (const row of box(walkRows(treePages(this.#recorded), failures), {
|
|
153
|
+
legend: "walk",
|
|
154
|
+
title: `${repoName()} ${glyph.dot} ${branch}`,
|
|
155
|
+
primary: true,
|
|
156
|
+
})) {
|
|
157
|
+
line(row);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const shots = manifest.subjects.reduce(
|
|
161
|
+
(total, subject) =>
|
|
162
|
+
total +
|
|
163
|
+
subject.states.reduce(
|
|
164
|
+
(stateTotal, state) => stateTotal + state.captures.length,
|
|
165
|
+
0,
|
|
166
|
+
),
|
|
167
|
+
0,
|
|
168
|
+
);
|
|
169
|
+
const summary = `${count(shots, "shot")} ${paint("dim", glyph.dot)} ${duration(elapsedMs)}`;
|
|
170
|
+
const failed = result.status !== "passed";
|
|
171
|
+
const walkFailed = paint("fail", `${glyph.fail} walk failed`);
|
|
172
|
+
|
|
173
|
+
if (this.#recorded.length === 0) {
|
|
174
|
+
line(status(summary, walkFailed, paint("dim", "nothing to upload")));
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
mkdirSync(this.#runDir, { recursive: true });
|
|
179
|
+
|
|
180
|
+
writeFileSync(
|
|
181
|
+
join(this.#runDir, "manifest.json"),
|
|
182
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
183
|
+
"utf8",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const runDir = paint("code", relative(process.cwd(), this.#runDir));
|
|
187
|
+
|
|
188
|
+
if (shots === 0) {
|
|
189
|
+
line(status(summary, paint("dim", "nothing to upload"), runDir));
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (process.env["WALKWRIGHT_NO_UPLOAD"] === "1") {
|
|
194
|
+
line(status(summary, paint("dim", "not uploaded (--no-upload)"), runDir));
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (failed && process.env["WALKWRIGHT_UPLOAD_ON_FAIL"] !== "1") {
|
|
199
|
+
line(status(summary, walkFailed, paint("dim", "not uploaded"), runDir));
|
|
200
|
+
line(
|
|
201
|
+
paint(
|
|
202
|
+
"dim",
|
|
203
|
+
" walkwright walk --upload-on-fail uploads the passing lanes",
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let config: WalkwrightConfig | undefined;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
config = readWalkwrightConfig();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
line(
|
|
215
|
+
status(
|
|
216
|
+
summary,
|
|
217
|
+
failed ? walkFailed : "",
|
|
218
|
+
paint("dim", "not uploaded"),
|
|
219
|
+
runDir,
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
failure(
|
|
223
|
+
describeError(error),
|
|
224
|
+
`fix ${describeConfigPath()} or delete it and run walkwright login`,
|
|
225
|
+
);
|
|
226
|
+
return { status: "failed" };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (config === undefined) {
|
|
230
|
+
line(
|
|
231
|
+
status(
|
|
232
|
+
summary,
|
|
233
|
+
failed ? walkFailed : "",
|
|
234
|
+
paint("dim", "not uploaded"),
|
|
235
|
+
runDir,
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
line(
|
|
239
|
+
paint(
|
|
240
|
+
"dim",
|
|
241
|
+
` no ${describeConfigPath()} ${glyph.dot} walkwright login to upload`,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const kept = failed
|
|
248
|
+
? paint("warn", `${glyph.carried} kept ${this.#keptLanes().join(", ")}`)
|
|
249
|
+
: "";
|
|
250
|
+
|
|
251
|
+
if (!process.stdout.isTTY) {
|
|
252
|
+
line(paint("dim", ` uploading ${count(shots, "shot")}`));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const files = [
|
|
256
|
+
"manifest.json",
|
|
257
|
+
...manifest.subjects.flatMap((subject) =>
|
|
258
|
+
subject.states.flatMap((state) =>
|
|
259
|
+
state.captures.flatMap((capture) => Object.values(capture.files)),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
let uploaded: { url: string; number: number | undefined };
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
uploaded = await uploadRun(
|
|
268
|
+
config,
|
|
269
|
+
branch,
|
|
270
|
+
this.#runDir,
|
|
271
|
+
files,
|
|
272
|
+
uploadProgress,
|
|
273
|
+
);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
line(status(summary, failed ? walkFailed : "", kept));
|
|
276
|
+
failure(
|
|
277
|
+
describeError(error),
|
|
278
|
+
"run walkwright walk again to retry the upload",
|
|
279
|
+
);
|
|
280
|
+
return { status: "failed" };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const as =
|
|
284
|
+
uploaded.number === undefined
|
|
285
|
+
? "uploaded"
|
|
286
|
+
: `uploaded as ${bold(`#${String(uploaded.number)}`)}`;
|
|
287
|
+
const url = paint("accent", uploaded.url);
|
|
288
|
+
const room = process.stdout.isTTY ? columns() - 1 : Infinity;
|
|
289
|
+
|
|
290
|
+
for (const candidate of [
|
|
291
|
+
status(summary, failed ? walkFailed : "", kept, as, url),
|
|
292
|
+
status(summary, failed ? walkFailed : "", as, url),
|
|
293
|
+
]) {
|
|
294
|
+
if (width(candidate) <= room) {
|
|
295
|
+
line(candidate);
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const head = status(summary, failed ? walkFailed : "", kept, as);
|
|
301
|
+
|
|
302
|
+
line(
|
|
303
|
+
width(head) <= room
|
|
304
|
+
? head
|
|
305
|
+
: status(summary, failed ? walkFailed : "", as),
|
|
306
|
+
);
|
|
307
|
+
line(` ${url}`);
|
|
308
|
+
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#keptLanes(): string[] {
|
|
313
|
+
return [
|
|
314
|
+
...new Set(
|
|
315
|
+
this.#recorded
|
|
316
|
+
.filter(({ shots }) => shots.shots.length > 0)
|
|
317
|
+
.map(({ lane }) => lane),
|
|
318
|
+
),
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#manifest(durationMs: number): Manifest {
|
|
323
|
+
const subjects: Subject[] = [];
|
|
324
|
+
const byPath = new Map<string, Subject>();
|
|
325
|
+
const captured = new Map<string, string>();
|
|
326
|
+
const files = new Map<string, string>();
|
|
327
|
+
|
|
328
|
+
for (const { lane, shots } of this.#recorded) {
|
|
329
|
+
for (const shot of shots.shots) {
|
|
330
|
+
const path = [...(shot.group ?? []), shot.page];
|
|
331
|
+
const pathKey = JSON.stringify(path);
|
|
332
|
+
const name = shot.state === "" ? "default" : shot.state;
|
|
333
|
+
const identityKey = `${pathKey}#${name}`;
|
|
334
|
+
|
|
335
|
+
const identityLane = captured.get(identityKey);
|
|
336
|
+
|
|
337
|
+
if (identityLane !== undefined) {
|
|
338
|
+
if (identityLane !== lane) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`"${identityLane}" and "${lane}" both captured ${describeShot(shot)} — record it in one lane, or give one of them another state`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
throw new Error(
|
|
345
|
+
`"${lane}" captured ${describeShot(shot)} twice — record it once, or give one of them another state`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
captured.set(identityKey, lane);
|
|
350
|
+
|
|
351
|
+
const fileLane = files.get(shot.file);
|
|
352
|
+
|
|
353
|
+
if (fileLane !== undefined && fileLane !== lane) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`"${fileLane}" and "${lane}" both wrote ${shot.file} — rename one of the "${shot.page}" pages`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
files.set(shot.file, lane);
|
|
360
|
+
|
|
361
|
+
let subject = byPath.get(pathKey);
|
|
362
|
+
|
|
363
|
+
if (subject === undefined) {
|
|
364
|
+
const url =
|
|
365
|
+
shot.path !== undefined && !shot.path.includes(":")
|
|
366
|
+
? shot.path
|
|
367
|
+
: undefined;
|
|
368
|
+
|
|
369
|
+
subject = {
|
|
370
|
+
path,
|
|
371
|
+
...(url === undefined ? {} : { url }),
|
|
372
|
+
states: [],
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
byPath.set(pathKey, subject);
|
|
376
|
+
subjects.push(subject);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const state: State = {
|
|
380
|
+
name,
|
|
381
|
+
captures: [{ files: { image: shot.file } }],
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
subject.states.push(state);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return manifestSchema.parse({
|
|
389
|
+
version: manifestVersion,
|
|
390
|
+
tool: "walkwright",
|
|
391
|
+
url: this.#appUrl,
|
|
392
|
+
startedAt: this.#startedAt.toISOString(),
|
|
393
|
+
runStats: {
|
|
394
|
+
duration: duration(durationMs),
|
|
395
|
+
lanes: this.#keptLanes().length,
|
|
396
|
+
},
|
|
397
|
+
subjects,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function describeShot(shot: ShotRecord): string {
|
|
403
|
+
const page =
|
|
404
|
+
shot.path === undefined
|
|
405
|
+
? `"${shot.page}"`
|
|
406
|
+
: `"${shot.page}" (${shot.path})`;
|
|
407
|
+
|
|
408
|
+
return shot.state === "" ? page : `the "${shot.state}" state of ${page}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function laneOf(test: TestCase): string {
|
|
412
|
+
return basename(test.location.file).replace(/(\.\w+)?\.ts$/, "");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function treePages(recorded: Recorded[]): TreePage[] {
|
|
416
|
+
const byKey = new Map<string, TreePage>();
|
|
417
|
+
const pages: TreePage[] = [];
|
|
418
|
+
|
|
419
|
+
const keyOf = (group: string[] | undefined, name: string): string =>
|
|
420
|
+
JSON.stringify([...(group ?? []), name]);
|
|
421
|
+
|
|
422
|
+
for (const { shots } of recorded) {
|
|
423
|
+
for (const page of shots.pages) {
|
|
424
|
+
const key = keyOf(page.group, page.name);
|
|
425
|
+
|
|
426
|
+
if (!byKey.has(key)) {
|
|
427
|
+
const entry: TreePage = {
|
|
428
|
+
name: page.name,
|
|
429
|
+
...(page.group === undefined ? {} : { group: page.group }),
|
|
430
|
+
shots: 0,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
byKey.set(key, entry);
|
|
434
|
+
pages.push(entry);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
for (const { shots } of recorded) {
|
|
440
|
+
for (const shot of shots.shots) {
|
|
441
|
+
const key = keyOf(shot.group, shot.page);
|
|
442
|
+
let entry = byKey.get(key);
|
|
443
|
+
|
|
444
|
+
if (entry === undefined) {
|
|
445
|
+
entry = {
|
|
446
|
+
name: shot.page,
|
|
447
|
+
...(shot.group === undefined ? {} : { group: shot.group }),
|
|
448
|
+
shots: 0,
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
byKey.set(key, entry);
|
|
452
|
+
pages.push(entry);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
entry.shots += 1;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return pages;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function walkRows(pages: TreePage[], failures: WalkFailure[]): string[] {
|
|
463
|
+
const root = buildTree(pages);
|
|
464
|
+
const rows: [label: string, note: string][] = [];
|
|
465
|
+
|
|
466
|
+
const walk = (folder: Folder, depth: number): void => {
|
|
467
|
+
const indent = " ".repeat(depth);
|
|
468
|
+
const entries = folder.pages;
|
|
469
|
+
|
|
470
|
+
for (const [index, page] of entries.entries()) {
|
|
471
|
+
const last = index === entries.length - 1 && folder.folders.length === 0;
|
|
472
|
+
|
|
473
|
+
rows.push([
|
|
474
|
+
`${indent}${paint("faint", last ? glyph.last : glyph.branch)} ${paint("text", page.name)}`,
|
|
475
|
+
page.shots === 0
|
|
476
|
+
? paint("dim", "no shots")
|
|
477
|
+
: `${paint("ok", glyph.ok)} ${paint("dim", count(page.shots, "shot"))}`,
|
|
478
|
+
]);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (const child of folder.folders) {
|
|
482
|
+
rows.push([
|
|
483
|
+
`${indent}${paint("accent", glyph.open)} ${bold(paint("text", child.name))}`,
|
|
484
|
+
"",
|
|
485
|
+
]);
|
|
486
|
+
walk(child, depth + 1);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
walk(root, 0);
|
|
491
|
+
|
|
492
|
+
const labelWidth = rows.reduce(
|
|
493
|
+
(max, [label]) => Math.max(max, width(label)),
|
|
494
|
+
0,
|
|
495
|
+
);
|
|
496
|
+
const out = rows.map(([label, note]) =>
|
|
497
|
+
note === "" ? label : `${pad(label, labelWidth)} ${note}`,
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
if (failures.length > 0) {
|
|
501
|
+
if (out.length > 0) {
|
|
502
|
+
out.push("");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const failed of failures) {
|
|
506
|
+
out.push(
|
|
507
|
+
`${paint("fail", `${glyph.fail} ${failed.lane}`)} ${paint("dim", glyph.dot)} ${paint("text", `"${failed.step}"`)} ${paint("fail", "failed")}`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function buildTree(pages: TreePage[]): Folder {
|
|
516
|
+
const root: Folder = { name: "", folders: [], pages: [] };
|
|
517
|
+
|
|
518
|
+
for (const page of pages) {
|
|
519
|
+
let folder = root;
|
|
520
|
+
|
|
521
|
+
for (const name of page.group ?? []) {
|
|
522
|
+
let next = folder.folders.find((child) => child.name === name);
|
|
523
|
+
|
|
524
|
+
if (next === undefined) {
|
|
525
|
+
next = { name, folders: [], pages: [] };
|
|
526
|
+
folder.folders.push(next);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
folder = next;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
folder.pages.push(page);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
sortFolder(root);
|
|
536
|
+
|
|
537
|
+
return root;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function sortFolder(folder: Folder): void {
|
|
541
|
+
folder.pages.sort((a, b) => compare(a.name, b.name));
|
|
542
|
+
folder.folders.sort((a, b) => compare(a.name, b.name));
|
|
543
|
+
|
|
544
|
+
for (const child of folder.folders) {
|
|
545
|
+
sortFolder(child);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function compare(a: string, b: string): number {
|
|
550
|
+
return a.localeCompare(b, undefined, { sensitivity: "base" });
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function uploadProgress(done: number, total: number, file: string): void {
|
|
554
|
+
const clear = transient(
|
|
555
|
+
` ${progress(done, total)} ${paint("dim", `uploading ${String(done)}/${String(total)}`)} ${paint("code", file)}`,
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
if (done === total) {
|
|
559
|
+
clear();
|
|
560
|
+
}
|
|
561
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const manifestVersion = 5;
|
|
4
|
+
|
|
5
|
+
export const captureSchema = z.object({
|
|
6
|
+
variant: z.record(z.string().min(1), z.string()).optional(),
|
|
7
|
+
files: z
|
|
8
|
+
.record(z.string().min(1), z.string().min(1))
|
|
9
|
+
.refine((files) => typeof files["image"] === "string", {
|
|
10
|
+
message: "files.image is required",
|
|
11
|
+
}),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const stateSchema = z.object({
|
|
15
|
+
name: z.string().min(1),
|
|
16
|
+
captures: z.array(captureSchema).min(1),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const subjectSchema = z.object({
|
|
20
|
+
path: z.array(z.string().min(1)).min(1),
|
|
21
|
+
url: z.string().optional(),
|
|
22
|
+
states: z.array(stateSchema).min(1),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const manifestSchema = z.object({
|
|
26
|
+
version: z.literal(manifestVersion),
|
|
27
|
+
tool: z.string().optional(),
|
|
28
|
+
url: z.string().optional(),
|
|
29
|
+
startedAt: z.iso.datetime().optional(),
|
|
30
|
+
runStats: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
|
|
31
|
+
subjects: z.array(subjectSchema),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type Capture = z.infer<typeof captureSchema>;
|
|
35
|
+
export type State = z.infer<typeof stateSchema>;
|
|
36
|
+
export type Subject = z.infer<typeof subjectSchema>;
|
|
37
|
+
export type Manifest = z.infer<typeof manifestSchema>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { BrowserContext, BrowserContextOptions } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
import type { StoreScope } from "./paths.js";
|
|
4
|
+
import { createStore } from "./store.js";
|
|
5
|
+
|
|
6
|
+
export type StorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;
|
|
7
|
+
|
|
8
|
+
export type StorageStateCookie = StorageState["cookies"][number];
|
|
9
|
+
|
|
10
|
+
export type StorageStateOption = NonNullable<
|
|
11
|
+
BrowserContextOptions["storageState"]
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
export interface StorageStateStoreOptions {
|
|
15
|
+
scope?: StoreScope;
|
|
16
|
+
cookies?: (cookie: StorageStateCookie) => boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StorageStateStore {
|
|
20
|
+
readonly path: string;
|
|
21
|
+
exists: () => boolean;
|
|
22
|
+
save: (context: BrowserContext) => Promise<void>;
|
|
23
|
+
restore: (context: BrowserContext) => Promise<void>;
|
|
24
|
+
clear: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const emptyStorageState: StorageStateOption = {
|
|
28
|
+
cookies: [],
|
|
29
|
+
origins: [],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function createStorageStateStore(
|
|
33
|
+
name: string,
|
|
34
|
+
{ scope = "worker", cookies }: StorageStateStoreOptions = {},
|
|
35
|
+
): StorageStateStore {
|
|
36
|
+
const store = createStore<StorageState>(name, { scope });
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
get path(): string {
|
|
40
|
+
return store.path;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
exists: store.exists,
|
|
44
|
+
|
|
45
|
+
save: async (context): Promise<void> => {
|
|
46
|
+
const state = await context.storageState();
|
|
47
|
+
|
|
48
|
+
store.persist(
|
|
49
|
+
cookies === undefined
|
|
50
|
+
? state
|
|
51
|
+
: { cookies: state.cookies.filter(cookies), origins: [] },
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
restore: async (context): Promise<void> => {
|
|
56
|
+
const state = store.read();
|
|
57
|
+
|
|
58
|
+
if (state !== undefined && state.cookies.length > 0) {
|
|
59
|
+
await context.addCookies(state.cookies);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
clear: store.clear,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function storageStateOf(
|
|
68
|
+
...stores: StorageStateStore[]
|
|
69
|
+
): StorageStateOption {
|
|
70
|
+
return stores.find((store) => store.exists())?.path ?? emptyStorageState;
|
|
71
|
+
}
|