@itmetsam/samurai-framework 0.7.1 → 0.9.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/README.md +31 -12
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/browser-finder.js +2 -2
- package/dist/browser/browser.d.ts +11 -15
- package/dist/browser/browser.js +79 -58
- package/dist/browser/chrome-launch.d.ts +57 -0
- package/dist/browser/chrome-launch.js +127 -0
- package/dist/browser/download-folders.d.ts +27 -0
- package/dist/browser/download-folders.js +97 -0
- package/dist/browser/file-chooser.d.ts +4 -3
- package/dist/browser/file-chooser.js +9 -8
- package/dist/browser/generated-files.d.ts +30 -0
- package/dist/browser/generated-files.js +116 -0
- package/dist/browser/page-logs.d.ts +2 -0
- package/dist/browser/page-logs.js +25 -5
- package/dist/browser/page.d.ts +37 -0
- package/dist/browser/page.js +129 -2
- package/dist/browser/screenshot.js +16 -2
- package/dist/browser/session-capabilities.d.ts +12 -0
- package/dist/browser/session-capabilities.js +8 -0
- package/dist/browsers/index.d.ts +2 -2
- package/dist/browsers/index.js +2 -2
- package/dist/browsers/overrides.d.ts +2 -0
- package/dist/browsers/overrides.js +2 -0
- package/dist/browsers/pins.d.ts +1 -1
- package/dist/browsers/pins.js +2 -2
- package/dist/browsers/resolve.d.ts +2 -0
- package/dist/browsers/resolve.js +14 -2
- package/dist/browsers/store.d.ts +2 -0
- package/dist/browsers/store.js +8 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +20 -5
- package/dist/locator/locator.d.ts +17 -5
- package/dist/locator/locator.js +77 -10
- package/dist/network/data-collector.d.ts +1 -0
- package/dist/network/data-collector.js +23 -5
- package/dist/network/network-tracker.d.ts +8 -1
- package/dist/network/network-tracker.js +42 -3
- package/dist/network/router.d.ts +5 -0
- package/dist/network/router.js +33 -11
- package/dist/project/files-store.d.ts +28 -0
- package/dist/project/files-store.js +106 -0
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +1 -0
- package/dist/recorder/apply.js +8 -6
- package/dist/recorder/files-capture.d.ts +26 -0
- package/dist/recorder/files-capture.js +92 -0
- package/dist/recorder/files-reader.d.ts +7 -0
- package/dist/recorder/files-reader.js +25 -0
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/normalise.d.ts +26 -2
- package/dist/recorder/normalise.js +48 -1
- package/dist/recorder/recorder.d.ts +38 -0
- package/dist/recorder/recorder.js +160 -1
- package/dist/recorder/session.d.ts +14 -1
- package/dist/recorder/session.js +36 -9
- package/dist/recorder/toolbar/controller.d.ts +15 -5
- package/dist/recorder/toolbar/controller.js +61 -7
- package/dist/recorder/toolbar/protocol.d.ts +10 -0
- package/dist/recorder/toolbar/source.d.ts +1 -1
- package/dist/recorder/toolbar/source.js +35 -2
- package/dist/recorder/toolbar/styles.d.ts +1 -1
- package/dist/recorder/toolbar/styles.js +31 -0
- package/dist/runner/project.js +2 -0
- package/dist/script/preload.js +65 -0
- package/dist/steps/edit.js +38 -3
- package/dist/steps/emit.d.ts +3 -1
- package/dist/steps/emit.js +19 -0
- package/dist/steps/files.d.ts +8 -0
- package/dist/steps/files.js +33 -0
- package/dist/steps/index.d.ts +3 -2
- package/dist/steps/index.js +2 -1
- package/dist/steps/model.d.ts +28 -0
- package/dist/steps/parse.js +79 -0
- package/dist/transport/bidi-connection.d.ts +6 -0
- package/dist/transport/bidi-connection.js +13 -0
- package/dist/types/browser.d.ts +1 -1
- package/dist/types/config.d.ts +7 -2
- package/package.json +3 -2
- package/dist/browser/file-paths.d.ts +0 -4
- package/dist/browser/file-paths.js +0 -18
|
@@ -21,6 +21,24 @@ export type ToolbarOptions = ControllerOptions & {
|
|
|
21
21
|
*/
|
|
22
22
|
onStop?: () => void;
|
|
23
23
|
};
|
|
24
|
+
/** Where uploads made while recording are stored */
|
|
25
|
+
export interface FileRecording {
|
|
26
|
+
/** The project folder */
|
|
27
|
+
projectDir: string;
|
|
28
|
+
/** Relative to the project folder. @default "files" */
|
|
29
|
+
filesDir?: string;
|
|
30
|
+
/** A file that isn't stored (over 50 MB, or unreadable): its step points at `path`, which doesn't exist */
|
|
31
|
+
onMissing?: (file: MissingFile) => void;
|
|
32
|
+
/** @internal Tests only: also record events no person caused (`isTrusted` false) */
|
|
33
|
+
trustSynthetic?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface MissingFile {
|
|
36
|
+
/** Project-relative, as the step has it */
|
|
37
|
+
path: string;
|
|
38
|
+
name: string;
|
|
39
|
+
size: number;
|
|
40
|
+
reason: "too-big" | "unreadable";
|
|
41
|
+
}
|
|
24
42
|
export interface RecorderOptions {
|
|
25
43
|
/** Called for every step the tester produces, in order. Steps get indexes from `at` on */
|
|
26
44
|
onEvent: (event: RecorderEvent) => void;
|
|
@@ -36,6 +54,8 @@ export interface RecorderOptions {
|
|
|
36
54
|
baseURL?: string;
|
|
37
55
|
/** Shows the recorder toolbar in the page (pause, assert, insert, wait, screenshot, stop). Without it, no toolbar */
|
|
38
56
|
toolbar?: ToolbarOptions;
|
|
57
|
+
/** Records uploads (file inputs and drops) into the project's files folder. Without it, uploads aren't recorded */
|
|
58
|
+
files?: FileRecording;
|
|
39
59
|
}
|
|
40
60
|
/**
|
|
41
61
|
* Watches the tester use the page and reports steps as they happen. A preload script reports clicks,
|
|
@@ -55,9 +75,21 @@ export declare class Recorder {
|
|
|
55
75
|
private readonly queue;
|
|
56
76
|
private readonly known;
|
|
57
77
|
private stopped;
|
|
78
|
+
/** Set when stopping begins: files messages after it are not taken */
|
|
79
|
+
private ending;
|
|
58
80
|
private stopping;
|
|
59
81
|
private controller;
|
|
60
82
|
private constructor();
|
|
83
|
+
/** The files capture script's channel, handed only to its sandbox */
|
|
84
|
+
private readonly filesChannel;
|
|
85
|
+
private filesPreload;
|
|
86
|
+
private filesRealms;
|
|
87
|
+
private readonly stored;
|
|
88
|
+
private readonly missing;
|
|
89
|
+
/** Files stored or reused in the files folder by this recording, project-relative */
|
|
90
|
+
get storedFiles(): string[];
|
|
91
|
+
/** Files whose steps point at a path that wasn't written */
|
|
92
|
+
get missingFiles(): MissingFile[];
|
|
61
93
|
/** The context's `samurai-toolbar` sandbox realms, as last listed: the only realms toolbar messages are taken from */
|
|
62
94
|
private toolbarRealms;
|
|
63
95
|
/** Toolbar messages waiting for a realm lookup, and those that arrived behind them (kept in order) */
|
|
@@ -75,6 +107,7 @@ export declare class Recorder {
|
|
|
75
107
|
* holds if a page realm ever got the toolbar's channel.
|
|
76
108
|
*/
|
|
77
109
|
private readonly onMessage;
|
|
110
|
+
private listFilesRealms;
|
|
78
111
|
private listToolbarRealms;
|
|
79
112
|
/** A message from the toolbar's own sandbox */
|
|
80
113
|
private fromToolbar;
|
|
@@ -91,6 +124,9 @@ export declare class Recorder {
|
|
|
91
124
|
private emit;
|
|
92
125
|
private locatorFor;
|
|
93
126
|
private handle;
|
|
127
|
+
private fromFiles;
|
|
128
|
+
/** Saves one kept file to the files folder; returns the path its step gets */
|
|
129
|
+
private storePageFile;
|
|
94
130
|
private release;
|
|
95
131
|
/**
|
|
96
132
|
* Pauses capture in the loaded document: clicks and typing are not reported until `unmute`.
|
|
@@ -104,3 +140,5 @@ export declare class Recorder {
|
|
|
104
140
|
stop(): Promise<void>;
|
|
105
141
|
private end;
|
|
106
142
|
}
|
|
143
|
+
/** The warning for a file that isn't saved, as the toolbar and the CLI show it */
|
|
144
|
+
export declare function missingFileText(file: MissingFile, folder: string): string;
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import logger from "../logger/index.js";
|
|
3
6
|
import { addPreload, runInLoadedContexts } from "../script/preload.js";
|
|
4
7
|
import { callFunction } from "../script/call-function.js";
|
|
5
8
|
import { ChannelArg, fromRemoteValue } from "../script/serialize.js";
|
|
6
9
|
import { ElementHandle } from "../script/element-handle.js";
|
|
7
10
|
import { locatorFromSpec } from "../steps/build.js";
|
|
8
|
-
import { addVariable, setSecret } from "../project/index.js";
|
|
11
|
+
import { addVariable, cleanFileName, filesFolder, RECORD_LIMIT, setSecret, storeFile, } from "../project/index.js";
|
|
12
|
+
import { humanSize } from "../steps/files.js";
|
|
9
13
|
import { CAPTURE_MUTE, CAPTURE_OFF, CAPTURE_SOURCE, CAPTURE_UNMUTE, } from "./capture.js";
|
|
14
|
+
import { FILES_SANDBOX, filesCaptureSource } from "./files-capture.js";
|
|
15
|
+
import { disposeFilesCapture, readPageFile, releasePageFile, } from "./files-reader.js";
|
|
10
16
|
import { Normaliser, } from "./normalise.js";
|
|
11
17
|
import { rankLocator } from "./ranking.js";
|
|
12
18
|
import { ToolbarController, } from "./toolbar/controller.js";
|
|
@@ -36,6 +42,13 @@ export class Recorder {
|
|
|
36
42
|
this.queue = { tail: Promise.resolve() };
|
|
37
43
|
this.known = new Map();
|
|
38
44
|
this.stopped = false;
|
|
45
|
+
/** Set when stopping begins: files messages after it are not taken */
|
|
46
|
+
this.ending = false;
|
|
47
|
+
/** The files capture script's channel, handed only to its sandbox */
|
|
48
|
+
this.filesChannel = `samurai-files-${randomUUID()}`;
|
|
49
|
+
this.filesRealms = new Set();
|
|
50
|
+
this.stored = new Set();
|
|
51
|
+
this.missing = [];
|
|
39
52
|
/** The context's `samurai-toolbar` sandbox realms, as last listed: the only realms toolbar messages are taken from */
|
|
40
53
|
this.toolbarRealms = new Set();
|
|
41
54
|
/** Toolbar messages waiting for a realm lookup, and those that arrived behind them (kept in order) */
|
|
@@ -56,6 +69,23 @@ export class Recorder {
|
|
|
56
69
|
this.enqueue(async () => this.handle(message));
|
|
57
70
|
return;
|
|
58
71
|
}
|
|
72
|
+
if (params.channel === this.filesChannel) {
|
|
73
|
+
if (this.ending)
|
|
74
|
+
return;
|
|
75
|
+
const realm = params.source.realm ?? "";
|
|
76
|
+
const data = params.data;
|
|
77
|
+
// In order with the page's messages: a picker follows the click that opened it
|
|
78
|
+
this.enqueue(async () => {
|
|
79
|
+
if (!this.filesRealms.has(realm))
|
|
80
|
+
await this.listFilesRealms();
|
|
81
|
+
if (!this.filesRealms.has(realm)) {
|
|
82
|
+
logger.warn("Recorder ignored a files message from outside the files sandbox");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await this.fromFiles(fromRemoteValue(data));
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
59
89
|
if (params.channel !== this.toolbarChannel || !this.controller)
|
|
60
90
|
return;
|
|
61
91
|
const realm = params.source.realm ?? "";
|
|
@@ -96,6 +126,14 @@ export class Recorder {
|
|
|
96
126
|
});
|
|
97
127
|
};
|
|
98
128
|
}
|
|
129
|
+
/** Files stored or reused in the files folder by this recording, project-relative */
|
|
130
|
+
get storedFiles() {
|
|
131
|
+
return [...this.stored];
|
|
132
|
+
}
|
|
133
|
+
/** Files whose steps point at a path that wasn't written */
|
|
134
|
+
get missingFiles() {
|
|
135
|
+
return [...this.missing];
|
|
136
|
+
}
|
|
99
137
|
static async start(target, options) {
|
|
100
138
|
const at = options.at ?? 0;
|
|
101
139
|
const normaliser = new Normaliser({
|
|
@@ -108,6 +146,15 @@ export class Recorder {
|
|
|
108
146
|
await recorder.begin(options.initialGoto ?? true);
|
|
109
147
|
return recorder;
|
|
110
148
|
}
|
|
149
|
+
async listFilesRealms() {
|
|
150
|
+
const { realms } = await this.connector.send("script.getRealms", {
|
|
151
|
+
context: this.context,
|
|
152
|
+
type: "window",
|
|
153
|
+
});
|
|
154
|
+
this.filesRealms = new Set(realms
|
|
155
|
+
.filter((info) => info.type === "window" && info.sandbox === FILES_SANDBOX)
|
|
156
|
+
.map((info) => info.realm));
|
|
157
|
+
}
|
|
111
158
|
async listToolbarRealms() {
|
|
112
159
|
const { realms } = await this.connector.send("script.getRealms", {
|
|
113
160
|
context: this.context,
|
|
@@ -157,6 +204,17 @@ export class Recorder {
|
|
|
157
204
|
arguments: [new ChannelArg(this.channel)],
|
|
158
205
|
onRunError: "log",
|
|
159
206
|
});
|
|
207
|
+
if (this.options.files) {
|
|
208
|
+
this.filesPreload = await addPreload(this.connector, {
|
|
209
|
+
source: filesCaptureSource({
|
|
210
|
+
trustSynthetic: this.options.files.trustSynthetic === true,
|
|
211
|
+
}),
|
|
212
|
+
sandbox: FILES_SANDBOX,
|
|
213
|
+
contexts: [this.context],
|
|
214
|
+
arguments: [new ChannelArg(this.filesChannel)],
|
|
215
|
+
onRunError: "log",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
160
218
|
if (this.controller) {
|
|
161
219
|
this.toolbarPreload = await addPreload(this.connector, {
|
|
162
220
|
source: TOOLBAR_SOURCE,
|
|
@@ -225,6 +283,8 @@ export class Recorder {
|
|
|
225
283
|
this.options.onEvent(event);
|
|
226
284
|
if (event.op === "insert")
|
|
227
285
|
this.controller?.stepAdded(event.step);
|
|
286
|
+
else if (event.op === "remove")
|
|
287
|
+
this.controller?.stepRemoved();
|
|
228
288
|
}
|
|
229
289
|
}
|
|
230
290
|
async locatorFor(message) {
|
|
@@ -269,6 +329,93 @@ export class Recorder {
|
|
|
269
329
|
};
|
|
270
330
|
this.emit(this.normaliser.observe(observation, Date.now()));
|
|
271
331
|
}
|
|
332
|
+
async fromFiles(message) {
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
if (message.type !== "files") {
|
|
335
|
+
if (!this.controller?.paused)
|
|
336
|
+
this.emit(this.normaliser.observe(message, now));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const release = (from) => Promise.allSettled(message.files
|
|
340
|
+
.slice(from)
|
|
341
|
+
.map((file) => releasePageFile(this.connector, this.context, file.id)));
|
|
342
|
+
if (this.controller?.paused)
|
|
343
|
+
return void (await release(0));
|
|
344
|
+
// storePageFile releases its own file; whatever it never reached is released here, on any exit
|
|
345
|
+
let started = 0;
|
|
346
|
+
try {
|
|
347
|
+
const locator = await this.locatorFor(message);
|
|
348
|
+
if (!locator) {
|
|
349
|
+
logger.warn("Recorder found no locator that matches only the element the files were %s; the upload was skipped", message.via === "drop" ? "dropped on" : "set on");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const files = [];
|
|
353
|
+
for (const file of message.files) {
|
|
354
|
+
started++;
|
|
355
|
+
files.push({ path: await this.storePageFile(file) });
|
|
356
|
+
}
|
|
357
|
+
this.emit(this.normaliser.observe({ type: "files", via: message.via, locator, files }, now));
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
await release(started);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/** Saves one kept file to the files folder; returns the path its step gets */
|
|
364
|
+
async storePageFile(file) {
|
|
365
|
+
const recording = this.options.files;
|
|
366
|
+
const folder = filesFolder(recording.projectDir, recording.filesDir);
|
|
367
|
+
const notSaved = (reason) => {
|
|
368
|
+
const missing = {
|
|
369
|
+
path: folder.relative
|
|
370
|
+
? `${folder.relative}/${cleanFileName(file.name)}`
|
|
371
|
+
: cleanFileName(file.name),
|
|
372
|
+
name: file.name,
|
|
373
|
+
size: file.size,
|
|
374
|
+
reason,
|
|
375
|
+
};
|
|
376
|
+
this.missing.push(missing);
|
|
377
|
+
recording.onMissing?.(missing);
|
|
378
|
+
this.controller?.warn(missingFileText(missing, folder.relative));
|
|
379
|
+
return missing.path;
|
|
380
|
+
};
|
|
381
|
+
if (file.size > RECORD_LIMIT) {
|
|
382
|
+
await releasePageFile(this.connector, this.context, file.id).catch(() => undefined);
|
|
383
|
+
return notSaved("too-big");
|
|
384
|
+
}
|
|
385
|
+
this.controller?.saving(file.name);
|
|
386
|
+
let temp;
|
|
387
|
+
try {
|
|
388
|
+
temp = await mkdtemp(path.join(tmpdir(), "samurai-upload-"));
|
|
389
|
+
const content = path.join(temp, "content");
|
|
390
|
+
const out = await open(content, "w");
|
|
391
|
+
try {
|
|
392
|
+
await readPageFile(this.connector, this.context, file.id, file.size, (bytes) => out.write(bytes));
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
await out.close();
|
|
396
|
+
}
|
|
397
|
+
const stored = await storeFile({
|
|
398
|
+
projectDir: recording.projectDir,
|
|
399
|
+
...(recording.filesDir !== undefined && {
|
|
400
|
+
filesDir: recording.filesDir,
|
|
401
|
+
}),
|
|
402
|
+
name: file.name,
|
|
403
|
+
content,
|
|
404
|
+
});
|
|
405
|
+
this.stored.add(stored.path);
|
|
406
|
+
return stored.path;
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
logger.warn("Recorder could not save %s", file.name, { err });
|
|
410
|
+
return notSaved("unreadable");
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
this.controller?.saving(undefined);
|
|
414
|
+
if (temp)
|
|
415
|
+
await rm(temp, { recursive: true, force: true }).catch(() => undefined);
|
|
416
|
+
await releasePageFile(this.connector, this.context, file.id).catch(() => undefined);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
272
419
|
async release() {
|
|
273
420
|
this.connector.offEvent("script.message", this.onMessage);
|
|
274
421
|
this.connector.offEvent("browsingContext.navigationStarted", this.onNavigation);
|
|
@@ -277,6 +424,7 @@ export class Recorder {
|
|
|
277
424
|
this.subscription?.unsubscribe(),
|
|
278
425
|
this.preload?.dispose(),
|
|
279
426
|
this.toolbarPreload?.dispose(),
|
|
427
|
+
this.filesPreload?.dispose(),
|
|
280
428
|
]);
|
|
281
429
|
}
|
|
282
430
|
/**
|
|
@@ -305,6 +453,7 @@ export class Recorder {
|
|
|
305
453
|
async end() {
|
|
306
454
|
if (this.stopped)
|
|
307
455
|
return;
|
|
456
|
+
this.ending = true;
|
|
308
457
|
this.controller?.dispose();
|
|
309
458
|
await runInLoadedContexts(this.connector, {
|
|
310
459
|
source: CAPTURE_OFF,
|
|
@@ -324,7 +473,17 @@ export class Recorder {
|
|
|
324
473
|
}).catch(() => undefined);
|
|
325
474
|
}
|
|
326
475
|
await this.queue.tail;
|
|
476
|
+
// Only now: started and queued transfers need the kept Files
|
|
477
|
+
if (this.filesPreload)
|
|
478
|
+
await disposeFilesCapture(this.connector, this.context).catch(() => undefined);
|
|
327
479
|
this.stopped = true;
|
|
328
480
|
await this.release();
|
|
329
481
|
}
|
|
330
482
|
}
|
|
483
|
+
/** The warning for a file that isn't saved, as the toolbar and the CLI show it */
|
|
484
|
+
export function missingFileText(file, folder) {
|
|
485
|
+
const where = folder === "" ? "the project folder" : `${folder}/`;
|
|
486
|
+
return file.reason === "too-big"
|
|
487
|
+
? `${file.name} is ${humanSize(file.size)}. Files over 50 MB aren't saved: add it to ${where} yourself, or use a generated file.`
|
|
488
|
+
: `${file.name} couldn't be read, so it isn't saved: add it to ${where} yourself, or use a generated file.`;
|
|
489
|
+
}
|
|
@@ -5,6 +5,7 @@ import { type ProjectOptions } from "../runner/project.js";
|
|
|
5
5
|
import type { SamuraiTestConfig } from "../types/config.js";
|
|
6
6
|
import type { ControllerOptions } from "./toolbar/controller.js";
|
|
7
7
|
import type { RecorderEvent } from "./normalise.js";
|
|
8
|
+
import { type MissingFile } from "./recorder.js";
|
|
8
9
|
export interface RecordSpecOptions extends ProjectOptions {
|
|
9
10
|
/** The spec file, absolute or relative to the project folder. Created when `create` is given and it doesn't exist */
|
|
10
11
|
file: string;
|
|
@@ -35,6 +36,8 @@ export interface RecordSpecOptions extends ProjectOptions {
|
|
|
35
36
|
state: "recording" | "paused";
|
|
36
37
|
toolbar: boolean;
|
|
37
38
|
}) => void;
|
|
39
|
+
/** A picked or dropped file wasn't saved (over 50 MB, or unreadable); `text` is the warning to show */
|
|
40
|
+
onMissingFile?: (file: MissingFile, text: string) => void;
|
|
38
41
|
/** Called once the browser is open and recording */
|
|
39
42
|
onStarted?: (context: {
|
|
40
43
|
page: Page;
|
|
@@ -50,11 +53,21 @@ export interface RecordResult {
|
|
|
50
53
|
secrets: string[];
|
|
51
54
|
/** Those of `secrets` that have no value in the recording's environment yet, read when the recording ended */
|
|
52
55
|
unsetSecrets: string[];
|
|
56
|
+
/** Files stored in (or reused from) the files folder, project-relative */
|
|
57
|
+
files: string[];
|
|
58
|
+
/** Files the steps point at that weren't saved: they must be added before the test can run */
|
|
59
|
+
missingFiles: MissingFile[];
|
|
60
|
+
/** The files folder is git-ignored: CI won't have the files */
|
|
61
|
+
filesIgnored: boolean;
|
|
62
|
+
/** The files folder, relative to the project with forward slashes ("" for the project root) */
|
|
63
|
+
filesFolder: string;
|
|
64
|
+
/** The files folder was created by this recording */
|
|
65
|
+
firstFiles: boolean;
|
|
53
66
|
}
|
|
54
67
|
/** What the toolbar shows of the project: its environment, variables, secrets and the test's screenshots */
|
|
55
68
|
export declare function toolbarOptions(run: Pick<PreparedRun, "settings" | "secrets">, source: string, dir: string, config: Pick<SamuraiTestConfig, "environments">, testIndex: number): Promise<ControllerOptions>;
|
|
56
69
|
/** The secrets a recording needs set: those its steps read, then those added from the panel, once each */
|
|
57
|
-
export declare function resultSecrets(events: readonly
|
|
70
|
+
export declare function resultSecrets(events: readonly RecorderEvent[], added: Iterable<string>): string[];
|
|
58
71
|
/** The index of the test `selector` names (see `RecordSpecOptions.test`) */
|
|
59
72
|
export declare function pickTest(tests: readonly {
|
|
60
73
|
name: string;
|
package/dist/recorder/session.js
CHANGED
|
@@ -2,12 +2,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Browser } from "../browser/browser.js";
|
|
4
4
|
import { ensureWarm } from "../browser/warm-up.js";
|
|
5
|
-
import { loadConfig, projectDir } from "../config/config.js";
|
|
5
|
+
import { loadConfig, projectDir, readConfig } from "../config/config.js";
|
|
6
6
|
import { loadSecrets } from "../config/secrets.js";
|
|
7
|
-
import { isGitIgnored } from "../project/index.js";
|
|
7
|
+
import { filesFolder, isFolderIgnored, isGitIgnored, } from "../project/index.js";
|
|
8
8
|
import { withProject } from "../runner/project.js";
|
|
9
9
|
import { parseSpec } from "../steps/parse.js";
|
|
10
10
|
import { applyRecorderEvent } from "./apply.js";
|
|
11
|
+
import { missingFileText, } from "./recorder.js";
|
|
11
12
|
const NEW_SPEC_HEADER = `import { expect, test } from "@itmetsam/samurai-framework";\n\n`;
|
|
12
13
|
function addTest(source, title) {
|
|
13
14
|
const header = source.trim() === ""
|
|
@@ -49,8 +50,10 @@ export async function toolbarOptions(run, source, dir, config, testIndex) {
|
|
|
49
50
|
export function resultSecrets(events, added) {
|
|
50
51
|
return [
|
|
51
52
|
...new Set([
|
|
52
|
-
...events.flatMap((
|
|
53
|
-
|
|
53
|
+
...events.flatMap((event) => event.op !== "remove" &&
|
|
54
|
+
event.step.kind === "fill" &&
|
|
55
|
+
event.step.value.kind === "secret"
|
|
56
|
+
? [event.step.value.name]
|
|
54
57
|
: []),
|
|
55
58
|
...added,
|
|
56
59
|
]),
|
|
@@ -98,7 +101,8 @@ export async function recordSpec(options) {
|
|
|
98
101
|
throw new Error(`${options.file} does not exist; give a title with --new to create it`);
|
|
99
102
|
}
|
|
100
103
|
// Before the spec is touched: a recording without its browser changes nothing
|
|
101
|
-
await
|
|
104
|
+
const browserName = (await readConfig("browser")) ?? "firefox";
|
|
105
|
+
await ensureWarm(browserName, { port: options.port ?? 9223 }, options.signal);
|
|
102
106
|
options.signal?.throwIfAborted();
|
|
103
107
|
source ??= "";
|
|
104
108
|
if (options.create !== undefined &&
|
|
@@ -114,10 +118,14 @@ export async function recordSpec(options) {
|
|
|
114
118
|
if (!Number.isInteger(at) || at < 0 || at > test.steps.length) {
|
|
115
119
|
throw new RangeError(`--at must be between 0 and ${test.steps.length} (the test has ${test.steps.length} steps)`);
|
|
116
120
|
}
|
|
117
|
-
const { browser, page } = await Browser.launch(
|
|
121
|
+
const { browser, page } = await Browser.launch(browserName, {
|
|
118
122
|
port: options.port ?? 9223,
|
|
119
123
|
headless: options.headless ?? settings.headless,
|
|
120
124
|
}, options.signal);
|
|
125
|
+
const loaded = await loadConfig();
|
|
126
|
+
const folder = filesFolder(projectDir(), loaded.filesDir);
|
|
127
|
+
const hadFolder = existsSync(folder.absolute);
|
|
128
|
+
let recorder;
|
|
121
129
|
const events = [];
|
|
122
130
|
const addedSecrets = new Set();
|
|
123
131
|
let stopRequested;
|
|
@@ -128,17 +136,22 @@ export async function recordSpec(options) {
|
|
|
128
136
|
await page.goto(options.url);
|
|
129
137
|
else if (settings.baseURL !== undefined)
|
|
130
138
|
await page.navigateTo(settings.baseURL);
|
|
131
|
-
|
|
139
|
+
recorder = await page.record({
|
|
132
140
|
at,
|
|
133
141
|
initialGoto: at === 0 && start !== undefined,
|
|
134
142
|
...(settings.baseURL !== undefined && { baseURL: settings.baseURL }),
|
|
135
143
|
toolbar: {
|
|
136
|
-
...(await toolbarOptions(run, source, projectDir(),
|
|
144
|
+
...(await toolbarOptions(run, source, projectDir(), loaded, testIndex)),
|
|
137
145
|
projectDir: projectDir(),
|
|
138
146
|
...(options.onStatus && { onStatus: options.onStatus }),
|
|
139
147
|
onSecretAdded: (name) => addedSecrets.add(name),
|
|
140
148
|
onStop: () => stopRequested(),
|
|
141
149
|
},
|
|
150
|
+
files: {
|
|
151
|
+
projectDir: projectDir(),
|
|
152
|
+
...(loaded.filesDir !== undefined && { filesDir: loaded.filesDir }),
|
|
153
|
+
onMissing: (file) => options.onMissingFile?.(file, missingFileText(file, folder.relative)),
|
|
154
|
+
},
|
|
142
155
|
onEvent: (event) => {
|
|
143
156
|
source = applyRecorderEvent(source, testIndex, event);
|
|
144
157
|
writeFileSync(file, source);
|
|
@@ -169,6 +182,20 @@ export async function recordSpec(options) {
|
|
|
169
182
|
// Read again: the toolbar may have written new ones to the .env file
|
|
170
183
|
const set = loadSecrets(settings.environment);
|
|
171
184
|
const unsetSecrets = secrets.filter((name) => !set.has(name));
|
|
172
|
-
|
|
185
|
+
const files = recorder?.storedFiles ?? [];
|
|
186
|
+
const missingFiles = recorder?.missingFiles ?? [];
|
|
187
|
+
return {
|
|
188
|
+
file,
|
|
189
|
+
test: test.name,
|
|
190
|
+
events,
|
|
191
|
+
secrets,
|
|
192
|
+
unsetSecrets,
|
|
193
|
+
files,
|
|
194
|
+
missingFiles,
|
|
195
|
+
filesFolder: folder.relative,
|
|
196
|
+
filesIgnored: files.length + missingFiles.length > 0 &&
|
|
197
|
+
(await isFolderIgnored(projectDir(), folder.relative)),
|
|
198
|
+
firstFiles: !hadFolder && files.length > 0,
|
|
199
|
+
};
|
|
173
200
|
});
|
|
174
201
|
}
|
|
@@ -46,11 +46,6 @@ export interface ControllerOptions {
|
|
|
46
46
|
type Message = ToolbarMessage & {
|
|
47
47
|
target?: ElementHandle;
|
|
48
48
|
};
|
|
49
|
-
/**
|
|
50
|
-
* The recorder's side of the toolbar: owns its state (pause, the rail, the environment's variables and
|
|
51
|
-
* secrets) and turns its messages into observations. Secret values stay here; the page only ever gets
|
|
52
|
-
* their names and whether they are set.
|
|
53
|
-
*/
|
|
54
49
|
export declare class ToolbarController {
|
|
55
50
|
private readonly deps;
|
|
56
51
|
private readonly options;
|
|
@@ -66,6 +61,9 @@ export declare class ToolbarController {
|
|
|
66
61
|
private question;
|
|
67
62
|
private toast;
|
|
68
63
|
private toastId;
|
|
64
|
+
private savingName;
|
|
65
|
+
private warnings;
|
|
66
|
+
private warningId;
|
|
69
67
|
/** The picked element an `assert` with the same id refers to */
|
|
70
68
|
private pick;
|
|
71
69
|
/** Ids of `assert`, `insert` and `add-insertable` being processed */
|
|
@@ -76,6 +74,8 @@ export declare class ToolbarController {
|
|
|
76
74
|
private readonly done;
|
|
77
75
|
/** Bumped on every page load: work started for an older page is not answered */
|
|
78
76
|
private page;
|
|
77
|
+
/** Wake-ups for the work that is waiting on the current page: called, and forgotten, when the page changes */
|
|
78
|
+
private pageGone;
|
|
79
79
|
private toolbar;
|
|
80
80
|
/** A `ready` arrived since the last load: it counts for the next one */
|
|
81
81
|
private readySinceLoad;
|
|
@@ -88,6 +88,10 @@ export declare class ToolbarController {
|
|
|
88
88
|
private reply;
|
|
89
89
|
private reportStatus;
|
|
90
90
|
private setToolbar;
|
|
91
|
+
/** A picked or dropped file is being saved; undefined when done */
|
|
92
|
+
saving(name: string | undefined): void;
|
|
93
|
+
/** Shows a warning that stays until the tester dismisses it */
|
|
94
|
+
warn(text: string): void;
|
|
91
95
|
private showToast;
|
|
92
96
|
handle(message: Message): Promise<void>;
|
|
93
97
|
/** A top-level document finished loading: push state; start the 2 s ready watchdog */
|
|
@@ -97,8 +101,14 @@ export declare class ToolbarController {
|
|
|
97
101
|
* and doesn't count for the coming load
|
|
98
102
|
*/
|
|
99
103
|
navigating(): void;
|
|
104
|
+
/** Lets what waits on the current document stop waiting: its commands may never be answered once it is gone */
|
|
105
|
+
private leavePage;
|
|
106
|
+
/** Runs `work`, but stops waiting for it when the page is left; resolves `gone` then */
|
|
107
|
+
private whileOnPage;
|
|
100
108
|
/** A step was emitted (any source): count it */
|
|
101
109
|
stepAdded(step: Step): void;
|
|
110
|
+
/** A recorded step was taken out again (a cancelled file picker's click) */
|
|
111
|
+
stepRemoved(): void;
|
|
102
112
|
/** The recording ended: no more pushes, replies or status */
|
|
103
113
|
dispose(): void;
|
|
104
114
|
/**
|
|
@@ -22,6 +22,8 @@ const fail = (error, field) => ({
|
|
|
22
22
|
* secrets) and turns its messages into observations. Secret values stay here; the page only ever gets
|
|
23
23
|
* their names and whether they are set.
|
|
24
24
|
*/
|
|
25
|
+
/** What a wait on the page ends with when the page was left first */
|
|
26
|
+
const LEFT = Symbol("left the page");
|
|
25
27
|
export class ToolbarController {
|
|
26
28
|
constructor(deps, options) {
|
|
27
29
|
this.deps = deps;
|
|
@@ -31,6 +33,8 @@ export class ToolbarController {
|
|
|
31
33
|
this.position = null;
|
|
32
34
|
this.steps = 0;
|
|
33
35
|
this.toastId = 0;
|
|
36
|
+
this.warnings = [];
|
|
37
|
+
this.warningId = 0;
|
|
34
38
|
/** Ids of `assert`, `insert` and `add-insertable` being processed */
|
|
35
39
|
this.inFlight = new Set();
|
|
36
40
|
/** Ids `accept` let through whose `handle` hasn't started yet */
|
|
@@ -39,6 +43,8 @@ export class ToolbarController {
|
|
|
39
43
|
this.done = new Set();
|
|
40
44
|
/** Bumped on every page load: work started for an older page is not answered */
|
|
41
45
|
this.page = 0;
|
|
46
|
+
/** Wake-ups for the work that is waiting on the current page: called, and forgotten, when the page changes */
|
|
47
|
+
this.pageGone = new Set();
|
|
42
48
|
this.toolbar = true;
|
|
43
49
|
/** A `ready` arrived since the last load: it counts for the next one */
|
|
44
50
|
this.readySinceLoad = false;
|
|
@@ -70,6 +76,8 @@ export class ToolbarController {
|
|
|
70
76
|
resumeQuestion: this.question !== undefined,
|
|
71
77
|
nextScreenshot: `screenshot-${this.screenshots + 1}`,
|
|
72
78
|
...(this.toast && { toast: this.toast }),
|
|
79
|
+
...(this.savingName !== undefined && { saving: this.savingName }),
|
|
80
|
+
warnings: this.warnings.map((w) => ({ ...w })),
|
|
73
81
|
};
|
|
74
82
|
}
|
|
75
83
|
push() {
|
|
@@ -90,6 +98,16 @@ export class ToolbarController {
|
|
|
90
98
|
this.toolbar = loaded;
|
|
91
99
|
this.reportStatus();
|
|
92
100
|
}
|
|
101
|
+
/** A picked or dropped file is being saved; undefined when done */
|
|
102
|
+
saving(name) {
|
|
103
|
+
this.savingName = name;
|
|
104
|
+
this.push();
|
|
105
|
+
}
|
|
106
|
+
/** Shows a warning that stays until the tester dismisses it */
|
|
107
|
+
warn(text) {
|
|
108
|
+
this.warnings.push({ id: ++this.warningId, text });
|
|
109
|
+
this.push();
|
|
110
|
+
}
|
|
93
111
|
showToast(text) {
|
|
94
112
|
this.toast = { id: ++this.toastId, text };
|
|
95
113
|
}
|
|
@@ -116,6 +134,9 @@ export class ToolbarController {
|
|
|
116
134
|
this.collapsed = message.collapsed === true;
|
|
117
135
|
this.pick = undefined;
|
|
118
136
|
return this.push();
|
|
137
|
+
case "dismiss":
|
|
138
|
+
this.warnings = this.warnings.filter((w) => w.id !== message.id);
|
|
139
|
+
return this.push();
|
|
119
140
|
case "pick":
|
|
120
141
|
return this.picked(message);
|
|
121
142
|
case "assert":
|
|
@@ -156,6 +177,7 @@ export class ToolbarController {
|
|
|
156
177
|
if (this.disposed)
|
|
157
178
|
return;
|
|
158
179
|
this.page++;
|
|
180
|
+
this.leavePage();
|
|
159
181
|
this.pick = undefined;
|
|
160
182
|
this.toast = undefined;
|
|
161
183
|
this.push();
|
|
@@ -180,6 +202,28 @@ export class ToolbarController {
|
|
|
180
202
|
*/
|
|
181
203
|
navigating() {
|
|
182
204
|
this.readySinceLoad = false;
|
|
205
|
+
this.leavePage();
|
|
206
|
+
}
|
|
207
|
+
/** Lets what waits on the current document stop waiting: its commands may never be answered once it is gone */
|
|
208
|
+
leavePage() {
|
|
209
|
+
const waiting = [...this.pageGone];
|
|
210
|
+
this.pageGone.clear();
|
|
211
|
+
for (const wake of waiting)
|
|
212
|
+
wake();
|
|
213
|
+
}
|
|
214
|
+
/** Runs `work`, but stops waiting for it when the page is left; resolves `gone` then */
|
|
215
|
+
async whileOnPage(work, gone) {
|
|
216
|
+
let wake;
|
|
217
|
+
const left = new Promise((resolve) => {
|
|
218
|
+
wake = () => resolve(gone);
|
|
219
|
+
this.pageGone.add(wake);
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
return await Promise.race([work(), left]);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
this.pageGone.delete(wake);
|
|
226
|
+
}
|
|
183
227
|
}
|
|
184
228
|
/** A step was emitted (any source): count it */
|
|
185
229
|
stepAdded(step) {
|
|
@@ -188,6 +232,11 @@ export class ToolbarController {
|
|
|
188
232
|
this.screenshots++;
|
|
189
233
|
this.push();
|
|
190
234
|
}
|
|
235
|
+
/** A recorded step was taken out again (a cancelled file picker's click) */
|
|
236
|
+
stepRemoved() {
|
|
237
|
+
this.steps = Math.max(0, this.steps - 1);
|
|
238
|
+
this.push();
|
|
239
|
+
}
|
|
191
240
|
/** The recording ended: no more pushes, replies or status */
|
|
192
241
|
dispose() {
|
|
193
242
|
this.disposed = true;
|
|
@@ -288,19 +337,24 @@ export class ToolbarController {
|
|
|
288
337
|
return this.reply(message.id, fail(PAUSED));
|
|
289
338
|
let found;
|
|
290
339
|
try {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
340
|
+
// Chrome never answers a command into a document that is being replaced, so a pick of the old page must not
|
|
341
|
+
// keep the messages behind it waiting
|
|
342
|
+
found = await this.whileOnPage(async () => {
|
|
343
|
+
const locator = message.target &&
|
|
344
|
+
(await this.deps.rank(message.target, message.candidates));
|
|
345
|
+
if (!locator)
|
|
346
|
+
return undefined;
|
|
347
|
+
const inspected = await this.deps.inspect(message.target, locator, message.candidates);
|
|
295
348
|
if (page === this.page)
|
|
296
|
-
this.pick = { id: message.id, locator, counts:
|
|
297
|
-
|
|
349
|
+
this.pick = { id: message.id, locator, counts: inspected.counts };
|
|
350
|
+
return inspected;
|
|
351
|
+
}, LEFT);
|
|
298
352
|
}
|
|
299
353
|
catch {
|
|
300
354
|
found = undefined; // the element went away while it was inspected
|
|
301
355
|
}
|
|
302
356
|
// The page that asked is gone: its successor's toolbar isn't waiting for this
|
|
303
|
-
if (page !== this.page)
|
|
357
|
+
if (found === LEFT || page !== this.page)
|
|
304
358
|
return;
|
|
305
359
|
this.reply(message.id, found ? this.withoutSecrets(found.info) : fail(UNIDENTIFIABLE));
|
|
306
360
|
}
|