@itmetsam/samurai-framework 0.2.2 → 0.2.3

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.
@@ -163,6 +163,14 @@ export default class Locator {
163
163
  isEditable(): Promise<boolean>;
164
164
  /** Wait for certain time (ms). Default: 300ms */
165
165
  wait(duration?: number): Promise<void>;
166
+ /**
167
+ * Waits until the element is attached, visible and enabled, focuses it and presses `key` with real keyboard input.
168
+ * Named keys are `Enter`, `Escape`, `Tab`, `Backspace`, `Delete`, `Space`, `Home`, `End`, `PageUp`, `PageDown` and the arrows;
169
+ * any other key must be a single character.
170
+ * @example
171
+ * await page.getByLabel("New todo").press("Enter");
172
+ */
173
+ press(key: string, options?: ActionOptions): Promise<void>;
166
174
  /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
167
175
  fill(value: string, options?: ActionOptions): Promise<void>;
168
176
  }
@@ -42,7 +42,25 @@ const FILL_CHECKS = [
42
42
  "editable",
43
43
  "hit target",
44
44
  ];
45
+ const PRESS_CHECKS = ["attached", "visible", "enabled"];
45
46
  const ATTACHED_ONLY = ["attached"];
47
+ /** WebDriver code points of the named keys `press()` accepts; any other single character is typed as is */
48
+ const NAMED_KEYS = {
49
+ Backspace: "\uE003",
50
+ Tab: "\uE004",
51
+ Enter: "\uE007",
52
+ Escape: "\uE00C",
53
+ Space: " ",
54
+ ArrowLeft: "\uE012",
55
+ ArrowUp: "\uE013",
56
+ ArrowRight: "\uE014",
57
+ ArrowDown: "\uE015",
58
+ Delete: "\uE017",
59
+ Home: "\uE011",
60
+ End: "\uE010",
61
+ PageUp: "\uE00E",
62
+ PageDown: "\uE00F",
63
+ };
46
64
  /** Unicode code point WebDriver uses for the Backspace key */
47
65
  const BACKSPACE = "";
48
66
  export default class Locator {
@@ -556,6 +574,34 @@ export default class Locator {
556
574
  ],
557
575
  });
558
576
  }
577
+ /**
578
+ * Waits until the element is attached, visible and enabled, focuses it and presses `key` with real keyboard input.
579
+ * Named keys are `Enter`, `Escape`, `Tab`, `Backspace`, `Delete`, `Space`, `Home`, `End`, `PageUp`, `PageDown` and the arrows;
580
+ * any other key must be a single character.
581
+ * @example
582
+ * await page.getByLabel("New todo").press("Enter");
583
+ */
584
+ async press(key, options) {
585
+ const code = NAMED_KEYS[key] ?? (Array.from(key).length === 1 ? key : "");
586
+ if (code === "") {
587
+ throw new Error(`press(): unknown key "${key}". Use one of ${Object.keys(NAMED_KEYS).join(", ")} or a single character`);
588
+ }
589
+ await this.waitForActionable("press", PRESS_CHECKS, { scroll: true, hitTest: false }, options);
590
+ await this.callOnElement(undefined, `el.focus();`);
591
+ await this.biDiConnector.send("input.performActions", {
592
+ context: this.contextId,
593
+ actions: [
594
+ {
595
+ type: "key",
596
+ id: "samurai-keyboard",
597
+ actions: [
598
+ { type: "keyDown", value: code },
599
+ { type: "keyUp", value: code },
600
+ ],
601
+ },
602
+ ],
603
+ });
604
+ }
559
605
  /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
560
606
  async fill(value, options) {
561
607
  const state = await this.waitForActionable("fill", FILL_CHECKS, { scroll: true, hitTest: true }, options);
@@ -6,6 +6,8 @@
6
6
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
7
7
  * - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
8
8
  * values never leave the page: `secret` is set and `value` is left out.
9
+ * - `{ type: "press", target, candidates, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
10
+ * click or typing, which are reported as such).
9
11
  * - `{ type: "assert", target, candidates, text, value }`: an Alt+click, which does not reach the page.
10
12
  * While Alt is held, the element under the pointer is outlined.
11
13
  *
@@ -8,6 +8,8 @@ const OFF_KEY = "samurai.recorder.off";
8
8
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
9
9
  * - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
10
10
  * values never leave the page: `secret` is set and `value` is left out.
11
+ * - `{ type: "press", target, candidates, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
12
+ * click or typing, which are reported as such).
11
13
  * - `{ type: "assert", target, candidates, text, value }`: an Alt+click, which does not reach the page.
12
14
  * While Alt is held, the element under the pointer is outlined.
13
15
  *
@@ -184,6 +186,18 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
184
186
  });
185
187
  }, true);
186
188
 
