@itmetsam/samurai-framework 0.18.0 → 0.20.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.
Files changed (65) hide show
  1. package/dist/assert/expect.d.ts +5 -0
  2. package/dist/assert/expect.js +8 -3
  3. package/dist/assert/locator-assertions.d.ts +16 -1
  4. package/dist/assert/locator-assertions.js +34 -7
  5. package/dist/assert/page-assertions.d.ts +25 -0
  6. package/dist/assert/page-assertions.js +67 -0
  7. package/dist/assert/text-match.d.ts +2 -0
  8. package/dist/assert/text-match.js +7 -0
  9. package/dist/browser/page-navigation.d.ts +4 -0
  10. package/dist/browser/page-navigation.js +15 -5
  11. package/dist/browser/page.d.ts +2 -0
  12. package/dist/browser/page.js +4 -0
  13. package/dist/capabilities.d.ts +6 -0
  14. package/dist/capabilities.js +6 -0
  15. package/dist/flows/run/index.d.ts +1 -0
  16. package/dist/flows/run/index.js +1 -0
  17. package/dist/flows/run/node-folder.d.ts +5 -0
  18. package/dist/flows/run/node-folder.js +8 -0
  19. package/dist/flows/run/project.js +2 -2
  20. package/dist/locator/element-reads.d.ts +4 -0
  21. package/dist/locator/element-reads.js +11 -0
  22. package/dist/locator/locator.d.ts +8 -0
  23. package/dist/locator/locator.js +25 -0
  24. package/dist/recorder/expectation.d.ts +5 -0
  25. package/dist/recorder/expectation.js +18 -0
  26. package/dist/recorder/normalise.d.ts +7 -1
  27. package/dist/recorder/normalise.js +6 -0
  28. package/dist/recorder/options.d.ts +1 -1
  29. package/dist/recorder/recorder.js +3 -0
  30. package/dist/recorder/toolbar/action-guard.d.ts +2 -2
  31. package/dist/recorder/toolbar/action-guard.js +2 -1
  32. package/dist/recorder/toolbar/actions/assert-page.d.ts +6 -0
  33. package/dist/recorder/toolbar/actions/assert-page.js +33 -0
  34. package/dist/recorder/toolbar/actions/assert.js +17 -1
  35. package/dist/recorder/toolbar/actions/pick.d.ts +3 -1
  36. package/dist/recorder/toolbar/actions/pick.js +11 -3
  37. package/dist/recorder/toolbar/controller-fixture.d.ts +1 -0
  38. package/dist/recorder/toolbar/controller-fixture.js +6 -1
  39. package/dist/recorder/toolbar/controller.d.ts +4 -2
  40. package/dist/recorder/toolbar/controller.js +23 -2
  41. package/dist/recorder/toolbar/inspect.d.ts +2 -1
  42. package/dist/recorder/toolbar/inspect.js +21 -4
  43. package/dist/recorder/toolbar/protocol.d.ts +37 -3
  44. package/dist/recorder/toolbar/recorder-toolbar-fixture.d.ts +21 -0
  45. package/dist/recorder/toolbar/recorder-toolbar-fixture.js +40 -0
  46. package/dist/recorder/toolbar/source/fields.js +9 -0
  47. package/dist/recorder/toolbar/source/handlers.js +25 -1
  48. package/dist/recorder/toolbar/source/picking.js +8 -1
  49. package/dist/recorder/toolbar/source/recorder-side.js +10 -0
  50. package/dist/recorder/toolbar/source/setup.js +6 -1
  51. package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
  52. package/dist/recorder/toolbar/source/views-panels.js +39 -4
  53. package/dist/recorder/toolbar/source/views-rail.js +1 -0
  54. package/dist/recorder/toolbar/source.d.ts +6 -5
  55. package/dist/recorder/toolbar/source.js +6 -5
  56. package/dist/recorder/toolbar/types.d.ts +9 -2
  57. package/dist/recorder/toolbar-bridge.d.ts +2 -0
  58. package/dist/recorder/toolbar-bridge.js +6 -1
  59. package/dist/steps/emit.js +8 -1
  60. package/dist/steps/index.d.ts +1 -1
  61. package/dist/steps/model.d.ts +19 -1
  62. package/dist/steps/parse/expectation.d.ts +2 -1
  63. package/dist/steps/parse/expectation.js +25 -1
  64. package/dist/steps/parse/step.js +9 -2
  65. package/package.json +1 -1
@@ -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
- expectation = { matcher: "toBeVisible" };
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 attribute equal to a secret) is left out,
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 attribute equal to a secret) is left out,
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 values = [...secrets.values()].filter((value) => value !== undefined && value !== "");
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) }),
@@ -37,6 +37,7 @@ export declare function setup(options?: Partial<ControllerOptions>, overrides?:
37
37
  calls: string[];
38
38
  page: {
39
39
  url: string;
40
+ title: string;
40
41
  };
41
42
  last: () => ToolbarState;
42
43
  noSecretLeaks: () => void;
@@ -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`, `insert` or
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`, `insert` or
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) and up to three locators that match more than one element, with counts.
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) and up to three locators that match more than one element, with counts.
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 = "toBeVisible" | "toHaveText" | "toContainText" | "toHaveValue" | "toHaveAttribute" | "toHaveCount";
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 !== "toBeVisible") message.expected = a.expected;
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, not: false, error: null,
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
  };
@@ -1,2 +1,2 @@
1
- /** Views: the Assert, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
1
+ /** Views: the Assert, Page, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
2
2
  export declare const VIEWS_PANELS_JS: string;