@itmetsam/samurai-framework 0.5.0 → 0.7.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 +4 -4
- package/bin/samurai.mjs +25 -0
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/browser-finder.d.ts +1 -0
- package/dist/browser/browser-finder.js +1 -0
- package/dist/browser/browser.d.ts +8 -0
- package/dist/browser/browser.js +33 -12
- package/dist/browser/evidence.d.ts +19 -0
- package/dist/browser/evidence.js +35 -0
- package/dist/browser/page.d.ts +6 -0
- package/dist/browser/page.js +20 -3
- package/dist/browser/screenshot.d.ts +2 -0
- package/dist/browser/screenshot.js +5 -2
- package/dist/browser/warm-up.d.ts +14 -0
- package/dist/browser/warm-up.js +37 -0
- package/dist/browsers/cache-dir.d.ts +2 -0
- package/dist/browsers/cache-dir.js +15 -0
- package/dist/browsers/downloader.d.ts +23 -0
- package/dist/browsers/downloader.js +52 -0
- package/dist/browsers/index.d.ts +6 -0
- package/dist/browsers/index.js +5 -0
- package/dist/browsers/overrides.d.ts +17 -0
- package/dist/browsers/overrides.js +15 -0
- package/dist/browsers/pins.d.ts +16 -0
- package/dist/browsers/pins.js +14 -0
- package/dist/browsers/resolve.d.ts +26 -0
- package/dist/browsers/resolve.js +56 -0
- package/dist/browsers/store.d.ts +52 -0
- package/dist/browsers/store.js +170 -0
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.js +2 -0
- package/dist/cli/browsers.d.ts +6 -0
- package/dist/cli/browsers.js +117 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +26 -2
- package/dist/project/config-edit.d.ts +16 -0
- package/dist/project/config-edit.js +218 -0
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/secrets-file.d.ts +8 -0
- package/dist/project/secrets-file.js +69 -0
- package/dist/project/variables.d.ts +6 -0
- package/dist/project/variables.js +17 -0
- package/dist/recorder/candidates.d.ts +7 -0
- package/dist/recorder/candidates.js +118 -0
- package/dist/recorder/capture.d.ts +8 -1
- package/dist/recorder/capture.js +25 -116
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/normalise.d.ts +28 -1
- package/dist/recorder/normalise.js +40 -1
- package/dist/recorder/recorder.d.ts +54 -0
- package/dist/recorder/recorder.js +195 -10
- package/dist/recorder/session.d.ts +15 -0
- package/dist/recorder/session.js +61 -7
- package/dist/recorder/toolbar/controller.d.ts +136 -0
- package/dist/recorder/toolbar/controller.js +471 -0
- package/dist/recorder/toolbar/inspect.d.ts +12 -0
- package/dist/recorder/toolbar/inspect.js +94 -0
- package/dist/recorder/toolbar/protocol.d.ts +135 -0
- package/dist/recorder/toolbar/protocol.js +4 -0
- package/dist/recorder/toolbar/source.d.ts +32 -0
- package/dist/recorder/toolbar/source.js +856 -0
- package/dist/recorder/toolbar/styles.d.ts +6 -0
- package/dist/recorder/toolbar/styles.js +95 -0
- package/dist/runner/reporter.d.ts +5 -1
- package/dist/runner/reporter.js +4 -3
- package/dist/runner/test-runner.d.ts +9 -0
- package/dist/runner/test-runner.js +44 -4
- package/dist/steps/emit.js +4 -0
- package/dist/steps/model.d.ts +11 -0
- package/dist/steps/parse.js +22 -3
- package/dist/transport/bidi-connection.d.ts +2 -0
- package/dist/transport/bidi-connection.js +33 -2
- package/dist/types/bidi-modules/script.d.ts +20 -3
- package/dist/types/config.d.ts +5 -0
- package/dist/types/test.d.ts +4 -0
- package/package.json +10 -1
|
@@ -1,39 +1,99 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import logger from "../logger/index.js";
|
|
3
3
|
import { addPreload, runInLoadedContexts } from "../script/preload.js";
|
|
4
|
+
import { callFunction } from "../script/call-function.js";
|
|
4
5
|
import { ChannelArg, fromRemoteValue } from "../script/serialize.js";
|
|
5
6
|
import { ElementHandle } from "../script/element-handle.js";
|
|
6
|
-
import {
|
|
7
|
+
import { locatorFromSpec } from "../steps/build.js";
|
|
8
|
+
import { addVariable, setSecret } from "../project/index.js";
|
|
9
|
+
import { CAPTURE_MUTE, CAPTURE_OFF, CAPTURE_SOURCE, CAPTURE_UNMUTE, } from "./capture.js";
|
|
7
10
|
import { Normaliser, } from "./normalise.js";
|
|
8
11
|
import { rankLocator } from "./ranking.js";
|
|
12
|
+
import { ToolbarController, } from "./toolbar/controller.js";
|
|
13
|
+
import { inspectElement } from "./toolbar/inspect.js";
|
|
14
|
+
import { TOOLBAR_KEY, TOOLBAR_SANDBOX, } from "./toolbar/protocol.js";
|
|
15
|
+
import { TOOLBAR_SOURCE } from "./toolbar/source.js";
|
|
16
|
+
/** The toolbar's hook in its sandbox */
|
|
17
|
+
const HOOK = `globalThis[Symbol.for(${JSON.stringify(TOOLBAR_KEY)})]`;
|
|
9
18
|
/**
|
|
10
19
|
* Watches the tester use the page and reports steps as they happen. A preload script reports clicks,
|
|
11
20
|
* typing and Alt+click assertions over a `script.message` channel; each element is given locators that
|
|
12
21
|
* were checked against the page; the normaliser turns the stream into steps.
|
|
13
22
|
*/
|
|
14
23
|
export class Recorder {
|
|
15
|
-
constructor(page, connector, context, normaliser, options,
|
|
24
|
+
constructor(page, connector, context, normaliser, options,
|
|
25
|
+
/** The capture script's channel: page messages only */
|
|
26
|
+
channel,
|
|
27
|
+
/** The toolbar's channel, handed only to the toolbar's sandbox: toolbar messages only */
|
|
28
|
+
toolbarChannel) {
|
|
16
29
|
this.page = page;
|
|
17
30
|
this.connector = connector;
|
|
18
31
|
this.context = context;
|
|
19
32
|
this.normaliser = normaliser;
|
|
20
33
|
this.options = options;
|
|
21
34
|
this.channel = channel;
|
|
35
|
+
this.toolbarChannel = toolbarChannel;
|
|
22
36
|
this.queue = { tail: Promise.resolve() };
|
|
23
37
|
this.known = new Map();
|
|
24
38
|
this.stopped = false;
|
|
39
|
+
/** The context's `samurai-toolbar` sandbox realms, as last listed: the only realms toolbar messages are taken from */
|
|
40
|
+
this.toolbarRealms = new Set();
|
|
41
|
+
/** Toolbar messages waiting for a realm lookup, and those that arrived behind them (kept in order) */
|
|
42
|
+
this.toolbarBacklog = Promise.resolve();
|
|
43
|
+
this.toolbarWaiting = 0;
|
|
44
|
+
/**
|
|
45
|
+
* Page messages come only on the capture channel; toolbar messages only on the toolbar channel and
|
|
46
|
+
* only from the toolbar's sandbox realm. Neither channel is reachable from page script (each stays in
|
|
47
|
+
* its script's closure, and the toolbar's in a sandbox the page cannot see); the realm check also
|
|
48
|
+
* holds if a page realm ever got the toolbar's channel.
|
|
49
|
+
*/
|
|
25
50
|
this.onMessage = (params) => {
|
|
26
|
-
if (params.
|
|
27
|
-
params.source.context !== this.context)
|
|
51
|
+
if (params.source.context !== this.context)
|
|
28
52
|
return;
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
if (params.channel === this.channel) {
|
|
54
|
+
const message = fromRemoteValue(params.data);
|
|
55
|
+
if (message.type !== "toolbar")
|
|
56
|
+
this.enqueue(async () => this.handle(message));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (params.channel !== this.toolbarChannel || !this.controller)
|
|
60
|
+
return;
|
|
61
|
+
const realm = params.source.realm ?? "";
|
|
62
|
+
if (this.toolbarWaiting === 0 && this.toolbarRealms.has(realm)) {
|
|
63
|
+
this.fromToolbar(params.data);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// A sandbox not seen yet (a new document's toolbar): look it up, and keep later messages behind it
|
|
67
|
+
this.toolbarWaiting++;
|
|
68
|
+
this.toolbarBacklog = this.toolbarBacklog
|
|
69
|
+
.then(async () => {
|
|
70
|
+
if (!this.toolbarRealms.has(realm))
|
|
71
|
+
await this.listToolbarRealms();
|
|
72
|
+
if (this.toolbarRealms.has(realm))
|
|
73
|
+
this.fromToolbar(params.data);
|
|
74
|
+
else
|
|
75
|
+
logger.warn("Recorder ignored a toolbar message from outside the toolbar's sandbox");
|
|
76
|
+
})
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
logger.debug("Could not check a toolbar message's realm", { err });
|
|
79
|
+
})
|
|
80
|
+
.finally(() => this.toolbarWaiting--);
|
|
81
|
+
};
|
|
82
|
+
this.onLoad = (params) => {
|
|
83
|
+
if (params.context === this.context && params.url !== "about:blank")
|
|
84
|
+
this.controller?.loaded();
|
|
31
85
|
};
|
|
32
86
|
this.onNavigation = (params) => {
|
|
33
87
|
if (params.context !== this.context || params.url === "about:blank")
|
|
34
88
|
return;
|
|
89
|
+
this.controller?.navigating();
|
|
35
90
|
this.known.clear();
|
|
36
|
-
this.enqueue(async () =>
|
|
91
|
+
this.enqueue(async () => {
|
|
92
|
+
// Paused: navigations are not recorded either
|
|
93
|
+
if (this.controller?.paused)
|
|
94
|
+
return;
|
|
95
|
+
this.emit(this.normaliser.navigated(params.url, Date.now()));
|
|
96
|
+
});
|
|
37
97
|
};
|
|
38
98
|
}
|
|
39
99
|
static async start(target, options) {
|
|
@@ -42,17 +102,49 @@ export class Recorder {
|
|
|
42
102
|
at,
|
|
43
103
|
...(options.baseURL !== undefined && { baseURL: options.baseURL }),
|
|
44
104
|
});
|
|
45
|
-
const recorder = new Recorder(target.page, target.connector, target.context, normaliser, options,
|
|
105
|
+
const recorder = new Recorder(target.page, target.connector, target.context, normaliser, options,
|
|
106
|
+
// The connector's debug log leaves out the data of these channels' messages (it knows them by prefix)
|
|
107
|
+
`samurai-recorder-${randomUUID()}`, `samurai-toolbar-${randomUUID()}`);
|
|
46
108
|
await recorder.begin(options.initialGoto ?? true);
|
|
47
109
|
return recorder;
|
|
48
110
|
}
|
|
111
|
+
async listToolbarRealms() {
|
|
112
|
+
const { realms } = await this.connector.send("script.getRealms", {
|
|
113
|
+
context: this.context,
|
|
114
|
+
type: "window",
|
|
115
|
+
});
|
|
116
|
+
this.toolbarRealms = new Set(realms
|
|
117
|
+
.filter((info) => info.type === "window" && info.sandbox === TOOLBAR_SANDBOX)
|
|
118
|
+
.map((info) => info.realm));
|
|
119
|
+
}
|
|
120
|
+
/** A message from the toolbar's own sandbox */
|
|
121
|
+
fromToolbar(data) {
|
|
122
|
+
const message = fromRemoteValue(data);
|
|
123
|
+
const controller = this.controller;
|
|
124
|
+
if (!controller || message.type !== "toolbar" || !("action" in message))
|
|
125
|
+
return;
|
|
126
|
+
if (message.action === "ready") {
|
|
127
|
+
// Not behind a slow insert: the ready watchdog is waiting for it
|
|
128
|
+
void controller.handle(message);
|
|
129
|
+
}
|
|
130
|
+
else if (controller.accept(message)) {
|
|
131
|
+
// Decided as it arrives: a double click's second copy would only run after the first had finished
|
|
132
|
+
this.enqueue(async () => controller.handle(message));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
49
135
|
async begin(initialGoto) {
|
|
136
|
+
const toolbar = this.options.toolbar;
|
|
137
|
+
if (toolbar)
|
|
138
|
+
this.controller = this.createController(toolbar);
|
|
50
139
|
this.connector.onEvent("script.message", this.onMessage);
|
|
51
140
|
this.connector.onEvent("browsingContext.navigationStarted", this.onNavigation);
|
|
141
|
+
if (toolbar)
|
|
142
|
+
this.connector.onEvent("browsingContext.load", this.onLoad);
|
|
52
143
|
try {
|
|
53
144
|
this.subscription = await this.connector.subscribe([
|
|
54
145
|
"script.message",
|
|
55
146
|
"browsingContext.navigationStarted",
|
|
147
|
+
...(toolbar ? ["browsingContext.load"] : []),
|
|
56
148
|
]);
|
|
57
149
|
if (initialGoto) {
|
|
58
150
|
const url = await this.page.url();
|
|
@@ -65,12 +157,62 @@ export class Recorder {
|
|
|
65
157
|
arguments: [new ChannelArg(this.channel)],
|
|
66
158
|
onRunError: "log",
|
|
67
159
|
});
|
|
160
|
+
if (this.controller) {
|
|
161
|
+
this.toolbarPreload = await addPreload(this.connector, {
|
|
162
|
+
source: TOOLBAR_SOURCE,
|
|
163
|
+
sandbox: TOOLBAR_SANDBOX,
|
|
164
|
+
contexts: [this.context],
|
|
165
|
+
arguments: [new ChannelArg(this.toolbarChannel)],
|
|
166
|
+
onRunError: "log",
|
|
167
|
+
});
|
|
168
|
+
// The document that is loaded now gets its state and its ready watchdog as a new one would
|
|
169
|
+
this.controller.loaded();
|
|
170
|
+
}
|
|
68
171
|
}
|
|
69
172
|
catch (err) {
|
|
173
|
+
this.controller?.dispose();
|
|
70
174
|
await this.release();
|
|
71
175
|
throw err;
|
|
72
176
|
}
|
|
73
177
|
}
|
|
178
|
+
createController(toolbar) {
|
|
179
|
+
const { projectDir, environment, onStatus, onStop, onSecretAdded } = toolbar;
|
|
180
|
+
return new ToolbarController({
|
|
181
|
+
push: (state) => this.callToolbar("update", [state]),
|
|
182
|
+
reply: (id, result) => this.callToolbar("reply", [id, result]),
|
|
183
|
+
currentUrl: () => this.page.url(),
|
|
184
|
+
rank: (target, candidates) => rankLocator(this.page, target, candidates),
|
|
185
|
+
inspect: (target, locator, candidates) => inspectElement(this.page, this.connector, this.context, target, locator, candidates),
|
|
186
|
+
type: async (locator, value) => {
|
|
187
|
+
await this.mute();
|
|
188
|
+
try {
|
|
189
|
+
await locatorFromSpec(this.page, locator).fill(value, {
|
|
190
|
+
timeout: 5000,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await this.unmute();
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
mute: () => this.mute(),
|
|
198
|
+
unmute: () => this.unmute(),
|
|
199
|
+
addVariable: (name, value) => addVariable(projectDir, environment, name, value),
|
|
200
|
+
setSecret: (name, value) => setSecret(projectDir, environment, name, value),
|
|
201
|
+
secretAdded: (name) => onSecretAdded?.(name),
|
|
202
|
+
observe: (observation) => this.emit(this.normaliser.observe(observation, Date.now())),
|
|
203
|
+
stop: () => {
|
|
204
|
+
if (onStop)
|
|
205
|
+
onStop();
|
|
206
|
+
else
|
|
207
|
+
void this.stop();
|
|
208
|
+
},
|
|
209
|
+
onStatus: (status) => onStatus?.(status),
|
|
210
|
+
}, toolbar);
|
|
211
|
+
}
|
|
212
|
+
/** Calls the toolbar hook's `method` in the loaded document; a document between loads has no toolbar yet */
|
|
213
|
+
callToolbar(method, args) {
|
|
214
|
+
callFunction(this.connector, this.context, `(...args) => { ${HOOK}?.${method}(...args); }`, args, { sandbox: TOOLBAR_SANDBOX, awaitPromise: false }).catch((err) => logger.debug("Could not reach the recorder toolbar", { err }));
|
|
215
|
+
}
|
|
74
216
|
enqueue(work) {
|
|
75
217
|
this.queue.tail = this.queue.tail.then(work).catch((err) => {
|
|
76
218
|
logger.warn("Recorder could not process an event", { err });
|
|
@@ -79,8 +221,11 @@ export class Recorder {
|
|
|
79
221
|
emit(events) {
|
|
80
222
|
if (this.stopped)
|
|
81
223
|
return;
|
|
82
|
-
for (const event of events)
|
|
224
|
+
for (const event of events) {
|
|
83
225
|
this.options.onEvent(event);
|
|
226
|
+
if (event.op === "insert")
|
|
227
|
+
this.controller?.stepAdded(event.step);
|
|
228
|
+
}
|
|
84
229
|
}
|
|
85
230
|
async locatorFor(message) {
|
|
86
231
|
const { sharedId } = message.target;
|
|
@@ -91,6 +236,9 @@ export class Recorder {
|
|
|
91
236
|
return spec;
|
|
92
237
|
}
|
|
93
238
|
async handle(message) {
|
|
239
|
+
// Paused: what the capture script still reports (before a new document is muted) is dropped
|
|
240
|
+
if (this.controller?.paused)
|
|
241
|
+
return;
|
|
94
242
|
const locator = await this.locatorFor(message);
|
|
95
243
|
if (!locator) {
|
|
96
244
|
logger.warn("Recorder found no locator that matches only the element the tester used; the %s was skipped", message.type);
|
|
@@ -124,20 +272,57 @@ export class Recorder {
|
|
|
124
272
|
async release() {
|
|
125
273
|
this.connector.offEvent("script.message", this.onMessage);
|
|
126
274
|
this.connector.offEvent("browsingContext.navigationStarted", this.onNavigation);
|
|
275
|
+
this.connector.offEvent("browsingContext.load", this.onLoad);
|
|
127
276
|
await Promise.allSettled([
|
|
128
277
|
this.subscription?.unsubscribe(),
|
|
129
278
|
this.preload?.dispose(),
|
|
279
|
+
this.toolbarPreload?.dispose(),
|
|
130
280
|
]);
|
|
131
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Pauses capture in the loaded document: clicks and typing are not reported until `unmute`.
|
|
284
|
+
* @internal
|
|
285
|
+
*/
|
|
286
|
+
async mute() {
|
|
287
|
+
await this.setMuted(CAPTURE_MUTE);
|
|
288
|
+
}
|
|
289
|
+
/** @internal */
|
|
290
|
+
async unmute() {
|
|
291
|
+
await this.setMuted(CAPTURE_UNMUTE);
|
|
292
|
+
}
|
|
293
|
+
async setMuted(source) {
|
|
294
|
+
await runInLoadedContexts(this.connector, {
|
|
295
|
+
source,
|
|
296
|
+
contexts: [this.context],
|
|
297
|
+
onRunError: "log",
|
|
298
|
+
});
|
|
299
|
+
}
|
|
132
300
|
/** Stops recording. Events already received are still processed and reported first */
|
|
133
|
-
|
|
301
|
+
stop() {
|
|
302
|
+
this.stopping ??= this.end();
|
|
303
|
+
return this.stopping;
|
|
304
|
+
}
|
|
305
|
+
async end() {
|
|
134
306
|
if (this.stopped)
|
|
135
307
|
return;
|
|
308
|
+
this.controller?.dispose();
|
|
136
309
|
await runInLoadedContexts(this.connector, {
|
|
137
310
|
source: CAPTURE_OFF,
|
|
138
311
|
contexts: [this.context],
|
|
139
312
|
onRunError: "log",
|
|
140
313
|
}).catch(() => undefined);
|
|
314
|
+
// A later recording of this document must not start muted
|
|
315
|
+
if (this.controller?.paused)
|
|
316
|
+
await this.unmute().catch(() => undefined);
|
|
317
|
+
if (this.toolbarPreload) {
|
|
318
|
+
// The window may stay open: take the toolbar and its listeners off the page
|
|
319
|
+
await runInLoadedContexts(this.connector, {
|
|
320
|
+
source: `() => { ${HOOK}?.dispose(); }`,
|
|
321
|
+
contexts: [this.context],
|
|
322
|
+
sandbox: TOOLBAR_SANDBOX,
|
|
323
|
+
onRunError: "log",
|
|
324
|
+
}).catch(() => undefined);
|
|
325
|
+
}
|
|
141
326
|
await this.queue.tail;
|
|
142
327
|
this.stopped = true;
|
|
143
328
|
await this.release();
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Browser } from "../browser/browser.js";
|
|
2
2
|
import type Page from "../browser/page.js";
|
|
3
|
+
import type { PreparedRun } from "../runner/prepare-run.js";
|
|
3
4
|
import { type ProjectOptions } from "../runner/project.js";
|
|
5
|
+
import type { SamuraiTestConfig } from "../types/config.js";
|
|
6
|
+
import type { ControllerOptions } from "./toolbar/controller.js";
|
|
4
7
|
import type { RecorderEvent } from "./normalise.js";
|
|
5
8
|
export interface RecordSpecOptions extends ProjectOptions {
|
|
6
9
|
/** The spec file, absolute or relative to the project folder. Created when `create` is given and it doesn't exist */
|
|
@@ -24,6 +27,14 @@ export interface RecordSpecOptions extends ProjectOptions {
|
|
|
24
27
|
signal?: AbortSignal;
|
|
25
28
|
/** Called after each step is written to the file, with the file's new content */
|
|
26
29
|
onEvent?: (event: RecorderEvent, source: string) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Called when the recording is paused or resumed, or the toolbar fails (or comes back) on a page.
|
|
32
|
+
* There is no initial call: until the first one the recording is `{ state: "recording", toolbar: true }`
|
|
33
|
+
*/
|
|
34
|
+
onStatus?: (status: {
|
|
35
|
+
state: "recording" | "paused";
|
|
36
|
+
toolbar: boolean;
|
|
37
|
+
}) => void;
|
|
27
38
|
/** Called once the browser is open and recording */
|
|
28
39
|
onStarted?: (context: {
|
|
29
40
|
page: Page;
|
|
@@ -38,6 +49,10 @@ export interface RecordResult {
|
|
|
38
49
|
/** Names the recorded steps read with `secrets.<NAME>`: they must be set before the test can run */
|
|
39
50
|
secrets: string[];
|
|
40
51
|
}
|
|
52
|
+
/** What the toolbar shows of the project: its environment, variables, secrets and the test's screenshots */
|
|
53
|
+
export declare function toolbarOptions(run: Pick<PreparedRun, "settings" | "secrets">, source: string, dir: string, config: Pick<SamuraiTestConfig, "environments">, testIndex: number): Promise<ControllerOptions>;
|
|
54
|
+
/** The secrets a recording needs set: those its steps read, then those added from the panel, once each */
|
|
55
|
+
export declare function resultSecrets(events: readonly Pick<RecorderEvent, "step">[], added: Iterable<string>): string[];
|
|
41
56
|
/** The index of the test `selector` names (see `RecordSpecOptions.test`) */
|
|
42
57
|
export declare function pickTest(tests: readonly {
|
|
43
58
|
name: string;
|
package/dist/recorder/session.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Browser } from "../browser/browser.js";
|
|
4
|
-
import {
|
|
4
|
+
import { ensureWarm } from "../browser/warm-up.js";
|
|
5
|
+
import { loadConfig, projectDir } from "../config/config.js";
|
|
6
|
+
import { isGitIgnored } from "../project/index.js";
|
|
5
7
|
import { withProject } from "../runner/project.js";
|
|
6
8
|
import { parseSpec } from "../steps/parse.js";
|
|
7
9
|
import { applyRecorderEvent } from "./apply.js";
|
|
@@ -12,6 +14,47 @@ function addTest(source, title) {
|
|
|
12
14
|
: `${source.replace(/\s*$/, "")}\n\n`;
|
|
13
15
|
return `${header}test(${JSON.stringify(title)}, async ({ page }) => {\n});\n`;
|
|
14
16
|
}
|
|
17
|
+
/** Every `secrets.<NAME>` a parsed value contains, at any depth */
|
|
18
|
+
function secretNames(node, found) {
|
|
19
|
+
if (Array.isArray(node))
|
|
20
|
+
node.forEach((item) => secretNames(item, found));
|
|
21
|
+
else if (node !== null && typeof node === "object") {
|
|
22
|
+
const record = node;
|
|
23
|
+
if (record.kind === "secret" && typeof record.name === "string")
|
|
24
|
+
found.add(record.name);
|
|
25
|
+
Object.values(record).forEach((item) => secretNames(item, found));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** What the toolbar shows of the project: its environment, variables, secrets and the test's screenshots */
|
|
29
|
+
export async function toolbarOptions(run, source, dir, config, testIndex) {
|
|
30
|
+
const tests = parseSpec(source);
|
|
31
|
+
const names = new Set(run.secrets.keys());
|
|
32
|
+
secretNames(tests, names);
|
|
33
|
+
const secrets = {};
|
|
34
|
+
for (const name of names)
|
|
35
|
+
secrets[name] = run.secrets.get(name);
|
|
36
|
+
const { environment } = run.settings;
|
|
37
|
+
return {
|
|
38
|
+
environment,
|
|
39
|
+
canAddVariables: typeof config.environments === "object",
|
|
40
|
+
variables: run.settings.variables,
|
|
41
|
+
secrets,
|
|
42
|
+
secretsIgnored: await isGitIgnored(dir, `.env.${environment}`),
|
|
43
|
+
screenshots: tests[testIndex]?.steps.filter(({ step }) => step.kind === "screenshot")
|
|
44
|
+
.length ?? 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The secrets a recording needs set: those its steps read, then those added from the panel, once each */
|
|
48
|
+
export function resultSecrets(events, added) {
|
|
49
|
+
return [
|
|
50
|
+
...new Set([
|
|
51
|
+
...events.flatMap(({ step }) => step.kind === "fill" && step.value.kind === "secret"
|
|
52
|
+
? [step.value.name]
|
|
53
|
+
: []),
|
|
54
|
+
...added,
|
|
55
|
+
]),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
15
58
|
/** The index of the test `selector` names (see `RecordSpecOptions.test`) */
|
|
16
59
|
export function pickTest(tests, selector) {
|
|
17
60
|
if (tests.length === 0)
|
|
@@ -46,12 +89,16 @@ export function pickTest(tests, selector) {
|
|
|
46
89
|
* await recordSpec({ file: "tests/login.spec.ts", create: "signs in", environment: "staging" });
|
|
47
90
|
*/
|
|
48
91
|
export async function recordSpec(options) {
|
|
49
|
-
return withProject(options, async (
|
|
92
|
+
return withProject(options, async (run) => {
|
|
93
|
+
const { settings } = run;
|
|
50
94
|
const file = path.resolve(projectDir(), options.file);
|
|
51
95
|
let source = existsSync(file) ? readFileSync(file, "utf8") : undefined;
|
|
52
96
|
if (source === undefined && options.create === undefined) {
|
|
53
97
|
throw new Error(`${options.file} does not exist; give a title with --new to create it`);
|
|
54
98
|
}
|
|
99
|
+
// Before the spec is touched: a recording without its browser changes nothing
|
|
100
|
+
await ensureWarm("firefox", { port: options.port ?? 9223 }, options.signal);
|
|
101
|
+
options.signal?.throwIfAborted();
|
|
55
102
|
source ??= "";
|
|
56
103
|
if (options.create !== undefined &&
|
|
57
104
|
!parseSpec(source).some((t) => t.name === options.create)) {
|
|
@@ -71,6 +118,9 @@ export async function recordSpec(options) {
|
|
|
71
118
|
headless: options.headless ?? settings.headless,
|
|
72
119
|
}, options.signal);
|
|
73
120
|
const events = [];
|
|
121
|
+
const addedSecrets = new Set();
|
|
122
|
+
let stopRequested;
|
|
123
|
+
const stopped = new Promise((resolve) => (stopRequested = resolve));
|
|
74
124
|
try {
|
|
75
125
|
const start = options.url ?? settings.baseURL;
|
|
76
126
|
if (options.url !== undefined)
|
|
@@ -81,6 +131,13 @@ export async function recordSpec(options) {
|
|
|
81
131
|
at,
|
|
82
132
|
initialGoto: at === 0 && start !== undefined,
|
|
83
133
|
...(settings.baseURL !== undefined && { baseURL: settings.baseURL }),
|
|
134
|
+
toolbar: {
|
|
135
|
+
...(await toolbarOptions(run, source, projectDir(), await loadConfig(), testIndex)),
|
|
136
|
+
projectDir: projectDir(),
|
|
137
|
+
...(options.onStatus && { onStatus: options.onStatus }),
|
|
138
|
+
onSecretAdded: (name) => addedSecrets.add(name),
|
|
139
|
+
onStop: () => stopRequested(),
|
|
140
|
+
},
|
|
84
141
|
onEvent: (event) => {
|
|
85
142
|
source = applyRecorderEvent(source, testIndex, event);
|
|
86
143
|
writeFileSync(file, source);
|
|
@@ -98,6 +155,7 @@ export async function recordSpec(options) {
|
|
|
98
155
|
once: true,
|
|
99
156
|
});
|
|
100
157
|
void browser.exited.then(resolve);
|
|
158
|
+
void stopped.then(resolve);
|
|
101
159
|
});
|
|
102
160
|
// With the window gone there is nothing to ask: what was reported before is in the file
|
|
103
161
|
if (!windowClosed)
|
|
@@ -106,11 +164,7 @@ export async function recordSpec(options) {
|
|
|
106
164
|
finally {
|
|
107
165
|
await browser.close().catch(() => undefined);
|
|
108
166
|
}
|
|
109
|
-
const secrets =
|
|
110
|
-
...new Set(events.flatMap(({ step }) => step.kind === "fill" && step.value.kind === "secret"
|
|
111
|
-
? [step.value.name]
|
|
112
|
-
: [])),
|
|
113
|
-
];
|
|
167
|
+
const secrets = resultSecrets(events, addedSecrets);
|
|
114
168
|
return { file, test: test.name, events, secrets };
|
|
115
169
|
});
|
|
116
170
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ElementHandle } from "../../script/element-handle.js";
|
|
2
|
+
import type { LocatorCall, LocatorSpec, Step } from "../../steps/model.js";
|
|
3
|
+
import { type Observation } from "../normalise.js";
|
|
4
|
+
import type { ActionReply, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
|
|
5
|
+
/** What `inspect` found for a picked element: the reply, and the locator behind each Count choice */
|
|
6
|
+
export interface Inspection {
|
|
7
|
+
info: PickInfo & {
|
|
8
|
+
ok: true;
|
|
9
|
+
};
|
|
10
|
+
/** `counts[i]` is the locator `info.counts[i]` describes */
|
|
11
|
+
counts: LocatorCall[];
|
|
12
|
+
}
|
|
13
|
+
/** Everything the controller does to the browser and the project; the recorder implements these */
|
|
14
|
+
export interface ControllerDeps {
|
|
15
|
+
/** Sends the state to every loaded top-level document's toolbar */
|
|
16
|
+
push(state: ToolbarState): void;
|
|
17
|
+
reply(id: number, result: PickInfo | ActionReply): void;
|
|
18
|
+
currentUrl(): Promise<string>;
|
|
19
|
+
rank(target: ElementHandle, candidates: LocatorCall[]): Promise<LocatorSpec | undefined>;
|
|
20
|
+
inspect(target: ElementHandle, locator: LocatorSpec, candidates: LocatorCall[]): Promise<Inspection>;
|
|
21
|
+
/** Types into the field with capture muted */
|
|
22
|
+
type(locator: LocatorSpec, value: string): Promise<void>;
|
|
23
|
+
mute(): Promise<void>;
|
|
24
|
+
unmute(): Promise<void>;
|
|
25
|
+
addVariable(name: string, value: string): Promise<void>;
|
|
26
|
+
setSecret(name: string, value: string): Promise<void>;
|
|
27
|
+
/** A secret was written to the project (called before its value is typed) */
|
|
28
|
+
secretAdded(name: string): void;
|
|
29
|
+
observe(observation: Observation): void;
|
|
30
|
+
stop(): void;
|
|
31
|
+
onStatus(status: {
|
|
32
|
+
state: "recording" | "paused";
|
|
33
|
+
toolbar: boolean;
|
|
34
|
+
}): void;
|
|
35
|
+
}
|
|
36
|
+
export interface ControllerOptions {
|
|
37
|
+
environment: string;
|
|
38
|
+
canAddVariables: boolean;
|
|
39
|
+
variables: Record<string, string | number | boolean>;
|
|
40
|
+
/** Name → value; undefined when not set */
|
|
41
|
+
secrets: Record<string, string | undefined>;
|
|
42
|
+
secretsIgnored: boolean;
|
|
43
|
+
/** Screenshot steps already in the test, for the default name */
|
|
44
|
+
screenshots: number;
|
|
45
|
+
}
|
|
46
|
+
type Message = ToolbarMessage & {
|
|
47
|
+
target?: ElementHandle;
|
|
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
|
+
export declare class ToolbarController {
|
|
55
|
+
private readonly deps;
|
|
56
|
+
private readonly options;
|
|
57
|
+
private status;
|
|
58
|
+
private collapsed;
|
|
59
|
+
private position;
|
|
60
|
+
private steps;
|
|
61
|
+
private screenshots;
|
|
62
|
+
private readonly variables;
|
|
63
|
+
private readonly secrets;
|
|
64
|
+
private pausedAt;
|
|
65
|
+
/** The URL the resume question offers; set while it is asked */
|
|
66
|
+
private question;
|
|
67
|
+
private toast;
|
|
68
|
+
private toastId;
|
|
69
|
+
/** The picked element an `assert` with the same id refers to */
|
|
70
|
+
private pick;
|
|
71
|
+
/** Ids of `assert`, `insert` and `add-insertable` being processed */
|
|
72
|
+
private readonly inFlight;
|
|
73
|
+
/** Ids `accept` let through whose `handle` hasn't started yet */
|
|
74
|
+
private readonly claimed;
|
|
75
|
+
/** Ids answered `{ ok: true }`: their step exists, a repeat is a double click */
|
|
76
|
+
private readonly done;
|
|
77
|
+
/** Bumped on every page load: work started for an older page is not answered */
|
|
78
|
+
private page;
|
|
79
|
+
private toolbar;
|
|
80
|
+
/** A `ready` arrived since the last load: it counts for the next one */
|
|
81
|
+
private readySinceLoad;
|
|
82
|
+
private watchdog;
|
|
83
|
+
private disposed;
|
|
84
|
+
constructor(deps: ControllerDeps, options: ControllerOptions);
|
|
85
|
+
get paused(): boolean;
|
|
86
|
+
private state;
|
|
87
|
+
private push;
|
|
88
|
+
private reply;
|
|
89
|
+
private reportStatus;
|
|
90
|
+
private setToolbar;
|
|
91
|
+
private showToast;
|
|
92
|
+
handle(message: Message): Promise<void>;
|
|
93
|
+
/** A top-level document finished loading: push state; start the 2 s ready watchdog */
|
|
94
|
+
loaded(): void;
|
|
95
|
+
/**
|
|
96
|
+
* A top-level navigation to a new document started: a `ready` from before it belongs to the old document
|
|
97
|
+
* and doesn't count for the coming load
|
|
98
|
+
*/
|
|
99
|
+
navigating(): void;
|
|
100
|
+
/** A step was emitted (any source): count it */
|
|
101
|
+
stepAdded(step: Step): void;
|
|
102
|
+
/** The recording ended: no more pushes, replies or status */
|
|
103
|
+
dispose(): void;
|
|
104
|
+
/**
|
|
105
|
+
* The toolbar draws at document start, so its `ready` usually comes before the load; Firefox also runs it
|
|
106
|
+
* in a transient about:blank while navigating. A `ready` that arrives while the watchdog runs settles it;
|
|
107
|
+
* any other counts for the next load.
|
|
108
|
+
*/
|
|
109
|
+
private ready;
|
|
110
|
+
private pause;
|
|
111
|
+
private resume;
|
|
112
|
+
private answer;
|
|
113
|
+
/** Runs an action that expects a reply; a second one with the same id while the first runs is dropped */
|
|
114
|
+
/**
|
|
115
|
+
* Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `insert` or
|
|
116
|
+
* `add-insertable` whose id is in flight or was already answered `{ ok: true }` is a double click and is
|
|
117
|
+
* dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
|
|
118
|
+
*/
|
|
119
|
+
accept(message: Message): boolean;
|
|
120
|
+
/** Runs an action that expects a reply, unless it is a repeat (see `accept`) */
|
|
121
|
+
private once;
|
|
122
|
+
private picked;
|
|
123
|
+
/**
|
|
124
|
+
* A field may hold an inserted secret: its value (and any text or attribute equal to a secret) is left out,
|
|
125
|
+
* and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
|
|
126
|
+
*/
|
|
127
|
+
private withoutSecrets;
|
|
128
|
+
private assert;
|
|
129
|
+
private insert;
|
|
130
|
+
private addInsertable;
|
|
131
|
+
/** Undefined when `work` succeeded, else the reply for its error */
|
|
132
|
+
private write;
|
|
133
|
+
private locate;
|
|
134
|
+
private typeIn;
|
|
135
|
+
}
|
|
136
|
+
export {};
|