189
+ // Enter, Tab and Escape change what the page does next (an Enter adds a todo and clears the field), so they are steps
190
+ const RECORDED_KEYS = ["Enter", "Tab", "Escape"];
191
+ // Enter on these is a click, which is recorded as one
192
+ const ACTIVATES = "a[href],button,summary,select,input[type=button],input[type=submit],input[type=reset],input[type=checkbox],input[type=radio]";
193
+ document.addEventListener("keydown", (event) => {
194
+ if (off() || !event.isTrusted || event.isComposing || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return;
195
+ if (RECORDED_KEYS.indexOf(event.key) < 0 || !(event.target instanceof Element)) return;
196
+ const el = event.target;
197
+ if (event.key === "Enter" && (el.closest(ACTIVATES) || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
198
+ report({ type: "press", target: el, candidates: candidatesOf(el), pressed: event.key });
199
+ }, true);
200
+
187
201
  document.addEventListener("mousemove", (event) => {
188
202
  if (off() || !event.altKey || !(event.target instanceof Element) || event.target === outlined) return;
189
203
  unhighlight();
@@ -22,6 +22,11 @@ export type Observation = {
22
22
  value?: string;
23
23
  secret?: boolean;
24
24
  secretName?: string;
25
+ } | {
26
+ type: "press";
27
+ key: string;
28
+ locator: LocatorSpec;
29
+ pressed: string;
25
30
  } | {
26
31
  type: "assert";
27
32
  key: string;
@@ -60,6 +60,18 @@ export class Normaliser {
60
60
  this.lastAction = at;
61
61
  return [this.insert({ kind: "click", locator: observation.locator })];
62
62
  }
63
+ if (observation.type === "press") {
64
+ // The page may react by clearing the field: what is typed next is a new fill, not a rewrite of this one
65
+ this.lastFill = undefined;
66
+ this.lastAction = at;
67
+ return [
68
+ this.insert({
69
+ kind: "press",
70
+ locator: observation.locator,
71
+ key: observation.pressed,
72
+ }),
73
+ ];
74
+ }
63
75
  if (observation.type === "assert") {
64
76
  this.lastFill = undefined;
65
77
  return [
@@ -99,24 +99,26 @@ export class Recorder {
99
99
  const key = message.target.sharedId;
100
100
  const observation = message.type === "click"
101
101
  ? { type: "click", key, locator, textEntry: message.textEntry === true }
102
- : message.type === "assert"
103
- ? {
104
- type: "assert",
105
- key,
106
- locator,
107
- text: message.text ?? "",
108
- ...(message.value !== undefined && { value: message.value }),
109
- }
110
- : {
111
- type: "input",
112
- key,
113
- locator,
114
- ...(message.value !== undefined && { value: message.value }),
115
- ...(message.secret && { secret: true }),
116
- ...(message.secretName !== undefined && {
117
- secretName: message.secretName,
118
- }),
119
- };
102
+ : message.type === "press"
103
+ ? { type: "press", key, locator, pressed: message.pressed ?? "" }
104
+ : message.type === "assert"
105
+ ? {
106
+ type: "assert",
107
+ key,
108
+ locator,
109
+ text: message.text ?? "",
110
+ ...(message.value !== undefined && { value: message.value }),
111
+ }
112
+ : {
113
+ type: "input",
114
+ key,
115
+ locator,
116
+ ...(message.value !== undefined && { value: message.value }),
117
+ ...(message.secret && { secret: true }),
118
+ ...(message.secretName !== undefined && {
119
+ secretName: message.secretName,
120
+ }),
121
+ };
120
122
  this.emit(this.normaliser.observe(observation, Date.now()));
121
123
  }
122
124
  async release() {
@@ -63,6 +63,8 @@ export function stepToSource(step) {
63
63
  return `await ${locatorToSource(step.locator)}.click();`;
64
64
  case "fill":
65
65
  return `await ${locatorToSource(step.locator)}.fill(${valueToSource(step.value)});`;
66
+ case "press":
67
+ return `await ${locatorToSource(step.locator)}.press(${str(step.key)});`;
66
68
  case "expect":
67
69
  return `await expect(${locatorToSource(step.locator)})${step.not ? ".not" : ""}.${expectationToSource(step.expectation)};`;
68
70
  case "waitForNetworkIdle":
@@ -75,6 +75,12 @@ export type Step = {
75
75
  locator: LocatorSpec;
76
76
  not: boolean;
77
77
  expectation: Expectation;
78
+ }
79
+ /** A key pressed while the element has focus, e.g. `Enter` to submit a field. Named as Playwright names keys */
80
+ | {
81
+ kind: "press";
82
+ locator: LocatorSpec;
83
+ key: string;
78
84
  } | {
79
85
  kind: "waitForNetworkIdle";
80
86
  }
@@ -214,6 +214,10 @@ function readStep(statement) {
214
214
  return undefined;
215
215
  if (name.text === "click" && args.length === 0)
216
216
  return { kind: "click", locator };
217
+ if (name.text === "press" && args.length === 1) {
218
+ const key = stringOf(args[0]);
219
+ return key === undefined ? undefined : { kind: "press", locator, key };
220
+ }
217
221
  if (name.text === "fill" && args.length === 1) {
218
222
  const value = readValue(args[0]);
219
223
  return value ? { kind: "fill", locator, value } : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {