@itmetsam/samurai-framework 0.6.0 → 0.7.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/README.md +2 -2
- package/bin/samurai.mjs +25 -0
- 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/cli/browsers.d.ts +1 -1
- package/dist/cli/browsers.js +2 -4
- package/dist/cli/main.js +17 -3
- 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 +17 -0
- package/dist/recorder/session.js +62 -8
- 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 +39 -4
- package/dist/steps/edit.d.ts +3 -2
- package/dist/steps/edit.js +28 -4
- package/dist/steps/emit.js +4 -0
- package/dist/steps/model.d.ts +21 -0
- package/dist/steps/parse.js +45 -3
- package/dist/transport/bidi-connection.js +32 -1
- package/dist/types/bidi-modules/script.d.ts +20 -3
- package/dist/types/test.d.ts +4 -0
- package/package.json +5 -1
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { cleanName } from "../../browser/evidence.js";
|
|
2
|
+
import { ConfigEditError } from "../../project/config-edit.js";
|
|
3
|
+
import { SECRET_NAME } from "../../project/secrets-file.js";
|
|
4
|
+
import { ExistsError, VARIABLE_NAME } from "../../project/variables.js";
|
|
5
|
+
import { expectationFor } from "../normalise.js";
|
|
6
|
+
/** A wait for time is 0.1–60 s */
|
|
7
|
+
const WAIT_MIN_MS = 100;
|
|
8
|
+
const WAIT_MAX_MS = 60000;
|
|
9
|
+
/** How long a loaded page's toolbar has to say `ready` */
|
|
10
|
+
const READY_WITHIN = 2000;
|
|
11
|
+
const UNIDENTIFIABLE = "This element can't be identified uniquely";
|
|
12
|
+
const TYPE_FAILED = "Couldn't type into that field";
|
|
13
|
+
const NO_ENVIRONMENT = "This recording has no environment";
|
|
14
|
+
const PAUSED = "Nothing you do now is recorded.";
|
|
15
|
+
const fail = (error, field) => ({
|
|
16
|
+
ok: false,
|
|
17
|
+
error,
|
|
18
|
+
...(field && { field }),
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* The recorder's side of the toolbar: owns its state (pause, the rail, the environment's variables and
|
|
22
|
+
* secrets) and turns its messages into observations. Secret values stay here; the page only ever gets
|
|
23
|
+
* their names and whether they are set.
|
|
24
|
+
*/
|
|
25
|
+
export class ToolbarController {
|
|
26
|
+
constructor(deps, options) {
|
|
27
|
+
this.deps = deps;
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.status = "recording";
|
|
30
|
+
this.collapsed = false;
|
|
31
|
+
this.position = null;
|
|
32
|
+
this.steps = 0;
|
|
33
|
+
this.toastId = 0;
|
|
34
|
+
/** Ids of `assert`, `insert` and `add-insertable` being processed */
|
|
35
|
+
this.inFlight = new Set();
|
|
36
|
+
/** Ids `accept` let through whose `handle` hasn't started yet */
|
|
37
|
+
this.claimed = new Set();
|
|
38
|
+
/** Ids answered `{ ok: true }`: their step exists, a repeat is a double click */
|
|
39
|
+
this.done = new Set();
|
|
40
|
+
/** Bumped on every page load: work started for an older page is not answered */
|
|
41
|
+
this.page = 0;
|
|
42
|
+
this.toolbar = true;
|
|
43
|
+
/** A `ready` arrived since the last load: it counts for the next one */
|
|
44
|
+
this.readySinceLoad = false;
|
|
45
|
+
this.disposed = false;
|
|
46
|
+
this.screenshots = options.screenshots;
|
|
47
|
+
this.variables = new Map(Object.entries(options.variables).map(([name, value]) => [
|
|
48
|
+
name,
|
|
49
|
+
String(value),
|
|
50
|
+
]));
|
|
51
|
+
this.secrets = new Map(Object.entries(options.secrets));
|
|
52
|
+
}
|
|
53
|
+
get paused() {
|
|
54
|
+
return this.status === "paused";
|
|
55
|
+
}
|
|
56
|
+
state() {
|
|
57
|
+
return {
|
|
58
|
+
status: this.status,
|
|
59
|
+
collapsed: this.collapsed,
|
|
60
|
+
position: this.position,
|
|
61
|
+
steps: this.steps,
|
|
62
|
+
environment: this.options.environment,
|
|
63
|
+
canAddVariables: this.options.canAddVariables,
|
|
64
|
+
variables: [...this.variables].map(([name, value]) => ({ name, value })),
|
|
65
|
+
secrets: [...this.secrets].map(([name, value]) => ({
|
|
66
|
+
name,
|
|
67
|
+
set: value !== undefined,
|
|
68
|
+
})),
|
|
69
|
+
secretsIgnored: this.options.secretsIgnored,
|
|
70
|
+
resumeQuestion: this.question !== undefined,
|
|
71
|
+
nextScreenshot: `screenshot-${this.screenshots + 1}`,
|
|
72
|
+
...(this.toast && { toast: this.toast }),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
push() {
|
|
76
|
+
if (!this.disposed)
|
|
77
|
+
this.deps.push(this.state());
|
|
78
|
+
}
|
|
79
|
+
reply(id, result) {
|
|
80
|
+
if (!this.disposed)
|
|
81
|
+
this.deps.reply(id, result);
|
|
82
|
+
}
|
|
83
|
+
reportStatus() {
|
|
84
|
+
if (!this.disposed)
|
|
85
|
+
this.deps.onStatus({ state: this.status, toolbar: this.toolbar });
|
|
86
|
+
}
|
|
87
|
+
setToolbar(loaded) {
|
|
88
|
+
if (this.toolbar === loaded)
|
|
89
|
+
return;
|
|
90
|
+
this.toolbar = loaded;
|
|
91
|
+
this.reportStatus();
|
|
92
|
+
}
|
|
93
|
+
showToast(text) {
|
|
94
|
+
this.toast = { id: ++this.toastId, text };
|
|
95
|
+
}
|
|
96
|
+
async handle(message) {
|
|
97
|
+
if (this.disposed)
|
|
98
|
+
return;
|
|
99
|
+
switch (message.action) {
|
|
100
|
+
case "ready":
|
|
101
|
+
return this.ready(message.url);
|
|
102
|
+
case "pause":
|
|
103
|
+
return this.pause();
|
|
104
|
+
case "resume":
|
|
105
|
+
return this.resume();
|
|
106
|
+
case "resume-goto":
|
|
107
|
+
return this.answer(message.add);
|
|
108
|
+
case "stop":
|
|
109
|
+
return this.deps.stop();
|
|
110
|
+
case "move":
|
|
111
|
+
if (!Number.isFinite(message.x) || !Number.isFinite(message.y))
|
|
112
|
+
return;
|
|
113
|
+
this.position = { x: Math.round(message.x), y: Math.round(message.y) };
|
|
114
|
+
return this.push();
|
|
115
|
+
case "collapse":
|
|
116
|
+
this.collapsed = message.collapsed === true;
|
|
117
|
+
this.pick = undefined;
|
|
118
|
+
return this.push();
|
|
119
|
+
case "pick":
|
|
120
|
+
return this.picked(message);
|
|
121
|
+
case "assert":
|
|
122
|
+
return this.once(message, () => this.assert(message));
|
|
123
|
+
case "insert":
|
|
124
|
+
return this.once(message, () => this.insert(message));
|
|
125
|
+
case "add-insertable":
|
|
126
|
+
return this.once(message, () => this.addInsertable(message));
|
|
127
|
+
case "wait":
|
|
128
|
+
if (this.paused)
|
|
129
|
+
return;
|
|
130
|
+
if (message.kind === "time") {
|
|
131
|
+
if (!Number.isFinite(message.ms) || message.ms < 0)
|
|
132
|
+
return;
|
|
133
|
+
// The panel's range (0.1–60 s), whatever the message says
|
|
134
|
+
const ms = Math.min(WAIT_MAX_MS, Math.max(WAIT_MIN_MS, Math.round(message.ms)));
|
|
135
|
+
this.deps.observe({ type: "wait-time", ms });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this.deps.observe({ type: "wait-idle" });
|
|
139
|
+
}
|
|
140
|
+
this.showToast("Wait step added");
|
|
141
|
+
return this.push();
|
|
142
|
+
case "screenshot":
|
|
143
|
+
if (this.paused)
|
|
144
|
+
return;
|
|
145
|
+
this.deps.observe({
|
|
146
|
+
type: "screenshot",
|
|
147
|
+
name: cleanName(String(message.name ?? "")),
|
|
148
|
+
fullPage: message.fullPage === true,
|
|
149
|
+
});
|
|
150
|
+
this.showToast("Screenshot step added");
|
|
151
|
+
return this.push();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** A top-level document finished loading: push state; start the 2 s ready watchdog */
|
|
155
|
+
loaded() {
|
|
156
|
+
if (this.disposed)
|
|
157
|
+
return;
|
|
158
|
+
this.page++;
|
|
159
|
+
this.pick = undefined;
|
|
160
|
+
this.toast = undefined;
|
|
161
|
+
this.push();
|
|
162
|
+
// A new document starts with capture on
|
|
163
|
+
if (this.paused)
|
|
164
|
+
void this.deps.mute().catch(() => undefined);
|
|
165
|
+
clearTimeout(this.watchdog);
|
|
166
|
+
this.watchdog = undefined;
|
|
167
|
+
if (this.readySinceLoad) {
|
|
168
|
+
this.readySinceLoad = false;
|
|
169
|
+
this.setToolbar(true);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.watchdog = setTimeout(() => {
|
|
173
|
+
this.watchdog = undefined;
|
|
174
|
+
this.setToolbar(false);
|
|
175
|
+
}, READY_WITHIN);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A top-level navigation to a new document started: a `ready` from before it belongs to the old document
|
|
179
|
+
* and doesn't count for the coming load
|
|
180
|
+
*/
|
|
181
|
+
navigating() {
|
|
182
|
+
this.readySinceLoad = false;
|
|
183
|
+
}
|
|
184
|
+
/** A step was emitted (any source): count it */
|
|
185
|
+
stepAdded(step) {
|
|
186
|
+
this.steps++;
|
|
187
|
+
if (step.kind === "screenshot")
|
|
188
|
+
this.screenshots++;
|
|
189
|
+
this.push();
|
|
190
|
+
}
|
|
191
|
+
/** The recording ended: no more pushes, replies or status */
|
|
192
|
+
dispose() {
|
|
193
|
+
this.disposed = true;
|
|
194
|
+
clearTimeout(this.watchdog);
|
|
195
|
+
this.watchdog = undefined;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The toolbar draws at document start, so its `ready` usually comes before the load; Firefox also runs it
|
|
199
|
+
* in a transient about:blank while navigating. A `ready` that arrives while the watchdog runs settles it;
|
|
200
|
+
* any other counts for the next load.
|
|
201
|
+
*/
|
|
202
|
+
ready(url) {
|
|
203
|
+
// The transient about:blank Firefox passes through while navigating is not the page being recorded
|
|
204
|
+
if (url === "about:blank")
|
|
205
|
+
return;
|
|
206
|
+
if (this.watchdog !== undefined) {
|
|
207
|
+
clearTimeout(this.watchdog);
|
|
208
|
+
this.watchdog = undefined;
|
|
209
|
+
}
|
|
210
|
+
else if (this.toolbar) {
|
|
211
|
+
this.readySinceLoad = true;
|
|
212
|
+
}
|
|
213
|
+
this.setToolbar(true);
|
|
214
|
+
this.push();
|
|
215
|
+
}
|
|
216
|
+
async pause() {
|
|
217
|
+
if (this.paused)
|
|
218
|
+
return;
|
|
219
|
+
this.status = "paused";
|
|
220
|
+
this.question = undefined;
|
|
221
|
+
this.pick = undefined;
|
|
222
|
+
this.push();
|
|
223
|
+
this.reportStatus();
|
|
224
|
+
const [, url] = await Promise.all([
|
|
225
|
+
this.deps.mute(),
|
|
226
|
+
this.deps.currentUrl(),
|
|
227
|
+
]);
|
|
228
|
+
this.pausedAt = url;
|
|
229
|
+
}
|
|
230
|
+
async resume() {
|
|
231
|
+
if (!this.paused)
|
|
232
|
+
return;
|
|
233
|
+
const url = await this.deps.currentUrl();
|
|
234
|
+
await this.deps.unmute();
|
|
235
|
+
this.status = "recording";
|
|
236
|
+
this.question =
|
|
237
|
+
this.pausedAt !== undefined && url !== this.pausedAt ? url : undefined;
|
|
238
|
+
this.pausedAt = undefined;
|
|
239
|
+
this.push();
|
|
240
|
+
this.reportStatus();
|
|
241
|
+
}
|
|
242
|
+
answer(add) {
|
|
243
|
+
const url = this.question;
|
|
244
|
+
if (url === undefined)
|
|
245
|
+
return;
|
|
246
|
+
this.question = undefined;
|
|
247
|
+
if (add === true)
|
|
248
|
+
this.deps.observe({ type: "goto", url });
|
|
249
|
+
this.push();
|
|
250
|
+
}
|
|
251
|
+
/** Runs an action that expects a reply; a second one with the same id while the first runs is dropped */
|
|
252
|
+
/**
|
|
253
|
+
* Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `insert` or
|
|
254
|
+
* `add-insertable` whose id is in flight or was already answered `{ ok: true }` is a double click and is
|
|
255
|
+
* dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
|
|
256
|
+
*/
|
|
257
|
+
accept(message) {
|
|
258
|
+
if (message.action !== "assert" &&
|
|
259
|
+
message.action !== "insert" &&
|
|
260
|
+
message.action !== "add-insertable")
|
|
261
|
+
return true;
|
|
262
|
+
const { id } = message;
|
|
263
|
+
if (this.inFlight.has(id) || this.done.has(id))
|
|
264
|
+
return false;
|
|
265
|
+
this.inFlight.add(id);
|
|
266
|
+
this.claimed.add(id);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
/** Runs an action that expects a reply, unless it is a repeat (see `accept`) */
|
|
270
|
+
async once(message, work) {
|
|
271
|
+
const { id } = message;
|
|
272
|
+
if (!this.claimed.has(id) && !this.accept(message))
|
|
273
|
+
return;
|
|
274
|
+
this.claimed.delete(id);
|
|
275
|
+
try {
|
|
276
|
+
const result = await work();
|
|
277
|
+
if (result.ok)
|
|
278
|
+
this.done.add(id);
|
|
279
|
+
this.reply(id, result);
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
this.inFlight.delete(id);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async picked(message) {
|
|
286
|
+
const page = this.page;
|
|
287
|
+
if (this.paused)
|
|
288
|
+
return this.reply(message.id, fail(PAUSED));
|
|
289
|
+
let found;
|
|
290
|
+
try {
|
|
291
|
+
const locator = message.target &&
|
|
292
|
+
(await this.deps.rank(message.target, message.candidates));
|
|
293
|
+
if (locator) {
|
|
294
|
+
found = await this.deps.inspect(message.target, locator, message.candidates);
|
|
295
|
+
if (page === this.page)
|
|
296
|
+
this.pick = { id: message.id, locator, counts: found.counts };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
found = undefined; // the element went away while it was inspected
|
|
301
|
+
}
|
|
302
|
+
// The page that asked is gone: its successor's toolbar isn't waiting for this
|
|
303
|
+
if (page !== this.page)
|
|
304
|
+
return;
|
|
305
|
+
this.reply(message.id, found ? this.withoutSecrets(found.info) : fail(UNIDENTIFIABLE));
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* A field may hold an inserted secret: its value (and any text or attribute equal to a secret) is left out,
|
|
309
|
+
* and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
|
|
310
|
+
*/
|
|
311
|
+
withoutSecrets(info) {
|
|
312
|
+
const values = [...this.secrets.values()].filter((value) => value !== undefined && value !== "");
|
|
313
|
+
const secret = (text) => text !== undefined && values.some((value) => text.includes(value));
|
|
314
|
+
if (!secret(info.value) &&
|
|
315
|
+
!secret(info.text) &&
|
|
316
|
+
!secret(info.first.expected) &&
|
|
317
|
+
!info.attributes.some((a) => secret(a.value)))
|
|
318
|
+
return info;
|
|
319
|
+
const text = secret(info.text) ? "" : info.text;
|
|
320
|
+
const first = expectationFor(text, undefined);
|
|
321
|
+
const { value: _value, ...rest } = info;
|
|
322
|
+
return {
|
|
323
|
+
...rest,
|
|
324
|
+
text,
|
|
325
|
+
attributes: info.attributes.filter((a) => !secret(a.value)),
|
|
326
|
+
first: {
|
|
327
|
+
matcher: first.matcher,
|
|
328
|
+
...("expected" in first && { expected: String(first.expected) }),
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
async assert(message) {
|
|
333
|
+
if (this.paused)
|
|
334
|
+
return fail(PAUSED);
|
|
335
|
+
const pick = this.pick;
|
|
336
|
+
if (!pick || pick.id !== message.id)
|
|
337
|
+
return fail("Pick the element again");
|
|
338
|
+
let locator = pick.locator;
|
|
339
|
+
let expectation;
|
|
340
|
+
const expected = String(message.expected ?? "");
|
|
341
|
+
switch (message.matcher) {
|
|
342
|
+
case "toBeVisible":
|
|
343
|
+
expectation = { matcher: "toBeVisible" };
|
|
344
|
+
break;
|
|
345
|
+
case "toHaveText":
|
|
346
|
+
case "toContainText":
|
|
347
|
+
case "toHaveValue":
|
|
348
|
+
expectation = { matcher: message.matcher, expected };
|
|
349
|
+
break;
|
|
350
|
+
case "toHaveAttribute":
|
|
351
|
+
if (!message.attribute)
|
|
352
|
+
return fail("Choose an attribute");
|
|
353
|
+
expectation = {
|
|
354
|
+
matcher: "toHaveAttribute",
|
|
355
|
+
name: message.attribute,
|
|
356
|
+
expected,
|
|
357
|
+
};
|
|
358
|
+
break;
|
|
359
|
+
case "toHaveCount": {
|
|
360
|
+
const call = pick.counts[message.countIndex ?? 0];
|
|
361
|
+
const count = Number(expected);
|
|
362
|
+
if (!call)
|
|
363
|
+
return fail("Choose what to count");
|
|
364
|
+
if (expected.trim() === "" || !Number.isInteger(count) || count < 0)
|
|
365
|
+
return fail("A count is a whole number");
|
|
366
|
+
locator = { chain: [call], fallbacks: [] };
|
|
367
|
+
expectation = { matcher: "toHaveCount", expected: count };
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
default:
|
|
371
|
+
return fail("Choose a matcher");
|
|
372
|
+
}
|
|
373
|
+
this.pick = undefined;
|
|
374
|
+
this.deps.observe({
|
|
375
|
+
type: "expect",
|
|
376
|
+
locator,
|
|
377
|
+
not: message.not === true,
|
|
378
|
+
expectation,
|
|
379
|
+
});
|
|
380
|
+
return { ok: true };
|
|
381
|
+
}
|
|
382
|
+
async insert(message) {
|
|
383
|
+
if (this.paused)
|
|
384
|
+
return fail(PAUSED);
|
|
385
|
+
const { kind, name } = message;
|
|
386
|
+
const value = kind === "secret" ? this.secrets.get(name) : this.variables.get(name);
|
|
387
|
+
if (value === undefined)
|
|
388
|
+
return fail(kind === "secret"
|
|
389
|
+
? `Set a value for ${name} first`
|
|
390
|
+
: `There is no variable "${name}"`);
|
|
391
|
+
const locator = await this.locate(message);
|
|
392
|
+
if (!locator)
|
|
393
|
+
return fail(UNIDENTIFIABLE);
|
|
394
|
+
return this.typeIn(locator, kind, name, value);
|
|
395
|
+
}
|
|
396
|
+
async addInsertable(message) {
|
|
397
|
+
if (this.paused)
|
|
398
|
+
return fail(PAUSED);
|
|
399
|
+
const { kind, name } = message;
|
|
400
|
+
const value = String(message.value ?? "");
|
|
401
|
+
if (kind === "env" && !this.options.canAddVariables)
|
|
402
|
+
return fail(NO_ENVIRONMENT);
|
|
403
|
+
const known = kind === "env"
|
|
404
|
+
? this.variables.has(name)
|
|
405
|
+
: this.secrets.get(name) !== undefined;
|
|
406
|
+
if (known)
|
|
407
|
+
return fail(`"${name}" already exists`, "name");
|
|
408
|
+
// Found before anything is written: an element that can't be filled leaves the project as it was
|
|
409
|
+
const locator = await this.locate(message);
|
|
410
|
+
if (!locator)
|
|
411
|
+
return fail(UNIDENTIFIABLE);
|
|
412
|
+
const written = kind === "env"
|
|
413
|
+
? await this.write(() => this.deps.addVariable(name, value), (err) => err instanceof ConfigEditError
|
|
414
|
+
? fail(`samurai.config.ts can't be changed automatically. Add ${name} to environments.${this.options.environment}.variables yourself.`)
|
|
415
|
+
: err instanceof ExistsError || !VARIABLE_NAME.test(name)
|
|
416
|
+
? fail(errorText(err), "name")
|
|
417
|
+
: fail(errorText(err)))
|
|
418
|
+
: await this.write(() => this.deps.setSecret(name, value), (err) =>
|
|
419
|
+
// An error never echoes the value
|
|
420
|
+
SECRET_NAME.test(name)
|
|
421
|
+
? fail(scrub(errorText(err), value))
|
|
422
|
+
: fail(scrub(errorText(err), value), "name"));
|
|
423
|
+
if (written)
|
|
424
|
+
return written;
|
|
425
|
+
if (kind === "env")
|
|
426
|
+
this.variables.set(name, value);
|
|
427
|
+
else {
|
|
428
|
+
this.secrets.set(name, value);
|
|
429
|
+
this.deps.secretAdded(name);
|
|
430
|
+
}
|
|
431
|
+
this.push();
|
|
432
|
+
return this.typeIn(locator, kind, name, value, true);
|
|
433
|
+
}
|
|
434
|
+
/** Undefined when `work` succeeded, else the reply for its error */
|
|
435
|
+
async write(work, replyFor) {
|
|
436
|
+
try {
|
|
437
|
+
await work();
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
return replyFor(err);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async locate(message) {
|
|
445
|
+
if (!message.target)
|
|
446
|
+
return undefined;
|
|
447
|
+
try {
|
|
448
|
+
return await this.deps.rank(message.target, message.candidates);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return undefined; // the element went away
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async typeIn(locator, kind, name, value, saved = false) {
|
|
455
|
+
try {
|
|
456
|
+
await this.deps.type(locator, value);
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// Typing clicks the field, which closes the panel the reply is for: the toast is what the tester sees
|
|
460
|
+
this.showToast(saved
|
|
461
|
+
? `Saved ${name}, but couldn't type into that field`
|
|
462
|
+
: TYPE_FAILED);
|
|
463
|
+
this.push();
|
|
464
|
+
return fail(TYPE_FAILED);
|
|
465
|
+
}
|
|
466
|
+
this.deps.observe({ type: "insert", locator, value: { kind, name } });
|
|
467
|
+
return { ok: true };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const errorText = (err) => err instanceof Error ? err.message : String(err);
|
|
471
|
+
const scrub = (text, secret) => secret === "" ? text : text.split(secret).join("••••••");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type Page from "../../browser/page.js";
|
|
2
|
+
import type { ElementHandle } from "../../script/element-handle.js";
|
|
3
|
+
import type { LocatorCall, LocatorSpec } from "../../steps/model.js";
|
|
4
|
+
import type { BiDiConnector } from "../../transport/bidi-connection.js";
|
|
5
|
+
import type { Inspection } from "./controller.js";
|
|
6
|
+
/** A locator call in short, as the Assert panel shows it: `heading "Sign in"`, `label "Email"`, `li.item` */
|
|
7
|
+
export declare function describeCall(call: LocatorCall): string;
|
|
8
|
+
/**
|
|
9
|
+
* What the Assert panel needs for a picked element: its short label, text, value, attributes, the first
|
|
10
|
+
* matcher (today's Alt+click rule) and up to three locators that match more than one element, with counts.
|
|
11
|
+
*/
|
|
12
|
+
export declare function inspectElement(page: Page, connector: BiDiConnector, context: string, target: ElementHandle, locator: LocatorSpec, candidates: LocatorCall[]): Promise<Inspection>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { callFunction } from "../../script/call-function.js";
|
|
2
|
+
import { locatorFromSpec } from "../../steps/build.js";
|
|
3
|
+
import { CANDIDATES_JS } from "../candidates.js";
|
|
4
|
+
import { expectationFor } from "../normalise.js";
|
|
5
|
+
import { TOOLBAR_SANDBOX } from "./protocol.js";
|
|
6
|
+
/** Most Count choices the Assert panel offers */
|
|
7
|
+
const MAX_COUNTS = 3;
|
|
8
|
+
/**
|
|
9
|
+
* Run in the toolbar's sandbox on the picked element: its text (cut at 4 KB), value (as the Alt+click rule reads it), first
|
|
10
|
+
* 20 attributes, its role without a name and its `tag.class` selector. `roleOf` only knows the roles the
|
|
11
|
+
* candidates use, so the common list and table roles are added for Count.
|
|
12
|
+
*/
|
|
13
|
+
const INSPECT_SOURCE = String.raw `(el) => {
|
|
14
|
+
${CANDIDATES_JS}
|
|
15
|
+
const TEXT_CAP = 4096;
|
|
16
|
+
const MORE_ROLES = { li: "listitem", ul: "list", ol: "list", tr: "row", td: "cell", th: "columnheader", option: "option", nav: "navigation", table: "table", article: "article", dialog: "dialog" };
|
|
17
|
+
const tag = el.tagName.toLowerCase();
|
|
18
|
+
const hasValue = typeof el.value === "string" && el.tagName !== "BUTTON" && el.tagName !== "LI";
|
|
19
|
+
const secret = tag === "input" && (el.getAttribute("type") || "").toLowerCase() === "password";
|
|
20
|
+
const classes = Array.from(el.classList).filter(Boolean);
|
|
21
|
+
return {
|
|
22
|
+
text: norm(String(el.textContent == null ? "" : el.textContent).slice(0, TEXT_CAP * 2)).slice(0, TEXT_CAP),
|
|
23
|
+
value: hasValue && !secret ? el.value : null,
|
|
24
|
+
attributes: Array.from(el.attributes).slice(0, 20).map((a) => ({ name: a.name, value: a.value })),
|
|
25
|
+
role: roleOf(el) || MORE_ROLES[tag] || null,
|
|
26
|
+
css: classes.length ? CSS.escape(tag) + classes.map((c) => "." + CSS.escape(c)).join("") : null,
|
|
27
|
+
};
|
|
28
|
+
}`;
|
|
29
|
+
/** A locator call in short, as the Assert panel shows it: `heading "Sign in"`, `label "Email"`, `li.item` */
|
|
30
|
+
export function describeCall(call) {
|
|
31
|
+
switch (call.method) {
|
|
32
|
+
case "getByRole":
|
|
33
|
+
return call.name === undefined
|
|
34
|
+
? call.role
|
|
35
|
+
: `${call.role} "${call.name}"`;
|
|
36
|
+
case "getByLabel":
|
|
37
|
+
return `label "${call.text}"`;
|
|
38
|
+
case "getByText":
|
|
39
|
+
return `text "${call.text}"`;
|
|
40
|
+
case "getByTestId":
|
|
41
|
+
return `test id "${call.testId}"`;
|
|
42
|
+
case "getByCss":
|
|
43
|
+
return call.css;
|
|
44
|
+
case "locator":
|
|
45
|
+
return call.xpath;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const sameCall = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
49
|
+
/**
|
|
50
|
+
* What the Assert panel needs for a picked element: its short label, text, value, attributes, the first
|
|
51
|
+
* matcher (today's Alt+click rule) and up to three locators that match more than one element, with counts.
|
|
52
|
+
*/
|
|
53
|
+
export async function inspectElement(page, connector, context, target, locator, candidates) {
|
|
54
|
+
const found = await callFunction(connector, context, INSPECT_SOURCE, [target], { sandbox: TOOLBAR_SANDBOX });
|
|
55
|
+
const value = found.value ?? undefined;
|
|
56
|
+
const first = expectationFor(found.text, value);
|
|
57
|
+
// The element's role without a name, its tag with its classes, then its other candidates
|
|
58
|
+
const options = [];
|
|
59
|
+
if (found.role)
|
|
60
|
+
options.push({ method: "getByRole", role: found.role });
|
|
61
|
+
if (found.css)
|
|
62
|
+
options.push({ method: "getByCss", css: found.css });
|
|
63
|
+
for (const call of candidates)
|
|
64
|
+
if (call.method !== "locator" && !options.some((o) => sameCall(o, call)))
|
|
65
|
+
options.push(call);
|
|
66
|
+
const calls = [];
|
|
67
|
+
const counts = [];
|
|
68
|
+
for (const call of options) {
|
|
69
|
+
if (calls.length >= MAX_COUNTS)
|
|
70
|
+
break;
|
|
71
|
+
const count = await locatorFromSpec(page, { chain: [call], fallbacks: [] })
|
|
72
|
+
.count()
|
|
73
|
+
.catch(() => 0); // e.g. a role the browser doesn't know
|
|
74
|
+
if (count <= 1)
|
|
75
|
+
continue;
|
|
76
|
+
calls.push(call);
|
|
77
|
+
counts.push({ label: describeCall(call), count });
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
info: {
|
|
81
|
+
ok: true,
|
|
82
|
+
label: describeCall(locator.chain[0]),
|
|
83
|
+
text: found.text,
|
|
84
|
+
...(value !== undefined && { value }),
|
|
85
|
+
attributes: found.attributes,
|
|
86
|
+
first: {
|
|
87
|
+
matcher: first.matcher,
|
|
88
|
+
...("expected" in first && { expected: String(first.expected) }),
|
|
89
|
+
},
|
|
90
|
+
counts,
|
|
91
|
+
},
|
|
92
|
+
counts: calls,
|
|
93
|
+
};
|
|
94
|
+
}
|