@itmetsam/samurai-framework 0.21.5 → 0.22.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 (141) hide show
  1. package/dist/api.d.ts +6 -1
  2. package/dist/api.js +1 -0
  3. package/dist/browser/browser-context.d.ts +18 -6
  4. package/dist/browser/browser-context.js +25 -7
  5. package/dist/browser/browser.d.ts +4 -4
  6. package/dist/browser/browser.js +11 -8
  7. package/dist/browser/chrome-launch.js +9 -1
  8. package/dist/browser/context-cookie-store.d.ts +17 -0
  9. package/dist/browser/context-cookie-store.js +90 -0
  10. package/dist/browser/index.d.ts +3 -1
  11. package/dist/browser/index.js +1 -1
  12. package/dist/browser/keyboard.d.ts +35 -0
  13. package/dist/browser/keyboard.js +83 -0
  14. package/dist/browser/mouse.d.ts +42 -0
  15. package/dist/browser/mouse.js +66 -0
  16. package/dist/browser/page-navigation.d.ts +0 -2
  17. package/dist/browser/page-navigation.js +1 -6
  18. package/dist/browser/page.d.ts +18 -8
  19. package/dist/browser/page.js +18 -9
  20. package/dist/browser/storage-state.d.ts +0 -2
  21. package/dist/browser/storage-state.js +0 -20
  22. package/dist/browser/use-applier.d.ts +16 -5
  23. package/dist/browser/use-applier.js +44 -17
  24. package/dist/browser/use-commands.d.ts +36 -0
  25. package/dist/browser/use-commands.js +120 -0
  26. package/dist/cli/args.d.ts +8 -4
  27. package/dist/cli/args.js +8 -5
  28. package/dist/cli/console-reporter.js +5 -1
  29. package/dist/cli/main.js +1 -0
  30. package/dist/cli/usage.d.ts +1 -1
  31. package/dist/cli/usage.js +4 -1
  32. package/dist/config/base-url.d.ts +4 -0
  33. package/dist/config/base-url.js +6 -0
  34. package/dist/config/config.js +6 -0
  35. package/dist/config/fully-parallel.d.ts +3 -0
  36. package/dist/config/fully-parallel.js +6 -0
  37. package/dist/config/group-use.d.ts +8 -0
  38. package/dist/config/group-use.js +41 -0
  39. package/dist/config/resolved-config.d.ts +2 -0
  40. package/dist/config/resolved-config.js +3 -0
  41. package/dist/config/storage-state-file.d.ts +3 -0
  42. package/dist/config/storage-state-file.js +20 -0
  43. package/dist/config/use-options.d.ts +3 -1
  44. package/dist/config/use-options.js +70 -14
  45. package/dist/config/web-server-options.d.ts +7 -0
  46. package/dist/config/web-server-options.js +111 -0
  47. package/dist/locator/check-actions.js +1 -1
  48. package/dist/locator/drag-actions.d.ts +17 -0
  49. package/dist/locator/drag-actions.js +80 -0
  50. package/dist/locator/element-actions.d.ts +10 -4
  51. package/dist/locator/element-actions.js +23 -16
  52. package/dist/locator/element-state.d.ts +5 -0
  53. package/dist/locator/element-state.js +2 -1
  54. package/dist/locator/event-actions.d.ts +10 -0
  55. package/dist/locator/event-actions.js +81 -0
  56. package/dist/locator/input-actions.d.ts +2 -11
  57. package/dist/locator/input-actions.js +3 -56
  58. package/dist/locator/key-actions.d.ts +11 -0
  59. package/dist/locator/key-actions.js +52 -0
  60. package/dist/locator/key-map.d.ts +16 -0
  61. package/dist/locator/key-map.js +76 -0
  62. package/dist/locator/locator.d.ts +35 -5
  63. package/dist/locator/locator.js +70 -16
  64. package/dist/locator/page-scripts.d.ts +2 -0
  65. package/dist/locator/page-scripts.js +7 -0
  66. package/dist/locator/pointer-actions.d.ts +44 -0
  67. package/dist/locator/pointer-actions.js +117 -0
  68. package/dist/locator/pointer-element-actions.d.ts +21 -0
  69. package/dist/locator/pointer-element-actions.js +42 -0
  70. package/dist/request/api-request-context.d.ts +65 -0
  71. package/dist/request/api-request-context.js +198 -0
  72. package/dist/request/api-response.d.ts +34 -0
  73. package/dist/request/api-response.js +57 -0
  74. package/dist/request/cookie-jar.d.ts +48 -0
  75. package/dist/request/cookie-jar.js +164 -0
  76. package/dist/request/http-transport.d.ts +31 -0
  77. package/dist/request/http-transport.js +113 -0
  78. package/dist/request/redirect.d.ts +10 -0
  79. package/dist/request/redirect.js +21 -0
  80. package/dist/request/request-body.d.ts +27 -0
  81. package/dist/request/request-body.js +71 -0
  82. package/dist/request/request.d.ts +16 -0
  83. package/dist/request/request.js +20 -0
  84. package/dist/runner/all-hooks.d.ts +7 -1
  85. package/dist/runner/all-hooks.js +17 -1
  86. package/dist/runner/fixture-defs.js +1 -0
  87. package/dist/runner/fixtures.d.ts +4 -1
  88. package/dist/runner/fixtures.js +3 -2
  89. package/dist/runner/global-hooks.d.ts +13 -0
  90. package/dist/runner/global-hooks.js +66 -0
  91. package/dist/runner/options.d.ts +13 -2
  92. package/dist/runner/output-tail.d.ts +14 -0
  93. package/dist/runner/output-tail.js +30 -0
  94. package/dist/runner/pool-schedule.d.ts +22 -0
  95. package/dist/runner/pool-schedule.js +55 -0
  96. package/dist/runner/pool-units.d.ts +34 -0
  97. package/dist/runner/pool-units.js +85 -0
  98. package/dist/runner/pooled-run.d.ts +7 -5
  99. package/dist/runner/pooled-run.js +19 -9
  100. package/dist/runner/registry.js +6 -2
  101. package/dist/runner/reporter.d.ts +10 -0
  102. package/dist/runner/reporter.js +12 -2
  103. package/dist/runner/request-fixture.d.ts +14 -0
  104. package/dist/runner/request-fixture.js +25 -0
  105. package/dist/runner/run-hooks.d.ts +16 -0
  106. package/dist/runner/run-hooks.js +52 -0
  107. package/dist/runner/run-test.js +7 -2
  108. package/dist/runner/run.d.ts +1 -0
  109. package/dist/runner/run.js +1 -0
  110. package/dist/runner/select.d.ts +15 -4
  111. package/dist/runner/select.js +38 -12
  112. package/dist/runner/serial-run.d.ts +23 -0
  113. package/dist/runner/serial-run.js +88 -0
  114. package/dist/runner/serial.d.ts +34 -0
  115. package/dist/runner/serial.js +63 -0
  116. package/dist/runner/shard.d.ts +22 -0
  117. package/dist/runner/shard.js +73 -0
  118. package/dist/runner/suite.d.ts +3 -3
  119. package/dist/runner/suite.js +4 -3
  120. package/dist/runner/test-function.d.ts +13 -1
  121. package/dist/runner/test-info.js +2 -1
  122. package/dist/runner/test-runner.d.ts +1 -0
  123. package/dist/runner/test-runner.js +62 -18
  124. package/dist/runner/use-options.d.ts +4 -4
  125. package/dist/runner/use-options.js +10 -3
  126. package/dist/runner/wait-for-url.d.ts +20 -0
  127. package/dist/runner/wait-for-url.js +65 -0
  128. package/dist/runner/web-server.d.ts +16 -0
  129. package/dist/runner/web-server.js +165 -0
  130. package/dist/runner/worker-pool.d.ts +13 -5
  131. package/dist/runner/worker-pool.js +85 -51
  132. package/dist/runner/worker-protocol.d.ts +8 -0
  133. package/dist/runner/worker-session.js +3 -1
  134. package/dist/runner/worker.d.ts +2 -0
  135. package/dist/runner/worker.js +7 -1
  136. package/dist/types/config.d.ts +83 -0
  137. package/dist/types/test.d.ts +18 -0
  138. package/dist/types/use.d.ts +45 -7
  139. package/package.json +1 -1
  140. package/dist/browser/emulation.d.ts +0 -61
  141. package/dist/browser/emulation.js +0 -180
