@itmetsam/samurai-framework 0.17.0 → 0.19.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/dist/assert/expect.d.ts +5 -0
- package/dist/assert/expect.js +8 -3
- package/dist/assert/locator-assertions.d.ts +16 -1
- package/dist/assert/locator-assertions.js +34 -7
- package/dist/assert/page-assertions.d.ts +25 -0
- package/dist/assert/page-assertions.js +67 -0
- package/dist/assert/text-match.d.ts +2 -0
- package/dist/assert/text-match.js +7 -0
- package/dist/browser/page-navigation.d.ts +4 -0
- package/dist/browser/page-navigation.js +15 -5
- package/dist/browser/page.d.ts +2 -0
- package/dist/browser/page.js +4 -0
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.js +6 -0
- package/dist/flows/run/handlers/support.js +1 -1
- package/dist/flows/run/handlers/types.d.ts +1 -1
- package/dist/flows/run/node-pool.d.ts +1 -1
- package/dist/flows/run/node-pool.js +7 -2
- package/dist/flows/run/project-options.d.ts +5 -1
- package/dist/flows/run/project.js +8 -8
- package/dist/flows/run/run.d.ts +2 -0
- package/dist/flows/run/run.js +2 -1
- package/dist/locator/element-reads.d.ts +4 -0
- package/dist/locator/element-reads.js +11 -0
- package/dist/locator/locator.d.ts +8 -0
- package/dist/locator/locator.js +25 -0
- package/dist/recorder/expectation.d.ts +5 -0
- package/dist/recorder/expectation.js +18 -0
- package/dist/recorder/normalise.d.ts +7 -1
- package/dist/recorder/normalise.js +6 -0
- package/dist/recorder/options.d.ts +1 -1
- package/dist/recorder/recorder.js +3 -0
- package/dist/recorder/toolbar/action-guard.d.ts +2 -2
- package/dist/recorder/toolbar/action-guard.js +2 -1
- package/dist/recorder/toolbar/actions/assert-page.d.ts +6 -0
- package/dist/recorder/toolbar/actions/assert-page.js +33 -0
- package/dist/recorder/toolbar/actions/assert.js +17 -1
- package/dist/recorder/toolbar/actions/pick.d.ts +3 -1
- package/dist/recorder/toolbar/actions/pick.js +11 -3
- package/dist/recorder/toolbar/controller-fixture.d.ts +1 -0
- package/dist/recorder/toolbar/controller-fixture.js +6 -1
- package/dist/recorder/toolbar/controller.d.ts +4 -2
- package/dist/recorder/toolbar/controller.js +23 -2
- package/dist/recorder/toolbar/inspect.d.ts +2 -1
- package/dist/recorder/toolbar/inspect.js +21 -4
- package/dist/recorder/toolbar/protocol.d.ts +37 -3
- package/dist/recorder/toolbar/recorder-toolbar-fixture.d.ts +21 -0
- package/dist/recorder/toolbar/recorder-toolbar-fixture.js +40 -0
- package/dist/recorder/toolbar/source/fields.js +9 -0
- package/dist/recorder/toolbar/source/handlers.js +25 -1
- package/dist/recorder/toolbar/source/picking.js +8 -1
- package/dist/recorder/toolbar/source/recorder-side.js +10 -0
- package/dist/recorder/toolbar/source/setup.js +6 -1
- package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
- package/dist/recorder/toolbar/source/views-panels.js +39 -4
- package/dist/recorder/toolbar/source/views-rail.js +1 -0
- package/dist/recorder/toolbar/source.d.ts +6 -5
- package/dist/recorder/toolbar/source.js +6 -5
- package/dist/recorder/toolbar/types.d.ts +9 -2
- package/dist/recorder/toolbar-bridge.d.ts +2 -0
- package/dist/recorder/toolbar-bridge.js +6 -1
- package/dist/runner/worker-protocol.d.ts +2 -0
- package/dist/runner/worker-session.js +3 -3
- package/dist/runner/worker.d.ts +3 -1
- package/dist/runner/worker.js +1 -1
- package/dist/steps/emit.js +8 -1
- package/dist/steps/index.d.ts +1 -1
- package/dist/steps/model.d.ts +19 -1
- package/dist/steps/parse/expectation.d.ts +2 -1
- package/dist/steps/parse/expectation.js +25 -1
- package/dist/steps/parse/step.js +9 -2
- package/package.json +1 -1
|
@@ -47,6 +47,12 @@ export class Normaliser {
|
|
|
47
47
|
not: observation.not,
|
|
48
48
|
expectation: observation.expectation,
|
|
49
49
|
};
|
|
50
|
+
case "expectPage":
|
|
51
|
+
return {
|
|
52
|
+
kind: "expectPage",
|
|
53
|
+
not: observation.not,
|
|
54
|
+
expectation: observation.expectation,
|
|
55
|
+
};
|
|
50
56
|
case "insert":
|
|
51
57
|
return {
|
|
52
58
|
kind: "fill",
|
|
@@ -49,7 +49,7 @@ export interface RecorderOptions {
|
|
|
49
49
|
initialGoto?: boolean;
|
|
50
50
|
/** URLs inside it are written relative to it, as `page.goto` resolves them */
|
|
51
51
|
baseURL?: string;
|
|
52
|
-
/** Shows the recorder toolbar in the page (pause, assert, insert, wait, screenshot, stop). Without it, no toolbar */
|
|
52
|
+
/** Shows the recorder toolbar in the page (pause, assert, page, insert, wait, screenshot, stop). Without it, no toolbar */
|
|
53
53
|
toolbar?: ToolbarOptions;
|
|
54
54
|
/** Records uploads (file inputs and drops) into the project's files folder. Without it, uploads aren't recorded */
|
|
55
55
|
files?: FileRecording;
|
|
@@ -180,6 +180,9 @@ export class Recorder {
|
|
|
180
180
|
page: this.page,
|
|
181
181
|
connector: this.connector,
|
|
182
182
|
context: this.context,
|
|
183
|
+
...(this.options.baseURL !== undefined && {
|
|
184
|
+
baseURL: this.options.baseURL,
|
|
185
|
+
}),
|
|
183
186
|
mute: () => this.mute(),
|
|
184
187
|
unmute: () => this.unmute(),
|
|
185
188
|
observe: (observation) => this.emit(this.normaliser.observe(observation, Date.now())),
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { ActionReply } from "./protocol.js";
|
|
2
2
|
import type { Message, Of } from "./types.js";
|
|
3
3
|
/** The messages that expect a reply, and that a double click sends twice */
|
|
4
|
-
type Replying = Of<"assert" | "insert" | "add-insertable">;
|
|
4
|
+
type Replying = Of<"assert" | "assert-page" | "insert" | "add-insertable">;
|
|
5
5
|
/**
|
|
6
6
|
* Drops the repeat of an action that is running or was answered `{ ok: true }` (a double click: its first copy
|
|
7
7
|
* answers), and lets a retry after `{ ok: false }` through.
|
|
8
8
|
*/
|
|
9
9
|
export declare class ActionGuard {
|
|
10
|
-
/** Ids of `assert`, `insert` and `add-insertable` being processed */
|
|
10
|
+
/** Ids of `assert`, `assert-page`, `insert` and `add-insertable` being processed */
|
|
11
11
|
private readonly inFlight;
|
|
12
12
|
/** Ids `accept` let through whose `run` hasn't started yet */
|
|
13
13
|
private readonly claimed;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export class ActionGuard {
|
|
6
6
|
constructor() {
|
|
7
|
-
/** Ids of `assert`, `insert` and `add-insertable` being processed */
|
|
7
|
+
/** Ids of `assert`, `assert-page`, `insert` and `add-insertable` being processed */
|
|
8
8
|
this.inFlight = new Set();
|
|
9
9
|
/** Ids `accept` let through whose `run` hasn't started yet */
|
|
10
10
|
this.claimed = new Set();
|
|
@@ -14,6 +14,7 @@ export class ActionGuard {
|
|
|
14
14
|
/** Whether to process `message`; call it as the message arrives, before queueing it */
|
|
15
15
|
accept(message) {
|
|
16
16
|
if (message.action !== "assert" &&
|
|
17
|
+
message.action !== "assert-page" &&
|
|
17
18
|
message.action !== "insert" &&
|
|
18
19
|
message.action !== "add-insertable")
|
|
19
20
|
return true;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ActionReply, PageInfo } from "../protocol.js";
|
|
2
|
+
import type { ActionContext, ControllerDeps, Of } from "../types.js";
|
|
3
|
+
/** What the Page panel offers: the recorded page's URL (relative to the base URL when it can be) and title */
|
|
4
|
+
export declare function pageInfoOf(deps: ControllerDeps, secrets: Map<string, string | undefined>): Promise<PageInfo>;
|
|
5
|
+
/** Turns the Page panel's choice into an `expectPage` step */
|
|
6
|
+
export declare function assertPageAction(ctx: ActionContext, message: Of<"assert-page">): Promise<ActionReply>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { urlForAssertion } from "../../expectation.js";
|
|
2
|
+
import { holdsSecret } from "./pick.js";
|
|
3
|
+
import { fail, PAUSED } from "./reply.js";
|
|
4
|
+
/** What the Page panel offers: the recorded page's URL (relative to the base URL when it can be) and title */
|
|
5
|
+
export async function pageInfoOf(deps, secrets) {
|
|
6
|
+
const { url, title } = await deps.pageInfo();
|
|
7
|
+
const secret = holdsSecret(secrets);
|
|
8
|
+
// A secret never reaches the page: a URL or title holding one is left out
|
|
9
|
+
const clean = (text) => (secret(text) ? "" : text);
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
url: clean(url),
|
|
13
|
+
urlPrefill: clean(urlForAssertion(url, deps.baseURL)),
|
|
14
|
+
title: clean(title),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Turns the Page panel's choice into an `expectPage` step */
|
|
18
|
+
export async function assertPageAction(ctx, message) {
|
|
19
|
+
if (ctx.isPaused())
|
|
20
|
+
return fail(PAUSED);
|
|
21
|
+
const { matcher } = message;
|
|
22
|
+
if (matcher !== "toHaveURL" && matcher !== "toHaveTitle")
|
|
23
|
+
return fail("Choose a matcher");
|
|
24
|
+
const expected = String(message.expected ?? "");
|
|
25
|
+
if (expected.trim() === "")
|
|
26
|
+
return fail("Fill in the value");
|
|
27
|
+
ctx.deps.observe({
|
|
28
|
+
type: "expectPage",
|
|
29
|
+
not: message.not === true,
|
|
30
|
+
expectation: { matcher, expected },
|
|
31
|
+
});
|
|
32
|
+
return { ok: true };
|
|
33
|
+
}
|
|
@@ -11,8 +11,24 @@ export async function assertAction(ctx, message) {
|
|
|
11
11
|
const expected = String(message.expected ?? "");
|
|
12
12
|
switch (message.matcher) {
|
|
13
13
|
case "toBeVisible":
|
|
14
|
-
|
|
14
|
+
case "toBeHidden":
|
|
15
|
+
case "toBeEnabled":
|
|
16
|
+
case "toBeChecked":
|
|
17
|
+
expectation = { matcher: message.matcher };
|
|
15
18
|
break;
|
|
19
|
+
case "toHaveClass":
|
|
20
|
+
expectation = { matcher: "toHaveClass", expected };
|
|
21
|
+
break;
|
|
22
|
+
case "toHaveCSS": {
|
|
23
|
+
const name = String(message.cssName ?? "").trim();
|
|
24
|
+
if (!name)
|
|
25
|
+
return fail("Choose a CSS property");
|
|
26
|
+
// A typo'd property computes to "", which would make the step always pass
|
|
27
|
+
if (!expected.trim())
|
|
28
|
+
return fail("Fill in the value");
|
|
29
|
+
expectation = { matcher: "toHaveCSS", name, expected };
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
16
32
|
case "toHaveText":
|
|
17
33
|
case "toContainText":
|
|
18
34
|
case "toHaveValue":
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { PickInfo } from "../protocol.js";
|
|
2
|
+
/** Whether a text holds the value of one of the secrets */
|
|
3
|
+
export declare function holdsSecret(secrets: Map<string, string | undefined>): (text: string | undefined) => boolean;
|
|
2
4
|
/**
|
|
3
|
-
* A field may hold an inserted secret: its value (and any text or
|
|
5
|
+
* A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
|
|
4
6
|
* and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
|
|
5
7
|
*/
|
|
6
8
|
export declare function withoutSecrets(info: PickInfo & {
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { expectationFor } from "../../expectation.js";
|
|
2
|
+
/** Whether a text holds the value of one of the secrets */
|
|
3
|
+
export function holdsSecret(secrets) {
|
|
4
|
+
const values = [...secrets.values()].filter((value) => value !== undefined && value !== "");
|
|
5
|
+
return (text) => text !== undefined && values.some((value) => text.includes(value));
|
|
6
|
+
}
|
|
2
7
|
/**
|
|
3
|
-
* A field may hold an inserted secret: its value (and any text or
|
|
8
|
+
* A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
|
|
4
9
|
* and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
|
|
5
10
|
*/
|
|
6
11
|
export function withoutSecrets(info, secrets) {
|
|
7
|
-
const
|
|
8
|
-
const secret = (text) => text !== undefined && values.some((value) => text.includes(value));
|
|
12
|
+
const secret = holdsSecret(secrets);
|
|
9
13
|
if (!secret(info.value) &&
|
|
10
14
|
!secret(info.text) &&
|
|
11
15
|
!secret(info.first.expected) &&
|
|
16
|
+
!secret(info.className ?? undefined) &&
|
|
17
|
+
!secret(info.css.value) &&
|
|
12
18
|
!info.attributes.some((a) => secret(a.value)))
|
|
13
19
|
return info;
|
|
14
20
|
const text = secret(info.text) ? "" : info.text;
|
|
@@ -18,6 +24,8 @@ export function withoutSecrets(info, secrets) {
|
|
|
18
24
|
...rest,
|
|
19
25
|
text,
|
|
20
26
|
attributes: info.attributes.filter((a) => !secret(a.value)),
|
|
27
|
+
className: secret(info.className ?? undefined) ? null : info.className,
|
|
28
|
+
css: secret(info.css.value) ? { ...info.css, value: "" } : info.css,
|
|
21
29
|
first: {
|
|
22
30
|
matcher: first.matcher,
|
|
23
31
|
...("expected" in first && { expected: String(first.expected) }),
|
|
@@ -28,6 +28,10 @@ export const INFO = {
|
|
|
28
28
|
attributes: [{ name: "id", value: "email" }],
|
|
29
29
|
first: { matcher: "toHaveValue", expected: "a@b.c" },
|
|
30
30
|
counts: [{ label: "listitem", count: 3 }],
|
|
31
|
+
checkable: false,
|
|
32
|
+
disableable: true,
|
|
33
|
+
className: null,
|
|
34
|
+
css: { name: "color", value: "rgb(0, 0, 0)" },
|
|
31
35
|
};
|
|
32
36
|
export const lastOf = (items) => items[items.length - 1];
|
|
33
37
|
export const msg = (message) => ({ type: "toolbar", ...message });
|
|
@@ -56,12 +60,13 @@ export function setup(options = {}, overrides = {}) {
|
|
|
56
60
|
const observed = [];
|
|
57
61
|
const statuses = [];
|
|
58
62
|
const calls = [];
|
|
59
|
-
const page = { url: "http://localhost/a" };
|
|
63
|
+
const page = { url: "http://localhost/a", title: "Home" };
|
|
60
64
|
let controller;
|
|
61
65
|
const deps = {
|
|
62
66
|
push: (state) => pushes.push(structuredClone(state)),
|
|
63
67
|
reply: (id, result) => replies.push({ id, result: structuredClone(result) }),
|
|
64
68
|
currentUrl: async () => page.url,
|
|
69
|
+
pageInfo: async () => ({ url: page.url, title: page.title }),
|
|
65
70
|
rank: async () => LOCATOR,
|
|
66
71
|
inspect: async () => ({
|
|
67
72
|
info: INFO,
|
|
@@ -69,12 +69,14 @@ export declare class ToolbarController {
|
|
|
69
69
|
private resume;
|
|
70
70
|
private answer;
|
|
71
71
|
/**
|
|
72
|
-
* Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `
|
|
73
|
-
* `add-insertable` whose id is in flight or was already answered `{ ok: true }` is a double click and is
|
|
72
|
+
* Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `assert-page`,
|
|
73
|
+
* `insert` or `add-insertable` whose id is in flight or was already answered `{ ok: true }` is a double click and is
|
|
74
74
|
* dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
|
|
75
75
|
*/
|
|
76
76
|
accept(message: Message): boolean;
|
|
77
77
|
/** Runs an action that expects a reply, unless it is a repeat (see `accept`) */
|
|
78
78
|
private replying;
|
|
79
|
+
/** The Page panel opened: the recorded page's URL and title, unless that page was left meanwhile */
|
|
80
|
+
private pageInfo;
|
|
79
81
|
private picked;
|
|
80
82
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cleanName } from "../../browser/evidence.js";
|
|
2
2
|
import { ActionGuard } from "./action-guard.js";
|
|
3
3
|
import { assertAction } from "./actions/assert.js";
|
|
4
|
+
import { assertPageAction, pageInfoOf } from "./actions/assert-page.js";
|
|
4
5
|
import { addInsertableAction, insertAction } from "./actions/insert.js";
|
|
5
6
|
import { withoutSecrets } from "./actions/pick.js";
|
|
6
7
|
import { fail, PAUSED, UNIDENTIFIABLE } from "./actions/reply.js";
|
|
@@ -137,6 +138,10 @@ export class ToolbarController {
|
|
|
137
138
|
return this.picked(message);
|
|
138
139
|
case "assert":
|
|
139
140
|
return this.replying(message, () => assertAction(this.actions, message));
|
|
141
|
+
case "page-info":
|
|
142
|
+
return this.pageInfo(message);
|
|
143
|
+
case "assert-page":
|
|
144
|
+
return this.replying(message, () => assertPageAction(this.actions, message));
|
|
140
145
|
case "insert":
|
|
141
146
|
return this.replying(message, () => insertAction(this.actions, message));
|
|
142
147
|
case "add-insertable":
|
|
@@ -272,8 +277,8 @@ export class ToolbarController {
|
|
|
272
277
|
this.push();
|
|
273
278
|
}
|
|
274
279
|
/**
|
|
275
|
-
* Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `
|
|
276
|
-
* `add-insertable` whose id is in flight or was already answered `{ ok: true }` is a double click and is
|
|
280
|
+
* Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `assert-page`,
|
|
281
|
+
* `insert` or `add-insertable` whose id is in flight or was already answered `{ ok: true }` is a double click and is
|
|
277
282
|
* dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
|
|
278
283
|
*/
|
|
279
284
|
accept(message) {
|
|
@@ -283,6 +288,22 @@ export class ToolbarController {
|
|
|
283
288
|
replying(message, work) {
|
|
284
289
|
return this.guard.run(message, work, (id, result) => this.reply(id, result));
|
|
285
290
|
}
|
|
291
|
+
/** The Page panel opened: the recorded page's URL and title, unless that page was left meanwhile */
|
|
292
|
+
async pageInfo(message) {
|
|
293
|
+
const page = this.scope.current;
|
|
294
|
+
if (this.paused)
|
|
295
|
+
return this.reply(message.id, fail(PAUSED));
|
|
296
|
+
let found;
|
|
297
|
+
try {
|
|
298
|
+
found = await this.scope.whileOn(() => pageInfoOf(this.deps, this.secrets));
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
found = { ok: false, error: "Couldn't read the page" };
|
|
302
|
+
}
|
|
303
|
+
if (found === LEFT || page !== this.scope.current)
|
|
304
|
+
return;
|
|
305
|
+
this.reply(message.id, found);
|
|
306
|
+
}
|
|
286
307
|
async picked(message) {
|
|
287
308
|
const page = this.scope.current;
|
|
288
309
|
if (this.paused)
|
|
@@ -5,6 +5,7 @@ import type { BiDiConnector } from "../../transport/bidi-connection.js";
|
|
|
5
5
|
import type { Inspection } from "./types.js";
|
|
6
6
|
/**
|
|
7
7
|
* What the Assert panel needs for a picked element: its short label, text, value, attributes, the first
|
|
8
|
-
* matcher (today's Alt+click rule)
|
|
8
|
+
* matcher (today's Alt+click rule), up to three locators that match more than one element, with counts,
|
|
9
|
+
* and what decides the Checked, Enabled, Has class and Has CSS choices.
|
|
9
10
|
*/
|
|
10
11
|
export declare function inspectElement(page: Page, connector: BiDiConnector, context: string, target: ElementHandle, locator: LocatorSpec, candidates: LocatorCall[]): Promise<Inspection>;
|
|
@@ -5,25 +5,37 @@ import { expectationFor } from "../expectation.js";
|
|
|
5
5
|
import { TOOLBAR_SANDBOX } from "./protocol.js";
|
|
6
6
|
/** Most Count choices the Assert panel offers */
|
|
7
7
|
const MAX_COUNTS = 3;
|
|
8
|
+
/** The property Has CSS starts with */
|
|
9
|
+
const CSS_PROPERTY = "color";
|
|
8
10
|
/**
|
|
9
11
|
* 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
12
|
* 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.
|
|
13
|
+
* candidates use, so the common list and table roles are added for Count. Also whether it can be checked or
|
|
14
|
+
* disabled, its class attribute and the computed value of `CSS_PROPERTY`.
|
|
12
15
|
*/
|
|
13
|
-
const INSPECT_SOURCE = String.raw `(el) => {
|
|
16
|
+
const INSPECT_SOURCE = String.raw `(el, cssName) => {
|
|
14
17
|
${CANDIDATES_JS}
|
|
15
18
|
const TEXT_CAP = 4096;
|
|
19
|
+
const FORM_CONTROLS = ["input", "button", "select", "textarea", "fieldset", "optgroup", "option"];
|
|
16
20
|
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
21
|
const tag = el.tagName.toLowerCase();
|
|
18
22
|
const hasValue = typeof el.value === "string" && el.tagName !== "BUTTON" && el.tagName !== "LI";
|
|
19
23
|
const secret = tag === "input" && (el.getAttribute("type") || "").toLowerCase() === "password";
|
|
20
24
|
const classes = Array.from(el.classList).filter(Boolean);
|
|
25
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
26
|
+
const role = (el.getAttribute("role") || "").trim().toLowerCase();
|
|
27
|
+
const className = el.getAttribute("class");
|
|
21
28
|
return {
|
|
22
29
|
text: norm(String(el.textContent == null ? "" : el.textContent).slice(0, TEXT_CAP * 2)).slice(0, TEXT_CAP),
|
|
23
30
|
value: hasValue && !secret ? el.value : null,
|
|
24
31
|
attributes: Array.from(el.attributes).slice(0, 20).map((a) => ({ name: a.name, value: a.value })),
|
|
25
32
|
role: roleOf(el) || MORE_ROLES[tag] || null,
|
|
26
33
|
css: classes.length ? CSS.escape(tag) + classes.map((c) => "." + CSS.escape(c)).join("") : null,
|
|
34
|
+
checkable: (tag === "input" && (type === "checkbox" || type === "radio")) ||
|
|
35
|
+
role === "checkbox" || role === "radio" || role === "switch" || el.hasAttribute("aria-checked"),
|
|
36
|
+
disableable: FORM_CONTROLS.includes(tag) || el.hasAttribute("aria-disabled"),
|
|
37
|
+
className: className && className.trim() ? className : null,
|
|
38
|
+
cssValue: getComputedStyle(el).getPropertyValue(cssName),
|
|
27
39
|
};
|
|
28
40
|
}`;
|
|
29
41
|
/** A locator call in short, as the Assert panel shows it: `heading "Sign in"`, `label "Email"`, `li.item` */
|
|
@@ -48,10 +60,11 @@ function describeCall(call) {
|
|
|
48
60
|
const sameCall = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
49
61
|
/**
|
|
50
62
|
* What the Assert panel needs for a picked element: its short label, text, value, attributes, the first
|
|
51
|
-
* matcher (today's Alt+click rule)
|
|
63
|
+
* matcher (today's Alt+click rule), up to three locators that match more than one element, with counts,
|
|
64
|
+
* and what decides the Checked, Enabled, Has class and Has CSS choices.
|
|
52
65
|
*/
|
|
53
66
|
export async function inspectElement(page, connector, context, target, locator, candidates) {
|
|
54
|
-
const found = await callFunction(connector, context, INSPECT_SOURCE, [target], { sandbox: TOOLBAR_SANDBOX });
|
|
67
|
+
const found = await callFunction(connector, context, INSPECT_SOURCE, [target, CSS_PROPERTY], { sandbox: TOOLBAR_SANDBOX });
|
|
55
68
|
const value = found.value ?? undefined;
|
|
56
69
|
const first = expectationFor(found.text, value);
|
|
57
70
|
// The element's role without a name, its tag with its classes, then its other candidates
|
|
@@ -88,6 +101,10 @@ export async function inspectElement(page, connector, context, target, locator,
|
|
|
88
101
|
...("expected" in first && { expected: String(first.expected) }),
|
|
89
102
|
},
|
|
90
103
|
counts,
|
|
104
|
+
checkable: found.checkable,
|
|
105
|
+
disableable: found.disableable,
|
|
106
|
+
className: found.className,
|
|
107
|
+
css: { name: CSS_PROPERTY, value: found.cssValue },
|
|
91
108
|
},
|
|
92
109
|
counts: calls,
|
|
93
110
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { LocatorCall } from "../../steps/model.js";
|
|
1
|
+
import type { Expectation, LocatorCall, PageExpectation } from "../../steps/model.js";
|
|
2
2
|
/** The BiDi sandbox realm the toolbar script runs in */
|
|
3
3
|
export declare const TOOLBAR_SANDBOX = "samurai-toolbar";
|
|
4
4
|
/** `Symbol.for(TOOLBAR_KEY)` on the sandbox global holds the toolbar's hook: `update`, `reply`, `press`, `root` */
|
|
5
5
|
export declare const TOOLBAR_KEY = "samurai.toolbar";
|
|
6
|
-
type MatcherChoice = "
|
|
6
|
+
type MatcherChoice = Expectation["matcher"];
|
|
7
7
|
/** What the toolbar draws; secrets are names only */
|
|
8
8
|
export interface ToolbarState {
|
|
9
9
|
status: "recording" | "paused";
|
|
@@ -84,6 +84,8 @@ export type ToolbarMessage = {
|
|
|
84
84
|
matcher: MatcherChoice;
|
|
85
85
|
expected?: string;
|
|
86
86
|
attribute?: string;
|
|
87
|
+
/** The property Has CSS reads, as typed */
|
|
88
|
+
cssName?: string;
|
|
87
89
|
countIndex?: number;
|
|
88
90
|
not: boolean;
|
|
89
91
|
} | {
|
|
@@ -101,6 +103,17 @@ export type ToolbarMessage = {
|
|
|
101
103
|
kind: "env" | "secret";
|
|
102
104
|
name: string;
|
|
103
105
|
value: string;
|
|
106
|
+
}
|
|
107
|
+
/** The Page panel opened: answered with a `PageInfo` */
|
|
108
|
+
| {
|
|
109
|
+
action: "page-info";
|
|
110
|
+
id: number;
|
|
111
|
+
} | {
|
|
112
|
+
action: "assert-page";
|
|
113
|
+
id: number;
|
|
114
|
+
matcher: PageExpectation["matcher"];
|
|
115
|
+
expected: string;
|
|
116
|
+
not: boolean;
|
|
104
117
|
} | {
|
|
105
118
|
action: "wait";
|
|
106
119
|
kind: "idle";
|
|
@@ -131,11 +144,32 @@ export type PickInfo = {
|
|
|
131
144
|
label: string;
|
|
132
145
|
count: number;
|
|
133
146
|
}[];
|
|
147
|
+
/** A checkbox or radio input, or role checkbox/radio/switch, or `aria-checked`: Checked is offered */
|
|
148
|
+
checkable: boolean;
|
|
149
|
+
/** A form control or button, or `aria-disabled`: Enabled is offered */
|
|
150
|
+
disableable: boolean;
|
|
151
|
+
/** The class attribute as written; null when there is none (Has class is not offered) */
|
|
152
|
+
className: string | null;
|
|
153
|
+
/** Has CSS's first property and its computed value */
|
|
154
|
+
css: {
|
|
155
|
+
name: string;
|
|
156
|
+
value: string;
|
|
157
|
+
};
|
|
158
|
+
} | {
|
|
159
|
+
ok: false;
|
|
160
|
+
error: string;
|
|
161
|
+
};
|
|
162
|
+
/** The answer to `page-info`: the recorded page's URL, the URL the Page panel prefills, and its title */
|
|
163
|
+
export type PageInfo = {
|
|
164
|
+
ok: true;
|
|
165
|
+
url: string;
|
|
166
|
+
urlPrefill: string;
|
|
167
|
+
title: string;
|
|
134
168
|
} | {
|
|
135
169
|
ok: false;
|
|
136
170
|
error: string;
|
|
137
171
|
};
|
|
138
|
-
/** The answer to `insert`, `add-insertable` and `assert` */
|
|
172
|
+
/** The answer to `insert`, `add-insertable`, `assert` and `assert-page` */
|
|
139
173
|
export type ActionReply = {
|
|
140
174
|
ok: true;
|
|
141
175
|
} | {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type Page from "../../browser/page.js";
|
|
2
|
+
import type { BiDiConnector } from "../../transport/bidi-connection.js";
|
|
3
|
+
import { type ToolbarState } from "./protocol.js";
|
|
4
|
+
/** The toolbar's hook, as an expression in its sandbox */
|
|
5
|
+
export declare const HOOK = "globalThis[Symbol.for(\"samurai.toolbar\")]";
|
|
6
|
+
export declare const pause: (ms: number) => Promise<unknown>;
|
|
7
|
+
/** Polls `probe` until it gives something other than undefined or false */
|
|
8
|
+
export declare function waitFor<T>(probe: () => T | undefined | Promise<T | undefined>, what: string, ms?: number): Promise<T>;
|
|
9
|
+
/** Drives the real toolbar in the page `target` gives (read on every call: the page is opened in `before`) */
|
|
10
|
+
export declare function toolbarDriver(target: () => {
|
|
11
|
+
page: Page;
|
|
12
|
+
connector: BiDiConnector;
|
|
13
|
+
}): {
|
|
14
|
+
inSandbox: <T>(fn: string, ...args: unknown[]) => Promise<T>;
|
|
15
|
+
press: (action: string, detail?: object) => Promise<unknown>;
|
|
16
|
+
/** Runs the picker's click on the first element matching `selector` */
|
|
17
|
+
pickElement: (selector: string) => Promise<unknown>;
|
|
18
|
+
viewState: () => Promise<ToolbarState | null>;
|
|
19
|
+
/** Sets a toolbar form input as typing into it would */
|
|
20
|
+
typeInto: (field: string, value: string) => Promise<unknown>;
|
|
21
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { callFunction } from "../../script/call-function.js";
|
|
2
|
+
import { TOOLBAR_KEY, TOOLBAR_SANDBOX } from "./protocol.js";
|
|
3
|
+
/** The toolbar's hook, as an expression in its sandbox */
|
|
4
|
+
export const HOOK = `globalThis[Symbol.for("${TOOLBAR_KEY}")]`;
|
|
5
|
+
export const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
/** Polls `probe` until it gives something other than undefined or false */
|
|
7
|
+
export async function waitFor(probe, what, ms = 8000) {
|
|
8
|
+
const end = Date.now() + ms;
|
|
9
|
+
for (;;) {
|
|
10
|
+
const value = await probe();
|
|
11
|
+
if (value !== undefined && value !== false)
|
|
12
|
+
return value;
|
|
13
|
+
if (Date.now() > end)
|
|
14
|
+
throw new Error(`Timed out waiting for ${what}`);
|
|
15
|
+
await pause(50);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Drives the real toolbar in the page `target` gives (read on every call: the page is opened in `before`) */
|
|
19
|
+
export function toolbarDriver(target) {
|
|
20
|
+
const inSandbox = (fn, ...args) => {
|
|
21
|
+
const { page, connector } = target();
|
|
22
|
+
return callFunction(connector, page.contextId, fn, args, {
|
|
23
|
+
sandbox: TOOLBAR_SANDBOX,
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
inSandbox,
|
|
28
|
+
press: (action, detail) => inSandbox(`(action, detail) => { ${HOOK}.press(action, detail); }`, action, detail),
|
|
29
|
+
/** Runs the picker's click on the first element matching `selector` */
|
|
30
|
+
pickElement: (selector) => inSandbox(`(selector) => { ${HOOK}.press("pick", { target: document.querySelector(selector) }); }`, selector),
|
|
31
|
+
viewState: () => inSandbox(`() => ${HOOK} ? ${HOOK}.state() : null`),
|
|
32
|
+
/** Sets a toolbar form input as typing into it would */
|
|
33
|
+
typeInto: (field, value) => inSandbox(`(field, value) => {
|
|
34
|
+
const el = ${HOOK}.root.querySelector('[data-field="' + field + '"]');
|
|
35
|
+
if (!el) throw new Error("no field " + field);
|
|
36
|
+
el.value = value;
|
|
37
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
38
|
+
}`, field, value),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -10,10 +10,19 @@ export const FIELDS_JS = String.raw ` const press = (action, detail) => {
|
|
|
10
10
|
if (!field) return;
|
|
11
11
|
const value = t.value;
|
|
12
12
|
if (field === "expected" && ui.assert) ui.assert.expected = value;
|
|
13
|
+
else if (field === "pageExpected" && ui.page) ui.page.expected = value;
|
|
13
14
|
else if (field === "attribute" && ui.assert) {
|
|
14
15
|
ui.assert.attribute = value;
|
|
15
16
|
ui.assert.expected = prefill(ui.assert, "toHaveAttribute");
|
|
16
17
|
render();
|
|
18
|
+
} else if (field === "cssName" && ui.assert) {
|
|
19
|
+
if (ui.assert.cssName === value) return;
|
|
20
|
+
ui.assert.cssName = value;
|
|
21
|
+
// The computed CSS value skips the secret filter on purpose: a computed style can't carry an
|
|
22
|
+
// input's typed value, and it is already visible on the page
|
|
23
|
+
ui.assert.cssValue = cssValueOf(ui.assert.target, value);
|
|
24
|
+
ui.assert.expected = prefill(ui.assert, "toHaveCSS");
|
|
25
|
+
render();
|
|
17
26
|
} else if (field === "countIndex" && ui.assert) {
|
|
18
27
|
ui.assert.countIndex = Number(value);
|
|
19
28
|
ui.assert.expected = prefill(ui.assert, "toHaveCount");
|
|
@@ -27,6 +27,7 @@ export const HANDLERS_JS = String.raw ` // Every action is what a click on its
|
|
|
27
27
|
ui.picking = !wasOn;
|
|
28
28
|
render();
|
|
29
29
|
},
|
|
30
|
+
page: () => openTool("page"),
|
|
30
31
|
insert: () => openTool("insert"),
|
|
31
32
|
wait: () => openTool("wait"),
|
|
32
33
|
screenshot: () => openTool("screenshot"),
|
|
@@ -47,14 +48,37 @@ export const HANDLERS_JS = String.raw ` // Every action is what a click on its
|
|
|
47
48
|
const a = ui.panel === "assert" && ui.assert;
|
|
48
49
|
if (!a || ui.busy || !a.info || !a.info.ok) return;
|
|
49
50
|
const message = { action: "assert", id: a.id, target: a.target, candidates: a.candidates, matcher: a.matcher, not: a.not };
|
|
50
|
-
if (a.matcher
|
|
51
|
+
if (!VALUELESS.includes(a.matcher)) message.expected = a.expected;
|
|
51
52
|
if (a.matcher === "toHaveAttribute") message.attribute = a.attribute;
|
|
53
|
+
if (a.matcher === "toHaveCSS") message.cssName = a.cssName;
|
|
52
54
|
if (a.matcher === "toHaveCount") message.countIndex = a.countIndex;
|
|
53
55
|
ui.busy = true;
|
|
54
56
|
a.error = null;
|
|
55
57
|
post(message);
|
|
56
58
|
render();
|
|
57
59
|
},
|
|
60
|
+
"page-matcher": (detail) => {
|
|
61
|
+
const p = ui.panel === "page" && ui.page;
|
|
62
|
+
if (!p || !p.info || !p.info.ok || !PAGE_MATCHERS.some(([matcher]) => matcher === detail.matcher)) return;
|
|
63
|
+
p.matcher = detail.matcher;
|
|
64
|
+
p.expected = detail.matcher === "toHaveTitle" ? p.info.title : p.info.urlPrefill;
|
|
65
|
+
p.error = null;
|
|
66
|
+
render();
|
|
67
|
+
},
|
|
68
|
+
"page-not": () => {
|
|
69
|
+
if (ui.panel !== "page" || !ui.page || !ui.page.info) return;
|
|
70
|
+
ui.page.not = !ui.page.not;
|
|
71
|
+
render();
|
|
72
|
+
},
|
|
73
|
+
"page-add": () => {
|
|
74
|
+
const p = ui.panel === "page" && ui.page;
|
|
75
|
+
if (!p || ui.busy || !p.info || !p.info.ok) return;
|
|
76
|
+
ui.busy = true;
|
|
77
|
+
ui.pending = p.id;
|
|
78
|
+
p.error = null;
|
|
79
|
+
post({ action: "assert-page", id: p.id, matcher: p.matcher, expected: p.expected, not: p.not });
|
|
80
|
+
render();
|
|
81
|
+
},
|
|
58
82
|
"insert-item": (detail) => {
|
|
59
83
|
if (ui.panel !== "insert" || ui.busy || !ui.insert.field) return;
|
|
60
84
|
if (detail.kind === "secret") {
|
|
@@ -3,6 +3,7 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
|
|
|
3
3
|
const closePanel = () => {
|
|
4
4
|
ui.panel = null;
|
|
5
5
|
ui.assert = null;
|
|
6
|
+
ui.page = null;
|
|
6
7
|
ui.busy = false;
|
|
7
8
|
ui.closeOnUpdate = false;
|
|
8
9
|
ui.pending = 0;
|
|
@@ -28,6 +29,11 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
|
|
|
28
29
|
ui.field = field;
|
|
29
30
|
ui.insert = { field, filter: "", error: null };
|
|
30
31
|
if (field) ui.focus = "filter";
|
|
32
|
+
} else if (tool === "page") {
|
|
33
|
+
const id = nextId++;
|
|
34
|
+
ui.page = { id, info: null, matcher: "toHaveURL", expected: "", not: false, error: null };
|
|
35
|
+
ui.pending = id;
|
|
36
|
+
post({ action: "page-info", id });
|
|
31
37
|
} else if (tool === "wait") {
|
|
32
38
|
ui.wait = { kind: "idle", seconds: "2" };
|
|
33
39
|
} else if (tool === "screenshot") {
|
|
@@ -45,7 +51,8 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
|
|
|
45
51
|
const candidates = candidatesOf(target);
|
|
46
52
|
const r = target.getBoundingClientRect();
|
|
47
53
|
ui.assert = {
|
|
48
|
-
id, target, candidates, info: null, matcher: "toBeVisible", expected: "", attribute: null, countIndex: 0,
|
|
54
|
+
id, target, candidates, info: null, matcher: "toBeVisible", expected: "", attribute: null, countIndex: 0,
|
|
55
|
+
cssName: "", cssValue: "", not: false, error: null,
|
|
49
56
|
rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
|
|
50
57
|
};
|
|
51
58
|
ui.panel = "assert";
|
|
@@ -37,12 +37,22 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
|
|
|
37
37
|
a.matcher = result.first.matcher;
|
|
38
38
|
a.attribute = result.attributes[0] ? result.attributes[0].name : null;
|
|
39
39
|
a.countIndex = 0;
|
|
40
|
+
a.cssName = result.css.name;
|
|
41
|
+
a.cssValue = result.css.value;
|
|
40
42
|
a.expected = prefill(a, a.matcher);
|
|
41
43
|
}
|
|
44
|
+
} else if (ui.panel === "page" && ui.page && !ui.page.info) {
|
|
45
|
+
ui.page.info = result;
|
|
46
|
+
if (ok) {
|
|
47
|
+
ui.page.expected = result.urlPrefill;
|
|
48
|
+
ui.focus = "pageExpected";
|
|
49
|
+
}
|
|
42
50
|
} else if (ok) {
|
|
43
51
|
closePanel();
|
|
44
52
|
} else if (ui.panel === "assert" && ui.assert) {
|
|
45
53
|
ui.assert.error = result.error;
|
|
54
|
+
} else if (ui.panel === "page" && ui.page) {
|
|
55
|
+
ui.page.error = result.error;
|
|
46
56
|
} else if (ui.panel === "insert") {
|
|
47
57
|
ui.insert.error = result.error;
|
|
48
58
|
} else if (ui.panel === "new") {
|
|
@@ -20,7 +20,12 @@ export const SETUP_JS = String.raw ` if (window !== window.top) return;
|
|
|
20
20
|
const MATCHERS = [
|
|
21
21
|
["toBeVisible", "Visible"], ["toHaveText", "Has text"], ["toContainText", "Contains text"],
|
|
22
22
|
["toHaveValue", "Has value"], ["toHaveAttribute", "Has attribute"], ["toHaveCount", "Count"],
|
|
23
|
+
["toBeHidden", "Hidden"], ["toBeEnabled", "Enabled"], ["toBeChecked", "Checked"],
|
|
24
|
+
["toHaveClass", "Has class"], ["toHaveCSS", "Has CSS"],
|
|
23
25
|
];
|
|
26
|
+
const PAGE_MATCHERS = [["toHaveURL", "URL"], ["toHaveTitle", "Title"]];
|
|
27
|
+
// Matchers that take no expected value
|
|
28
|
+
const VALUELESS = ["toBeVisible", "toBeHidden", "toBeEnabled", "toBeChecked"];
|
|
24
29
|
|
|
25
30
|
let channel = send;
|
|
26
31
|
let state = Object.assign({}, DEFAULTS);
|
|
@@ -30,7 +35,7 @@ export const SETUP_JS = String.raw ` if (window !== window.top) return;
|
|
|
30
35
|
// Local UI state; everything about steps lives in the recorder
|
|
31
36
|
const ui = {
|
|
32
37
|
panel: null, picking: false, busy: false, closeOnUpdate: false, pending: 0, focus: null,
|
|
33
|
-
field: null, assert: null, insert: null, form: null,
|
|
38
|
+
field: null, assert: null, page: null, insert: null, form: null,
|
|
34
39
|
wait: { kind: "idle", seconds: "2" }, shot: { name: "", fullPage: false },
|
|
35
40
|
drag: null, dropped: null, toastId: null,
|
|
36
41
|
};
|