@@ -2,10 +2,13 @@ import path from "path";
2
2
  import logger from "../logger/index.js";
3
3
  import { pathToFileURL } from "url";
4
4
  import { validateBrowserConfig } from "./browsers.js";
5
+ import { validateGroupUse } from "./group-use.js";
5
6
  import { validateUseConfig } from "./use-options.js";
6
7
  import { validateForbidOnlyConfig } from "./forbid-only.js";
7
8
  import { validateRetriesConfig } from "./retries.js";
9
+ import { validateFullyParallelConfig } from "./fully-parallel.js";
8
10
  import { validateWorkersConfig } from "./workers.js";
11
+ import { validateWebServerConfig } from "./web-server-options.js";
9
12
  let activeProject;
10
13
  /** Makes `project` the one config, secrets, reports and downloads are resolved for; `undefined` ends it */
11
14
  export function setActiveProject(project) {
@@ -31,7 +34,10 @@ export function validateConfig(config) {
31
34
  validateWorkersConfig(config);
32
35
  validateForbidOnlyConfig(config);
33
36
  validateRetriesConfig(config);
37
+ validateFullyParallelConfig(config);
34
38
  validateUseConfig(config);
39
+ validateGroupUse(config);
40
+ validateWebServerConfig(config);
35
41
  }
36
42
  /** The whole `samurai.config.ts` of the project (the working directory unless a project is active) */
37
43
  export async function loadRawConfig() {
@@ -0,0 +1,3 @@
1
+ import type { SamuraiTestConfig } from "../types/config.js";
2
+ /** Checks the `fullyParallel` key of a config. Throws an Error that names the key */
3
+ export declare function validateFullyParallelConfig(config: SamuraiTestConfig): void;
@@ -0,0 +1,6 @@
1
+ /** Checks the `fullyParallel` key of a config. Throws an Error that names the key */
2
+ export function validateFullyParallelConfig(config) {
3
+ if (config.fullyParallel !== undefined &&
4
+ typeof config.fullyParallel !== "boolean")
5
+ throw new Error(`fullyParallel must be true or false, got ${JSON.stringify(config.fullyParallel)}`);
6
+ }
@@ -0,0 +1,8 @@
1
+ import type { GroupUse, SamuraiGroup, SamuraiTestConfig } from "../types/config.js";
2
+ /**
3
+ * Checks the `use` of every group of the config: the options of config `use` (`test.use()` ones, and `viewport`).
4
+ * Throws `config.groups[1].use.locale: what is wrong`.
5
+ */
6
+ export declare function validateGroupUse(config: SamuraiTestConfig): void;
7
+ /** The `use` of the group called `name`; nothing when there is no such group or it has none */
8
+ export declare function groupUseOf(groups: readonly SamuraiGroup[] | undefined, name: string): GroupUse;
@@ -0,0 +1,41 @@
1
+ import { USE_OPTION_NAMES, validateUseOptions } from "./use-options.js";
2
+ const isSize = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
3
+ /** Rewrites `use(): locale must ...` as `config.groups[1].use.locale: locale must ...` */
4
+ function atGroup(place, err) {
5
+ if (!(err instanceof TypeError))
6
+ throw err;
7
+ const problem = err.message.replace(/^use\(\): /, "");
8
+ const name = /^(?:invalid |unknown )?(\w+)/.exec(problem)?.[1];
9
+ const known = name !== undefined &&
10
+ (name === "viewport" || USE_OPTION_NAMES.includes(name));
11
+ throw new TypeError(`${place}${known ? `.${name}` : ""}: ${problem}`);
12
+ }
13
+ /**
14
+ * Checks the `use` of every group of the config: the options of config `use` (`test.use()` ones, and `viewport`).
15
+ * Throws `config.groups[1].use.locale: what is wrong`.
16
+ */
17
+ export function validateGroupUse(config) {
18
+ config.groups?.forEach((group, index) => {
19
+ if (group.use === undefined)
20
+ return;
21
+ const place = `config.groups[${index}].use`;
22
+ try {
23
+ const raw = group.use;
24
+ const isObject = typeof raw === "object" && raw !== null && !Array.isArray(raw);
25
+ const { viewport, ...options } = (isObject ? raw : {});
26
+ // Anything but an object is refused here, with the message `test.use()` gives
27
+ validateUseOptions(isObject ? options : raw);
28
+ if (viewport !== undefined &&
29
+ viewport !== null &&
30
+ !(isSize(viewport.width) && isSize(viewport.height)))
31
+ throw new TypeError(`viewport must be { width, height } with whole numbers above 0, or null, got ${JSON.stringify(viewport)}`);
32
+ }
33
+ catch (err) {
34
+ atGroup(place, err);
35
+ }
36
+ });
37
+ }
38
+ /** The `use` of the group called `name`; nothing when there is no such group or it has none */
39
+ export function groupUseOf(groups, name) {
40
+ return groups?.find((group) => group.name === name)?.use ?? {};
41
+ }
@@ -45,6 +45,8 @@ export interface ResolvedConfig {
45
45
  workers?: number | string;
46
46
  /** The workers a run without `--workers` and `SAMURAI_WORKERS` uses on this machine */
47
47
  workersResolved: number;
48
+ /** The config's `fullyParallel` as written; absent when not set */
49
+ fullyParallel?: boolean;
48
50
  retries: number;
49
51
  forbidOnly: boolean;
50
52
  headless: boolean;
@@ -62,6 +62,9 @@ export function resolveConfig(config, options) {
62
62
  bidiCommandTimeout: config.bidi?.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT,
63
63
  ...(config.workers !== undefined && { workers: config.workers }),
64
64
  workersResolved: resolveWorkers({ config: config.workers }),
65
+ ...(config.fullyParallel !== undefined && {
66
+ fullyParallel: config.fullyParallel,
67
+ }),
65
68
  // Matches the runner's `resolveRetries` without the command line flag and the environment (config/ cannot import runner/)
66
69
  retries: config.retries ?? DEFAULT_RETRIES,
67
70
  forbidOnly: forbidsOnly(config, env),
@@ -0,0 +1,3 @@
1
+ import type { StorageState } from "../types/use.js";
2
+ /** The state itself, or the JSON file (relative to `baseDir`) that holds it. Throws `<who>: what is wrong` (`use()` unless told otherwise). */
3
+ export declare function loadStorageState(source: string | StorageState, baseDir: string, who?: string): StorageState;
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { checkStorageStateObject } from "./use-options.js";
4
+ /** The state itself, or the JSON file (relative to `baseDir`) that holds it. Throws `<who>: what is wrong` (`use()` unless told otherwise). */
5
+ export function loadStorageState(source, baseDir, who = "use()") {
6
+ if (typeof source !== "string")
7
+ return source;
8
+ const file = path.resolve(baseDir, source);
9
+ let state;
10
+ try {
11
+ state = JSON.parse(readFileSync(file, "utf8"));
12
+ }
13
+ catch (err) {
14
+ throw new Error(`${who}: storageState file ${JSON.stringify(source)} could not be read: ${err instanceof Error ? err.message : String(err)}`);
15
+ }
16
+ if (typeof state !== "object" || state === null || Array.isArray(state))
17
+ throw new Error(`${who}: storageState file ${JSON.stringify(source)} must hold an object`);
18
+ checkStorageStateObject(state);
19
+ return state;
20
+ }
@@ -1,5 +1,7 @@
1
1
  import type { SamuraiTestConfig } from "../types/config.js";
2
2
  import type { UseOptions } from "../types/use.js";
3
+ /** The options of `use`: config, group, `test.use()` and (those that can change at run time) `page.use()` */
4
+ export declare const USE_OPTION_NAMES: readonly string[];
3
5
  /**
4
6
  * Checks the options of `test.use()` or of the config's `use` and returns them. The config's `use` also holds
5
7
  * options that are not these (`viewport`, `headless`, ...), so it passes `allowOthers`; `test.use()` refuses
@@ -9,7 +11,7 @@ export declare function validateUseOptions(options: unknown, allowOthers?: boole
9
11
  /** Checks a storage state object, whether it came from the options or from a file */
10
12
  export declare function checkStorageStateObject(state: Record<string, unknown>): void;
11
13
  /** The options after `layers` are applied in order: a later layer wins, key by key */
12
- export declare function mergeUse(layers: readonly UseOptions[]): UseOptions;
14
+ export declare function mergeUse<T extends UseOptions>(layers: readonly T[]): T;
13
15
  /** Checks the new options of a config's `use` */
14
16
  export declare function validateUseConfig(config: SamuraiTestConfig): void;
15
17
  /** The options of `test.use()` that a config's `use` holds, without the rest (`viewport`, `headless`, ...) */
@@ -1,7 +1,12 @@
1
- const NAMES = [
1
+ /** The options of `use`: config, group, `test.use()` and (those that can change at run time) `page.use()` */
2
+ export const USE_OPTION_NAMES = [
2
3
  "locale",
3
- "timezoneId",
4
+ "timezone",
4
5
  "userAgent",
6
+ "geolocation",
7
+ "offline",
8
+ "orientation",
9
+ "screen",
5
10
  "javaScriptEnabled",
6
11
  "acceptInsecureCerts",
7
12
  "hasTouch",
@@ -10,12 +15,20 @@ const NAMES = [
10
15
  "extraHTTPHeaders",
11
16
  "colorScheme",
12
17
  ];
13
- const BOOLEANS = [
14
- "javaScriptEnabled",
15
- "acceptInsecureCerts",
16
- "hasTouch",
17
- "isMobile",
18
+ /** A name that was in use once, and the one that replaced it: refused with a message that says which */
19
+ const RENAMED = {
20
+ timezoneId: "timezone",
21
+ touch: "hasTouch",
22
+ };
23
+ const ORIENTATIONS = [
24
+ "portrait-primary",
25
+ "portrait-secondary",
26
+ "landscape-primary",
27
+ "landscape-secondary",
18
28
  ];
29
+ /** These may be `null` (the real value back) */
30
+ const NULLABLE_BOOLEANS = ["javaScriptEnabled", "hasTouch", "offline"];
31
+ const BOOLEANS = ["acceptInsecureCerts", "isMobile"];
19
32
  const COLOR_SCHEMES = ["light", "dark", "no-preference"];
20
33
  const HEADER_NAME = /^[\w!#$%&'*+.^`|~-]+$/;
21
34
  const fail = (problem) => {
@@ -30,17 +43,29 @@ const isObject = (value) => typeof value === "object" && value !== null && !Arra
30
43
  export function validateUseOptions(options, allowOthers = false) {
31
44
  if (!isObject(options))
32
45
  return fail("the options must be an object");
33
- for (const key of Object.keys(options))
34
- if (!allowOthers && !NAMES.includes(key))
46
+ for (const key of Object.keys(options)) {
47
+ const renamed = RENAMED[key];
48
+ if (renamed !== undefined)
49
+ fail(`unknown option "${key}"; use "${renamed}"`);
50
+ if (!allowOthers && !USE_OPTION_NAMES.includes(key))
35
51
  fail(`unknown option "${key}"`);
52
+ }
36
53
  const use = options;
37
54
  for (const name of BOOLEANS)
38
55
  if (use[name] !== undefined && typeof use[name] !== "boolean")
39
56
  fail(`${name} must be true or false, got ${JSON.stringify(use[name])}`);
40
- checkLocale(use.locale);
41
- checkTimezone(use.timezoneId);
42
- if (use.userAgent !== undefined && !nonEmpty(use.userAgent))
57
+ for (const name of NULLABLE_BOOLEANS)
58
+ if (use[name] != null && typeof use[name] !== "boolean")
59
+ fail(`${name} must be true, false or null, got ${JSON.stringify(use[name])}`);
60
+ if (use.locale !== null)
61
+ checkLocale(use.locale);
62
+ if (use.timezone !== null)
63
+ checkTimezone(use.timezone);
64
+ if (use.userAgent != null && !nonEmpty(use.userAgent))
43
65
  fail("userAgent must be a non-empty string");
66
+ checkGeolocation(use.geolocation);
67
+ checkOrientation(use.orientation);
68
+ checkScreen(use.screen);
44
69
  if (use.colorScheme !== undefined && !COLOR_SCHEMES.includes(use.colorScheme))
45
70
  fail(`colorScheme must be "light", "dark" or "no-preference", got ${JSON.stringify(use.colorScheme)}`);
46
71
  checkHeaders(use.extraHTTPHeaders);
@@ -68,9 +93,40 @@ function checkTimezone(timezone) {
68
93
  new Intl.DateTimeFormat(undefined, { timeZone: timezone });
69
94
  }
70
95
  catch {
71
- fail(`unknown timezoneId ${JSON.stringify(timezone)}`);
96
+ fail(`unknown timezone ${JSON.stringify(timezone)}`);
72
97
  }
73
98
  }
99
+ const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
100
+ const isPositiveInteger = (value) => Number.isInteger(value) && value > 0;
101
+ function checkGeolocation(place) {
102
+ if (place === undefined || place === null)
103
+ return;
104
+ if (!isObject(place))
105
+ return fail("geolocation must be { latitude, longitude } or null");
106
+ const { latitude, longitude, accuracy } = place;
107
+ if (!isNumber(latitude) || latitude < -90 || latitude > 90)
108
+ fail("latitude must be between -90 and 90");
109
+ if (!isNumber(longitude) || longitude < -180 || longitude > 180)
110
+ fail("longitude must be between -180 and 180");
111
+ if (accuracy !== undefined && (!isNumber(accuracy) || accuracy < 0))
112
+ fail("accuracy must be 0 or more");
113
+ }
114
+ function checkOrientation(orientation) {
115
+ if (orientation === undefined || orientation === null)
116
+ return;
117
+ const type = orientation === "portrait" || orientation === "landscape"
118
+ ? `${orientation}-primary`
119
+ : orientation;
120
+ if (!ORIENTATIONS.includes(type))
121
+ fail(`unknown orientation ${JSON.stringify(orientation)}`);
122
+ }
123
+ function checkScreen(screen) {
124
+ if (screen === undefined || screen === null)
125
+ return;
126
+ if (!isObject(screen) ||
127
+ !(isPositiveInteger(screen.width) && isPositiveInteger(screen.height)))
128
+ fail("screen width and height must be positive integers");
129
+ }
74
130
  function checkHeaders(headers) {
75
131
  if (headers === undefined)
76
132
  return;
@@ -124,5 +180,5 @@ export function validateUseConfig(config) {
124
180
  }
125
181
  /** The options of `test.use()` that a config's `use` holds, without the rest (`viewport`, `headless`, ...) */
126
182
  export function useOptionsOf(configUse) {
127
- return Object.fromEntries(Object.entries(configUse ?? {}).filter(([key]) => NAMES.includes(key)));
183
+ return Object.fromEntries(Object.entries(configUse ?? {}).filter(([key]) => USE_OPTION_NAMES.includes(key)));
128
184
  }
@@ -0,0 +1,7 @@
1
+ import type { SamuraiTestConfig, WebServerConfig } from "../types/config.js";
2
+ export declare const DEFAULT_WEB_SERVER_TIMEOUT = 60000;
3
+ export declare const DEFAULT_GRACEFUL_TIMEOUT = 5000;
4
+ /** The config's `webServer` as a list (empty when there is none). Call after validation */
5
+ export declare function webServersOf(config: SamuraiTestConfig): WebServerConfig[];
6
+ /** Checks the `webServer`, `globalSetup` and `globalTeardown` keys of a config. Throws `config.<key>: what is wrong` */
7
+ export declare function validateWebServerConfig(config: SamuraiTestConfig): void;
@@ -0,0 +1,111 @@
1
+ export const DEFAULT_WEB_SERVER_TIMEOUT = 60_000;
2
+ export const DEFAULT_GRACEFUL_TIMEOUT = 5_000;
3
+ const KEYS = [
4
+ "command",
5
+ "url",
6
+ "port",
7
+ "cwd",
8
+ "env",
9
+ "timeout",
10
+ "reuseExistingServer",
11
+ "stdout",
12
+ "stderr",
13
+ "ignoreHTTPSErrors",
14
+ "gracefulShutdown",
15
+ ];
16
+ const STREAMS = ["pipe", "ignore"];
17
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
18
+ const fail = (where, problem) => {
19
+ throw new Error(`${where}: ${problem}`);
20
+ };
21
+ /** The config's `webServer` as a list (empty when there is none). Call after validation */
22
+ export function webServersOf(config) {
23
+ const { webServer } = config;
24
+ if (webServer === undefined)
25
+ return [];
26
+ return Array.isArray(webServer) ? webServer : [webServer];
27
+ }
28
+ /** Checks the `webServer`, `globalSetup` and `globalTeardown` keys of a config. Throws `config.<key>: what is wrong` */
29
+ export function validateWebServerConfig(config) {
30
+ const { webServer } = config;
31
+ if (webServer !== undefined) {
32
+ if (Array.isArray(webServer)) {
33
+ if (webServer.length === 0)
34
+ fail("config.webServer", "the list must not be empty");
35
+ webServer.forEach((server, index) => checkServer(server, `config.webServer[${index}]`));
36
+ }
37
+ else
38
+ checkServer(webServer, "config.webServer");
39
+ }
40
+ for (const key of ["globalSetup", "globalTeardown"]) {
41
+ const hook = config[key];
42
+ if (hook !== undefined &&
43
+ typeof hook !== "function" &&
44
+ (typeof hook !== "string" || hook === ""))
45
+ fail(`config.${key}`, "must be a module path or a function");
46
+ }
47
+ }
48
+ function checkServer(server, where) {
49
+ if (!isObject(server))
50
+ return fail(where, "must be an object");
51
+ for (const key of Object.keys(server))
52
+ if (!KEYS.includes(key))
53
+ fail(where, `unknown key "${key}"`);
54
+ const s = server;
55
+ if (typeof s.command !== "string" || s.command.trim() === "")
56
+ fail(`${where}.command`, "must be a non-empty string");
57
+ if ((s.url === undefined) === (s.port === undefined))
58
+ fail(where, "give either url or port");
59
+ if (s.url !== undefined)
60
+ checkUrl(s.url, `${where}.url`);
61
+ if (s.port !== undefined &&
62
+ !(Number.isInteger(s.port) && s.port >= 1 && s.port <= 65535))
63
+ fail(`${where}.port`, `must be a whole number from 1 to 65535`);
64
+ if (s.cwd !== undefined && (typeof s.cwd !== "string" || s.cwd === ""))
65
+ fail(`${where}.cwd`, "must be a non-empty string");
66
+ if (s.env !== undefined) {
67
+ if (!isObject(s.env))
68
+ fail(`${where}.env`, "must be an object of names and string values");
69
+ for (const [name, value] of Object.entries(s.env))
70
+ if (typeof value !== "string")
71
+ fail(`${where}.env`, `"${name}" must be a string`);
72
+ }
73
+ if (s.timeout !== undefined &&
74
+ !(typeof s.timeout === "number" &&
75
+ Number.isFinite(s.timeout) &&
76
+ s.timeout > 0))
77
+ fail(`${where}.timeout`, "must be a positive number of milliseconds");
78
+ for (const key of ["reuseExistingServer", "ignoreHTTPSErrors"])
79
+ if (s[key] !== undefined && typeof s[key] !== "boolean")
80
+ fail(`${where}.${key}`, "must be true or false");
81
+ for (const key of ["stdout", "stderr"])
82
+ if (s[key] !== undefined && !STREAMS.includes(s[key]))
83
+ fail(`${where}.${key}`, `must be "pipe" or "ignore"`);
84
+ if (s.gracefulShutdown !== undefined)
85
+ checkGraceful(s.gracefulShutdown, where);
86
+ }
87
+ function checkUrl(url, where) {
88
+ try {
89
+ if (typeof url !== "string")
90
+ throw new TypeError();
91
+ const protocol = new URL(url).protocol;
92
+ if (protocol !== "http:" && protocol !== "https:")
93
+ throw new TypeError();
94
+ }
95
+ catch {
96
+ fail(where, "must be an http or https URL");
97
+ }
98
+ }
99
+ function checkGraceful(graceful, where) {
100
+ if (!isObject(graceful))
101
+ return fail(`${where}.gracefulShutdown`, "must be an object");
102
+ for (const key of Object.keys(graceful))
103
+ if (key !== "signal" && key !== "timeout")
104
+ fail(`${where}.gracefulShutdown`, `unknown key "${key}"`);
105
+ const { signal, timeout } = graceful;
106
+ if (signal !== undefined && signal !== "SIGTERM" && signal !== "SIGINT")
107
+ fail(`${where}.gracefulShutdown.signal`, `must be "SIGTERM" or "SIGINT"`);
108
+ if (timeout !== undefined &&
109
+ !(typeof timeout === "number" && Number.isFinite(timeout) && timeout >= 0))
110
+ fail(`${where}.gracefulShutdown.timeout`, "must be a number of milliseconds, 0 or more");
111
+ }
@@ -1,5 +1,5 @@
1
1
  import { callOnExistingElement } from "./element-access.js";
2
- import { clickRect } from "./input-actions.js";
2
+ import { clickRect } from "./pointer-actions.js";
3
3
  import { ATTACHED_ONLY, CLICK_CHECKS } from "./page-scripts.js";
4
4
  /** Body run in the page as `(el) => { ... }`: what kind of control the element is, and whether it is checked */
5
5
  const CONTROL_INFO_BODY = `
@@ -0,0 +1,17 @@
1
+ import type { ActionOptions } from "./actionability.js";
2
+ import type { ElementAccess } from "./element-access.js";
3
+ import { type Point } from "./pointer-actions.js";
4
+ export interface DragOptions extends ActionOptions {
5
+ /** Where on the dragged element to press: pixels from its top left corner (default: the centre) */
6
+ sourcePosition?: Point;
7
+ /** Where on the target to release (default: the centre) */
8
+ targetPosition?: Point;
9
+ /** Pointer moves between the two points (default 10) */
10
+ steps?: number;
11
+ }
12
+ /**
13
+ * Presses on the element, moves to `target` in steps and releases there, with real pointer input. Both ends
14
+ * wait like a click does. Chrome runs a real HTML5 drag; Firefox starts one but never drops it from
15
+ * synthetic pointer input, so the drop (and `dragend`) are then fired as synthetic events (`isTrusted` false).
16
+ */
17
+ export declare function dragElement(access: ElementAccess, target: ElementAccess, options?: DragOptions): Promise<void>;
@@ -0,0 +1,80 @@
1
+ import { pointAtElement, } from "./pointer-element-actions.js";
2
+ import { HOVER_CHECKS, CLICK_CHECKS } from "./page-scripts.js";
3
+ import { pointerDrag } from "./pointer-actions.js";
4
+ const DEFAULT_STEPS = 10;
5
+ const STATE = `globalThis[Symbol.for("samurai.drag")]`;
6
+ /** Starts watching the drag events of the document, to tell afterwards how far a native drag got */
7
+ const WATCH_DRAG = `() => {
8
+ const state = { started: false, dropped: false, data: [], off: [] };
9
+ const on = (target, type, fn, capture) => {
10
+ target.addEventListener(type, fn, capture);
11
+ state.off.push(() => target.removeEventListener(type, fn, capture));
12
+ };
13
+ on(document, "dragstart", () => { state.started = true; }, true);
14
+ on(document, "drop", () => { state.dropped = true; }, true);
15
+ // Bubbling to the window comes after the page's own dragstart handlers, so what they put in the data is there
16
+ on(window, "dragstart", (event) => {
17
+ const dt = event.dataTransfer;
18
+ for (const type of dt ? Array.from(dt.types) : []) {
19
+ if (type !== "Files") state.data.push([type, dt.getData(type)]);
20
+ }
21
+ }, false);
22
+ ${STATE} = state;
23
+ }`;
24
+ /**
25
+ * Stops watching. When the browser began a native drag but never dropped (Firefox does this with
26
+ * synthetic pointer input), or the source is \`draggable\` and no drag began, fires the rest of the sequence as
27
+ * synthetic events with a DataTransfer that carries what the page's dragstart handler stored.
28
+ */
29
+ const FINISH_DRAG = `(source, target, from, to, moved) => {
30
+ const state = ${STATE};
31
+ delete ${STATE};
32
+ if (!state) return;
33
+ state.off.forEach((off) => off());
34
+ if (state.dropped || !moved) return;
35
+ if (!state.started && !source.closest('[draggable="true"]')) return;
36
+ const data = new DataTransfer();
37
+ for (const [type, value] of state.data) data.setData(type, value);
38
+ const fire = (el, type, at) => el.dispatchEvent(new DragEvent(type, {
39
+ bubbles: true, cancelable: true, composed: true, dataTransfer: data, clientX: at.x, clientY: at.y,
40
+ }));
41
+ if (!state.started && !fire(source, "dragstart", from)) return;
42
+ if (!state.started) fire(target, "dragenter", to);
43
+ // A drop is only allowed where dragover was cancelled, as in a real drag
44
+ if (fire(target, "dragover", to)) { fire(source, "dragend", to); return; }
45
+ fire(target, "drop", to);
46
+ fire(source, "dragend", to);
47
+ }`;
48
+ /**
49
+ * Presses on the element, moves to `target` in steps and releases there, with real pointer input. Both ends
50
+ * wait like a click does. Chrome runs a real HTML5 drag; Firefox starts one but never drops it from
51
+ * synthetic pointer input, so the drop (and `dragend`) are then fired as synthetic events (`isTrusted` false).
52
+ */
53
+ export async function dragElement(access, target, options = {}) {
54
+ const { sourcePosition, targetPosition, steps = DEFAULT_STEPS, ...wait } = options;
55
+ if (!Number.isInteger(steps) || steps < 1) {
56
+ throw new Error("dragTo(): steps must be a whole number, 1 or more");
57
+ }
58
+ const sourceOptions = {
59
+ ...wait,
60
+ ...(sourcePosition && { position: sourcePosition }),
61
+ };
62
+ const targetOptions = {
63
+ ...wait,
64
+ ...(targetPosition && { position: targetPosition }),
65
+ };
66
+ // The target first: scrolling it into view may move the source, which is probed last
67
+ const to = await pointAtElement(target, "dragTo", HOVER_CHECKS, targetOptions);
68
+ const from = await pointAtElement(access, "dragTo", CLICK_CHECKS, sourceOptions);
69
+ const source = await access.requireElement();
70
+ const destination = await target.requireElement();
71
+ await access.call(WATCH_DRAG, []);
72
+ let moved = false;
73
+ try {
74
+ await pointerDrag(access.connector, access.contextId, from, to, steps);
75
+ moved = true;
76
+ }
77
+ finally {
78
+ await access.call(FINISH_DRAG, [source, destination, from, to, moved]);
79
+ }
80
+ }
@@ -1,16 +1,22 @@
1
1
  import { type ElementScreenshotOptions } from "../browser/screenshot.js";
2
2
  import type { ActionOptions } from "./actionability.js";
3
3
  import { type ElementAccess } from "./element-access.js";
4
- /** Waits until the element is attached, visible, stable, enabled and not covered, then clicks its centre with real pointer input */
5
- export declare function clickElement(access: ElementAccess, options?: ActionOptions): Promise<void>;
6
4
  /** Takes a screenshot of just the element, after scrolling it into view and waiting until it is attached and visible */
7
5
  export declare function screenshotElement(access: ElementAccess, options?: ElementScreenshotOptions & {
8
6
  timeout?: number;
9
7
  }): Promise<Buffer>;
10
8
  /** Waits until the element is attached, then focuses it */
11
9
  export declare function focusElement(access: ElementAccess, options?: ActionOptions): Promise<void>;
12
- /** Waits until the element is attached, visible and enabled, focuses it and presses `key` with real keyboard input */
13
- export declare function pressOnElement(access: ElementAccess, key: string, options?: ActionOptions): Promise<void>;
10
+ /** Focuses the element and presses `key` with real keyboard input; `key` can be a chord such as `Control+a` */
11
+ export declare function pressOnElement(access: ElementAccess, key: string, options?: ActionOptions & {
12
+ delay?: number;
13
+ }): Promise<void>;
14
+ /** Focuses the element and types `text` into it one key press at a time, `delay` ms apart */
15
+ export declare function pressSequentially(access: ElementAccess, text: string, options?: ActionOptions & {
16
+ delay?: number;
17
+ }): Promise<void>;
18
+ /** Empties an input, textarea or editable element, as `fill("")` does */
19
+ export declare function clearElement(access: ElementAccess, options?: ActionOptions): Promise<void>;
14
20
  /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
15
21
  export declare function fillElement(access: ElementAccess, value: string, options?: ActionOptions & {
16
22
  output?: string;
@@ -1,14 +1,11 @@
1
1
  import { bindOutput } from "../wait/output-sink.js";
2
2
  import { takeScreenshot, } from "../browser/screenshot.js";
3
3
  import { callOnElement, callOnExistingElement, requireElement, } from "./element-access.js";
4
- import { clickRect, DEFAULT_PAUSE_MS, NAMED_KEYS, pause, pressKey, typeText, } from "./input-actions.js";
5
- import { ATTACHED_ONLY, CLICK_CHECKS, FILL_CHECKS, PRESS_CHECKS, SCREENSHOT_CHECKS, } from "./page-scripts.js";
6
- /** Waits until the element is attached, visible, stable, enabled and not covered, then clicks its centre with real pointer input */
7
- export async function clickElement(access, options) {
8
- const state = await access.waitForActionable("click", CLICK_CHECKS, { scroll: true, hitTest: true }, options);
9
- // "attached" is always required, so the box is present
10
- await clickRect(access.connector, access.contextId, state.box);
11
- }
4
+ import { clickRect } from "./pointer-actions.js";
5
+ import { DEFAULT_PAUSE_MS, pause, typeText } from "./input-actions.js";
6
+ import { chordSteps, performKeys, textSteps } from "./key-actions.js";
7
+ import { NAMED_KEYS, parseKeyChord } from "./key-map.js";
8
+ import { ATTACHED_ONLY, FILL_CHECKS, PRESS_CHECKS, SCREENSHOT_CHECKS, } from "./page-scripts.js";
12
9
  /** Takes a screenshot of just the element, after scrolling it into view and waiting until it is attached and visible */
13
10
  export async function screenshotElement(access, options = {}) {
14
11
  const { timeout, ...capture } = options;
@@ -23,15 +20,25 @@ export async function focusElement(access, options) {
23
20
  await access.waitForActionable("focus", ATTACHED_ONLY, { scroll: false, hitTest: false }, options);
24
21
  await callOnExistingElement(access, `el.focus();`);
25
22
  }
26
- /** Waits until the element is attached, visible and enabled, focuses it and presses `key` with real keyboard input */
27
- export async function pressOnElement(access, key, options) {
28
- const code = NAMED_KEYS[key] ?? (Array.from(key).length === 1 ? key : "");
29
- if (code === "") {
30
- throw new Error(`press(): unknown key "${key}". Use one of ${Object.keys(NAMED_KEYS).join(", ")} or a single character`);
31
- }
32
- await access.waitForActionable("press", PRESS_CHECKS, { scroll: true, hitTest: false }, options);
23
+ /** Waits until the element is attached, visible and enabled and focuses it; the keys then go to the page's keyboard */
24
+ async function focusForKeys(access, method, options) {
25
+ await access.waitForActionable(method, PRESS_CHECKS, { scroll: true, hitTest: false }, options);
33
26
  await callOnElement(access, undefined, `el.focus();`);
34
- await pressKey(access.connector, access.contextId, code);
27
+ }
28
+ /** Focuses the element and presses `key` with real keyboard input; `key` can be a chord such as `Control+a` */
29
+ export async function pressOnElement(access, key, options) {
30
+ const chord = parseKeyChord(key, "press");
31
+ await focusForKeys(access, "press", options);
32
+ await performKeys(access.connector, access.contextId, chordSteps(chord, options?.delay));
33
+ }
34
+ /** Focuses the element and types `text` into it one key press at a time, `delay` ms apart */
35
+ export async function pressSequentially(access, text, options) {
36
+ await focusForKeys(access, "pressSequentially", options);
37
+ await performKeys(access.connector, access.contextId, textSteps(text, options?.delay));
38
+ }
39
+ /** Empties an input, textarea or editable element, as `fill("")` does */
40
+ export async function clearElement(access, options) {
41
+ await fillElement(access, "", options);
35
42
  }
36
43
  /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
37
44
  export async function fillElement(access, value, options) {
@@ -22,6 +22,11 @@ export interface ProbeOptions {
22
22
  scroll: boolean;
23
23
  /** Check which element receives a click at the element's centre */
24
24
  hitTest: boolean;
25
+ /** Where to check the hit: this far from the element's top left corner; the centre without it */
26
+ at?: {
27
+ x: number;
28
+ y: number;
29
+ };
25
30
  /**
26
31
  * Probe a checkbox, radio or switch the way a user reaches it: opacity 0 does not hide it, and a click on
27
32
  * one of its labels counts as hitting it (`control-probe.ts`). Only the check actions set this
@@ -46,7 +46,8 @@ export const PROBE_ELEMENT = `(el, options) => {
46
46
  const editable = Boolean(enabled && !el.readOnly && typeable);
47
47
  let hitTarget = null;
48
48
  if (options.hitTest) {
49
- const top = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
49
+ const at = options.at || { x: rect.width / 2, y: rect.height / 2 };
50
+ const top = document.elementFromPoint(rect.left + at.x, rect.top + at.y);
50
51
  hitTarget = !top ? "nothing"
51
52
  : top === el || el.contains(top) ? "self"
52
53
  : top.tagName.toLowerCase() + (top.id ? "#" + top.id : "") +
@@ -0,0 +1,10 @@
1
+ import type { ActionOptions } from "./actionability.js";
2
+ import { type ElementAccess } from "./element-access.js";
3
+ /** Waits until the element is attached, then removes focus from it */
4
+ export declare function blurElement(access: ElementAccess, options?: ActionOptions): Promise<void>;
5
+ /**
6
+ * Waits until the element is attached, then fires a synthetic event of `type` at it (no scrolling, visibility or
7
+ * enabled checks, as with a user it would not be reached). The event class follows the type; `eventInit`
8
+ * (plain data) is merged over `bubbles`, `cancelable` and `composed`, which are all true.
9
+ */
10
+ export declare function dispatchEventOn(access: ElementAccess, type: string, eventInit?: Record<string, unknown>, options?: ActionOptions): Promise<void>;