@itmetsam/samurai-framework 0.2.1 → 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.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A browser test framework written in TypeScript. It drives a real browser over the [WebDriver BiDi](https://www.w3.org/TR/webdriver-bidi/) protocol, with no WebDriver server and no Playwright or Puppeteer underneath.
4
4
 
5
+ [![CI](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml)
6
+
5
7
  ```ts
6
8
  import { expect, test } from "../api.js";
7
9
 
@@ -86,7 +88,7 @@ bun run dev --timeout 60000 # per-test timeout in ms; also --expect-timeout
86
88
 
87
89
  Once the package is installed in a project, `samurai run` does the same with a readable summary and a proper exit code, and `samurai init` scaffolds a new project (see [Command line](docs/cli.md)).
88
90
 
89
- A browser window opens for each test and closes when the test ends. The result is written to `result/report.json` (status, duration and error per test); nothing summarises it on the console yet. The process exits with code 0 when every test passed and 1 otherwise (add `--headless` to hide the window). Framework logs go to the console and `logs/`.
91
+ A browser window opens for each test and closes when the test ends. The result is written to `result/report.json` (status, duration and error per test); nothing summarises it on the console yet. The process exits with code 0 when every test passed and 1 otherwise (`--headless` hides the window; on CI it is the default, see [Configuration](docs/configuration.md#headless)). Framework logs go to the console and `logs/`.
90
92
 
91
93
  > `src/tests/index.spec.ts` is an example that submits a real contact form. Delete it or replace it before running everything.
92
94
 
package/dist/cli/args.js CHANGED
@@ -21,6 +21,7 @@ export function parseCommandLine(argv) {
21
21
  args: argv,
22
22
  options: FLAGS,
23
23
  strict: true,
24
+ allowNegative: true,
24
25
  allowPositionals: true,
25
26
  });
26
27
  }
@@ -31,8 +32,8 @@ function parseFlags(argv) {
31
32
  export function parseRunnerFlags(argv) {
32
33
  const values = parseFlags(argv);
33
34
  const flags = {};
34
- if (values.headless)
35
- flags.headless = true;
35
+ if (values.headless !== undefined)
36
+ flags.headless = values.headless;
36
37
  if (values.port !== undefined) {
37
38
  const port = Number(values.port);
38
39
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
package/dist/cli/init.js CHANGED
@@ -1,6 +1,12 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  const PACKAGE = "@itmetsam/samurai-framework";
4
+ /** The version of this package, so a new project asks for the release that scaffolded it */
5
+ function frameworkVersion() {
6
+ const manifest = new URL("../../package.json", import.meta.url);
7
+ return JSON.parse(readFileSync(manifest, "utf8"))
8
+ .version;
9
+ }
4
10
  /** The files `samurai init` writes, by path relative to the project folder */
5
11
  export function scaffold(name) {
6
12
  return {
@@ -10,7 +16,7 @@ export function scaffold(name) {
10
16
  private: true,
11
17
  type: "module",
12
18
  scripts: { test: "samurai run" },
13
- dependencies: { [PACKAGE]: "^0.1.0" },
19
+ dependencies: { [PACKAGE]: `^${frameworkVersion()}` },
14
20
  devDependencies: { tsx: "^4.21.0" },
15
21
  }, null, 2) + "\n",
16
22
  "samurai.config.ts": `import { defineConfig } from "${PACKAGE}";
@@ -1,4 +1,4 @@
1
- export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless Run the browser without a window\n --port <n> Browser debugging port (default 9223)\n\nOptions for record (also --env, --port, --json):\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\nRun it in the project folder, the one holding samurai.config.ts.\n";
1
+ export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n\nOptions for record (also --env, --port, --json):\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\nRun it in the project folder, the one holding samurai.config.ts.\n";
2
2
  export interface Io {
3
3
  out: (text: string) => void;
4
4
  err: (text: string) => void;
package/dist/cli/main.js CHANGED
@@ -22,7 +22,8 @@ Options for run and list:
22
22
  Options for run:
23
23
  --timeout <ms> Time one test may take
24
24
  --expect-timeout <ms> Time actions and assertions retry
25
- --headless Run the browser without a window
25
+ --headless, --no-headless Run the browser without a window, or with one.
26
+ Default: the config's use.headless, else headless when CI is set
26
27
  --port <n> Browser debugging port (default 9223)
27
28
 
28
29
  Options for record (also --env, --port, --json):
@@ -5,6 +5,8 @@ export interface RunOverrides {
5
5
  environment?: string;
6
6
  timeout?: number;
7
7
  expectTimeout?: number;
8
+ /** Beats the config's `headless`; the command line's `--headless` and `--no-headless` */
9
+ headless?: boolean;
8
10
  }
9
11
  /** Everything about a run that depends on the chosen environment */
10
12
  export interface RunSettings {
@@ -15,12 +17,18 @@ export interface RunSettings {
15
17
  /** Time (ms) actions and assertions retry */
16
18
  expectTimeout: number;
17
19
  variables: Variables;
20
+ /** Whether the browser runs without a window */
21
+ headless: boolean;
18
22
  }
19
23
  export declare class UnknownEnvironmentError extends Error {
20
24
  constructor(message: string);
21
25
  }
22
26
  /** Picks the environment and applies precedence: run override > environment > project > built-in default */
23
- export declare function resolveRunSettings(config: SamuraiTestConfig, overrides?: RunOverrides): RunSettings;
27
+ /** True when the `CI` environment variable says this is a CI run (set, and not "", "0" or "false") */
28
+ export declare function isCI(env?: Partial<NodeJS.ProcessEnv>): boolean;
29
+ export declare function resolveRunSettings(config: SamuraiTestConfig, overrides?: RunOverrides,
30
+ /** Where `CI` is read from */
31
+ env?: Partial<NodeJS.ProcessEnv>): RunSettings;
24
32
  /** Makes `settings` the run's settings; `undefined` ends the run (tests use this to reset) */
25
33
  export declare function setRunSettings(settings: RunSettings | undefined): void;
26
34
  /** The active run's settings, or undefined outside a run (unit and browser tests) */
@@ -9,7 +9,14 @@ export class UnknownEnvironmentError extends Error {
9
9
  }
10
10
  }
11
11
  /** Picks the environment and applies precedence: run override > environment > project > built-in default */
12
- export function resolveRunSettings(config, overrides = {}) {
12
+ /** True when the `CI` environment variable says this is a CI run (set, and not "", "0" or "false") */
13
+ export function isCI(env = process.env) {
14
+ const value = env.CI?.trim().toLowerCase();
15
+ return (value !== undefined && value !== "" && value !== "0" && value !== "false");
16
+ }
17
+ export function resolveRunSettings(config, overrides = {},
18
+ /** Where `CI` is read from */
19
+ env = process.env) {
13
20
  const environments = config.environments ?? {};
14
21
  const names = Object.keys(environments);
15
22
  const available = `Available: ${names.join(", ")}`;
@@ -43,6 +50,10 @@ export function resolveRunSettings(config, overrides = {}) {
43
50
  config.expect?.timeout ??
44
51
  DEFAULT_EXPECT_TIMEOUT,
45
52
  variables: { ...(environment?.variables ?? {}) },
53
+ headless: overrides.headless ??
54
+ environment?.headless ??
55
+ config.use?.headless ??
56
+ isCI(env),
46
57
  };
47
58
  }
48
59
  let active;
@@ -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() {
@@ -18,6 +18,8 @@ export interface RecordSpecOptions extends ProjectOptions {
18
18
  url?: string;
19
19
  /** The browser's debugging port. @default 9223 */
20
20
  port?: number;
21
+ /** Run the browser without a window. A person can't use it then; for tests and machines without a display. @default the config's `use.headless`, else headless on CI and a window otherwise */
22
+ headless?: boolean;
21
23
  /** Aborting ends the recording, like closing the browser window */
22
24
  signal?: AbortSignal;
23
25
  /** Called after each step is written to the file, with the file's new content */
@@ -66,7 +66,10 @@ export async function recordSpec(options) {
66
66
  if (!Number.isInteger(at) || at < 0 || at > test.steps.length) {
67
67
  throw new RangeError(`--at must be between 0 and ${test.steps.length} (the test has ${test.steps.length} steps)`);
68
68
  }
69
- const { browser, page } = await Browser.launch("firefox", { port: options.port ?? 9223, headless: false }, options.signal);
69
+ const { browser, page } = await Browser.launch("firefox", {
70
+ port: options.port ?? 9223,
71
+ headless: options.headless ?? settings.headless,
72
+ }, options.signal);
70
73
  const events = [];
71
74
  try {
72
75
  const start = options.url ?? settings.baseURL;
@@ -11,7 +11,7 @@ export async function withProject(options, work) {
11
11
  if (active)
12
12
  throw new Error("A test run is already active in this process");
13
13
  active = true;
14
- const { projectDir: dir, config, dataDir, environment, timeout, expectTimeout, } = options;
14
+ const { projectDir: dir, config, dataDir, environment, timeout, expectTimeout, headless, } = options;
15
15
  const previousDir = projectDir();
16
16
  try {
17
17
  setActiveProject({ dir: dir ?? previousDir, config, dataDir });
@@ -22,6 +22,7 @@ export async function withProject(options, work) {
22
22
  ...(environment !== undefined && { environment }),
23
23
  ...(timeout !== undefined && { timeout }),
24
24
  ...(expectTimeout !== undefined && { expectTimeout }),
25
+ ...(headless !== undefined && { headless }),
25
26
  });
26
27
  return await work(run);
27
28
  }
@@ -13,6 +13,7 @@ import TestReporter, {} from "./reporter.js";
13
13
  import Page from "../browser/page.js";
14
14
  import { takePendingAssertions } from "../assert/expect.js";
15
15
  import { toTestError } from "./test-error.js";
16
+ import { ConnectionClosedError } from "../transport/bidi-connection.js";
16
17
  import { judgePageLogs } from "./page-logs-report.js";
17
18
  /** Whether a test with this full name is selected by the options */
18
19
  export function isSelected(name, { grep, testNames }) {
@@ -97,6 +98,17 @@ export default class TestRunner {
97
98
  async executeTestCase(test, reporter) {
98
99
  logger.verbose("Starting test: %s", test.name);
99
100
  reporter.onTestStart(test);
101
+ // A test ends once, whichever of finishing, failing, timing out, an abort or a closed browser comes first
102
+ let ended = false;
103
+ const finish = (error, logs) => {
104
+ if (ended)
105
+ return;
106
+ ended = true;
107
+ reporter.onTestEnd(test, error, logs);
108
+ };
109
+ let browserClosed = false;
110
+ /** Set once the runner itself closes the browser */
111
+ let closing = false;
100
112
  const selectedBrowser = (await readConfig("browser")) ?? "firefox";
101
113
  const { settings, secrets } = this.run;
102
114
  const timeout = settings.timeout;
@@ -108,7 +120,7 @@ export default class TestRunner {
108
120
  await Promise.race([
109
121
  new Promise((resolve) => {
110
122
  timeoutTimer = setTimeout(() => {
111
- reporter.onTestEnd(test, {
123
+ finish({
112
124
  message: `Test timed out after ${timeout / 1000} seconds`,
113
125
  type: "timeout",
114
126
  });
@@ -118,7 +130,7 @@ export default class TestRunner {
118
130
  onRunAborted = () => {
119
131
  if (timeoutController.signal.aborted)
120
132
  return;
121
- reporter.onTestEnd(test, { message: "Run aborted", type: "error" });
133
+ finish({ message: "Run aborted", type: "error" });
122
134
  timeoutController.abort(new Error("Run aborted"));
123
135
  resolve();
124
136
  };
@@ -130,9 +142,22 @@ export default class TestRunner {
130
142
  try {
131
143
  const launched = await Browser.launch(selectedBrowser, {
132
144
  port: this.options.port ?? 9223,
133
- headless: this.options.headless ?? false,
145
+ headless: this.options.headless ?? settings.headless,
134
146
  }, timeoutController.signal);
135
147
  browser = launched.browser;
148
+ // A person closing the window, or the browser crashing: end the test now instead of waiting for a
149
+ // command to time out
150
+ void launched.browser.exited.then(() => {
151
+ if (ended || closing)
152
+ return;
153
+ browserClosed = true;
154
+ finish({
155
+ message: "The browser was closed while the test was running",
156
+ type: "error",
157
+ });
158
+ timeoutController.abort(new Error("Browser closed"));
159
+ resolve();
160
+ });
136
161
  page = launched.page;
137
162
  const fixtures = {
138
163
  page: launched.page,
@@ -146,7 +171,7 @@ export default class TestRunner {
146
171
  return resolve();
147
172
  const unawaited = takePendingAssertions();
148
173
  if (unawaited.length > 0) {
149
- reporter.onTestEnd(test, {
174
+ finish({
150
175
  message: unawaited
151
176
  .map((matcher) => `expect(locator).${matcher}() was not awaited`)
152
177
  .join("\n"),
@@ -155,13 +180,18 @@ export default class TestRunner {
155
180
  return resolve();
156
181
  }
157
182
  const verdict = await judgeLogs(page, false);
158
- reporter.onTestEnd(test, verdict.error, verdict);
183
+ finish(verdict.error, verdict);
159
184
  resolve();
160
185
  }
161
186
  catch (err) {
162
187
  if (timeoutController.signal.aborted)
163
188
  return resolve();
164
- reporter.onTestEnd(test, toTestError(err), await judgeLogs(page, true));
189
+ finish(browserClosed || err instanceof ConnectionClosedError
190
+ ? {
191
+ message: "The browser was closed while the test was running",
192
+ type: "error",
193
+ }
194
+ : toTestError(err), await judgeLogs(page, true));
165
195
  resolve();
166
196
  }
167
197
  }),
@@ -171,6 +201,7 @@ export default class TestRunner {
171
201
  this.options.signal?.removeEventListener("abort", onRunAborted);
172
202
  takePendingAssertions();
173
203
  logger.verbose("Closing browser for test: %s", test.name);
204
+ closing = true;
174
205
  await browser?.close();
175
206
  }
176
207
  }
@@ -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;
@@ -8,9 +8,21 @@ export interface SendOptions {
8
8
  /** Overrides the connector's commandTimeout for this command */
9
9
  timeout?: number;
10
10
  }
11
+ /**
12
+ * The connection to the browser is gone (the browser exited or was closed, or the socket dropped). Commands that
13
+ * were waiting fail with it, and so does every command sent afterwards: a closed socket would swallow them silently.
14
+ */
15
+ export declare class ConnectionClosedError extends Error {
16
+ readonly cause: unknown;
17
+ constructor(message: string, options?: {
18
+ cause?: unknown;
19
+ });
20
+ }
11
21
  export declare class BiDiConnector {
12
22
  private resolveMap;
13
23
  private currentId;
24
+ /** Set once the socket closed or failed: nothing sent after that gets an answer */
25
+ private closed;
14
26
  private webSocket;
15
27
  private eventEmitter;
16
28
  private commandTimeout;
@@ -19,6 +31,7 @@ export declare class BiDiConnector {
19
31
  static connect(url: string, options?: ConnectorOptions): Promise<BiDiConnector>;
20
32
  private onWebsocketClose;
21
33
  private onWebsocketError;
34
+ private get isClosed();
22
35
  /** Rejects every pending command; `reason` gets the pending method names for its message */
23
36
  private rejectAll;
24
37
  private getId;
@@ -5,15 +5,30 @@ import { BiDiError } from "./bidi-error.js";
5
5
  import { SubscriptionManager, } from "./subscriptions.js";
6
6
  /** Time (ms) a command may wait for its reply before it fails with code "timeout" */
7
7
  const DEFAULT_COMMAND_TIMEOUT = 30000;
8
+ /**
9
+ * The connection to the browser is gone (the browser exited or was closed, or the socket dropped). Commands that
10
+ * were waiting fail with it, and so does every command sent afterwards: a closed socket would swallow them silently.
11
+ */
12
+ export class ConnectionClosedError extends Error {
13
+ constructor(message, options) {
14
+ super(message);
15
+ this.name = "ConnectionClosedError";
16
+ this.cause = options?.cause;
17
+ }
18
+ }
8
19
  export class BiDiConnector {
9
20
  constructor(ws, options = {}) {
10
21
  this.resolveMap = new Map();
11
22
  this.currentId = 0;
23
+ /** Set once the socket closed or failed: nothing sent after that gets an answer */
24
+ this.closed = false;
12
25
  this.onWebsocketClose = () => {
13
- this.rejectAll((pending) => new Error(`Websocket closed${pendingSuffix(pending)}`));
26
+ this.closed = true;
27
+ this.rejectAll((pending) => new ConnectionClosedError(`Websocket closed${pendingSuffix(pending)}`));
14
28
  };
15
29
  this.onWebsocketError = (ev) => {
16
- this.rejectAll((pending) => Object.assign(new Error(`Websocket error${pendingSuffix(pending)}`), {
30
+ this.closed = true;
31
+ this.rejectAll((pending) => new ConnectionClosedError(`Websocket error${pendingSuffix(pending)}`, {
17
32
  cause: ev,
18
33
  }));
19
34
  };
@@ -69,6 +84,11 @@ export class BiDiConnector {
69
84
  });
70
85
  });
71
86
  }
87
+ get isClosed() {
88
+ // A socket that is closing or closed (readyState 2 or 3) has already lost the browser
89
+ const state = this.webSocket.readyState;
90
+ return this.closed || (typeof state === "number" && state > 1);
91
+ }
72
92
  /** Rejects every pending command; `reason` gets the pending method names for its message */
73
93
  rejectAll(reason) {
74
94
  const entries = [...this.resolveMap.values()];
@@ -82,6 +102,10 @@ export class BiDiConnector {
82
102
  send(method, params, options = {}) {
83
103
  return new Promise((resolve, reject) => {
84
104
  try {
105
+ if (this.isClosed) {
106
+ // Sending on a closed socket is silently dropped: without this the command waits out its timeout
107
+ throw new ConnectionClosedError(`Websocket closed: cannot send ${method}`);
108
+ }
85
109
  const id = this.getId();
86
110
  const timeout = options.timeout ?? this.commandTimeout;
87
111
  const timer = setTimeout(() => {
@@ -166,7 +190,8 @@ export class BiDiConnector {
166
190
  this.webSocket.removeEventListener("message", this.messageListener);
167
191
  this.webSocket.removeEventListener("close", this.onWebsocketClose);
168
192
  this.webSocket.removeEventListener("error", this.onWebsocketError);
169
- this.rejectAll((pending) => new Error(`BiDiConnector killed${pendingSuffix(pending)}`));
193
+ this.closed = true;
194
+ this.rejectAll((pending) => new ConnectionClosedError(`BiDiConnector killed${pendingSuffix(pending)}`));
170
195
  this.eventEmitter.removeAllListeners();
171
196
  this.webSocket.close();
172
197
  }
@@ -31,6 +31,8 @@ export interface SamuraiEnvironment {
31
31
  /** Overrides the project timeout (ms) for this environment */
32
32
  timeout?: number;
33
33
  expect?: { timeout?: number };
34
+ /** Overrides `use.headless` for this environment, e.g. `true` for a `ci` environment */
35
+ headless?: boolean;
34
36
  /** Test data, used in steps as ${name}. Never put secrets here. */
35
37
  variables?: Record<string, string | number | boolean>;
36
38
  }
@@ -90,6 +92,11 @@ export interface SamuraiTestConfig {
90
92
  * @default { width: 1280, height: 720 }
91
93
  */
92
94
  viewport?: { width: number; height: number } | null;
95
+ /**
96
+ * Run the browser without a window. Needed on machines without a display, such as CI runners.
97
+ * Left out, a run is headless when the `CI` environment variable is set and shows a window otherwise
98
+ */
99
+ headless?: boolean;
93
100
  };
94
101
  /** What to do with the browser's console output and uncaught page exceptions */
95
102
  logs?: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.2.1",
4
- "description": "",
3
+ "version": "0.2.3",
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": {
7
7
  ".": {
@@ -37,7 +37,14 @@
37
37
  "test:browser": "tsx --test --test-concurrency=1 \"src/**/*.browser-test.ts\"",
38
38
  "test:cli": "tsx --test --test-concurrency=1 \"src/**/*.cli-test.ts\""
39
39
  },
40
- "keywords": [],
40
+ "keywords": [
41
+ "testing",
42
+ "browser",
43
+ "e2e",
44
+ "webdriver-bidi",
45
+ "firefox",
46
+ "recorder"
47
+ ],
41
48
  "author": "",
42
49
  "license": "ISC",
43
50
  "type": "module",
@@ -64,5 +71,19 @@
64
71
  "tsx": {
65
72
  "optional": true
66
73
  }
74
+ },
75
+ "repository": {
76
+ "type": "git",
77
+ "url": "git+https://github.com/degrootsam/samurai-framework.git"
78
+ },
79
+ "homepage": "https://github.com/degrootsam/samurai-framework#readme",
80
+ "bugs": {
81
+ "url": "https://github.com/degrootsam/samurai-framework/issues"
82
+ },
83
+ "publishConfig": {
84
+ "access": "public"
85
+ },
86
+ "engines": {
87
+ "node": ">=24"
67
88
  }
68
89
  }
@@ -1 +0,0 @@
1
- export {};
@@ -1,170 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import path from "node:path";
5
- import { describe, it } from "node:test";
6
- import { ConsoleReporter } from "./console-reporter.js";
7
- import { initProject, scaffold } from "./init.js";
8
- import { main, USAGE } from "./main.js";
9
- function io() {
10
- const out = [];
11
- const err = [];
12
- const target = {
13
- out: (t) => out.push(t),
14
- err: (t) => err.push(t),
15
- color: false,
16
- env: {},
17
- };
18
- return { target, out: () => out.join(""), err: () => err.join("") };
19
- }
20
- describe("main", () => {
21
- it("prints the usage for --help, -h and help", async () => {
22
- for (const argv of [["--help"], ["-h"], ["help"], ["run", "--help"]]) {
23
- const { target, out } = io();
24
- assert.equal(await main(argv, target), 0);
25
- assert.equal(out(), USAGE);
26
- }
27
- });
28
- it("prints the version", async () => {
29
- const { target, out } = io();
30
- assert.equal(await main(["--version"], target), 0);
31
- assert.match(out(), /^\d+\.\d+\.\d+/);
32
- });
33
- it("exits 2 for an unknown command, with the usage", async () => {
34
- const { target, err } = io();
35
- assert.equal(await main(["bogus"], target), 2);
36
- assert.match(err(), /Unknown command "bogus"/);
37
- assert.match(err(), /Usage:/);
38
- });
39
- it("exits 2 for an unknown flag or a bad value, with a message", async () => {
40
- const flag = io();
41
- assert.equal(await main(["run", "--nope"], flag.target), 2);
42
- assert.match(flag.err(), /nope/);
43
- const port = io();
44
- assert.equal(await main(["run", "--port", "abc"], port.target), 2);
45
- assert.match(port.err(), /--port/);
46
- });
47
- it("exits 2 when the project has no config", async () => {
48
- const dir = mkdtempSync(path.join(tmpdir(), "samurai-cli-"));
49
- const previous = process.cwd();
50
- process.chdir(dir);
51
- try {
52
- const { target, err } = io();
53
- assert.equal(await main(["list"], target), 2);
54
- assert.ok(err().length > 0);
55
- }
56
- finally {
57
- process.chdir(previous);
58
- }
59
- });
60
- it("record needs a spec and a sensible --at", async () => {
61
- const none = io();
62
- assert.equal(await main(["record"], none.target), 2);
63
- assert.match(none.err(), /Which spec/);
64
- const bad = io();
65
- assert.equal(await main(["record", "a.spec.ts", "--at", "x"], bad.target), 2);
66
- assert.match(bad.err(), /--at/);
67
- });
68
- it("record refuses a spec that does not exist unless --new is given", async () => {
69
- const dir = mkdtempSync(path.join(tmpdir(), "samurai-cli-"));
70
- writeFileSync(path.join(dir, "samurai.config.ts"), "export default {};");
71
- const previous = process.cwd();
72
- process.chdir(dir);
73
- try {
74
- const { target, err } = io();
75
- assert.equal(await main(["record", "tests/none.spec.ts"], target), 2);
76
- assert.match(err(), /does not exist/);
77
- }
78
- finally {
79
- process.chdir(previous);
80
- }
81
- });
82
- it("init scaffolds a project and never overwrites", async () => {
83
- const dir = mkdtempSync(path.join(tmpdir(), "samurai-init-"));
84
- writeFileSync(path.join(dir, "package.json"), '{"name":"mine"}');
85
- const { target, out } = io();
86
- assert.equal(await main(["init", dir], target), 0);
87
- assert.match(out(), /kept\s+package\.json/);
88
- assert.match(out(), /created samurai\.config\.ts/);
89
- assert.equal(readFileSync(path.join(dir, "package.json"), "utf8"), '{"name":"mine"}');
90
- for (const file of [
91
- "samurai.config.ts",
92
- "tests/example.spec.ts",
93
- ".env.example",
94
- ".gitignore",
95
- ]) {
96
- assert.ok(existsSync(path.join(dir, file)), file);
97
- }
98
- });
99
- });
100
- describe("scaffold", () => {
101
- it("is an ES module project with a test script", () => {
102
- const manifest = JSON.parse(scaffold("shop")["package.json"]);
103
- assert.equal(manifest.type, "module");
104
- assert.equal(manifest.scripts.test, "samurai run");
105
- assert.equal(manifest.name, "shop");
106
- });
107
- it("names the package after the folder, safely", () => {
108
- const dir = mkdtempSync(path.join(tmpdir(), "My Shop!-"));
109
- initProject(dir);
110
- const manifest = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
111
- assert.match(manifest.name, /^[a-z0-9-]+$/);
112
- });
113
- });
114
- describe("ConsoleReporter", () => {
115
- const summary = {
116
- status: "failed",
117
- duration: 2300,
118
- startTime: 0,
119
- environment: "staging",
120
- tests: [
121
- {
122
- name: "a",
123
- file: "/x/a.spec.ts",
124
- startTime: 0,
125
- status: "success",
126
- duration: 1000,
127
- },
128
- {
129
- name: "b > c",
130
- file: "/x/a.spec.ts",
131
- startTime: 0,
132
- status: "failed",
133
- duration: 1300,
134
- type: "error",
135
- message: "boom\nsecond line",
136
- },
137
- ],
138
- };
139
- it("prints a line per test, the failures in full and a count", () => {
140
- let text = "";
141
- const reporter = new ConsoleReporter({
142
- write: (t) => (text += t),
143
- color: false,
144
- });
145
- reporter.handle({ type: "run-start", environment: "staging", total: 2 });
146
- reporter.handle({ type: "test-start", name: "a", file: "/x/a.spec.ts" });
147
- for (const result of summary.tests)
148
- reporter.handle({
149
- type: "test-end",
150
- name: "name" in result ? result.name : "",
151
- file: "/x/a.spec.ts",
152
- result,
153
- });
154
- reporter.handle({ type: "run-end", summary });
155
- assert.match(text, /Running 2 tests against staging/);
156
- assert.match(text, /✔ a \(1\.0s\)/);
157
- assert.match(text, /✖ b > c \(1\.3s\)/);
158
- assert.match(text, /\n {4}boom\n {4}second line\n/);
159
- assert.match(text, /1 failed, 1 passed \(2\.3s\)/);
160
- assert.ok(!text.includes("\u001b"), "no colour codes when colour is off");
161
- });
162
- it("colours when asked to", () => {
163
- let text = "";
164
- new ConsoleReporter({ write: (t) => (text += t), color: true }).handle({
165
- type: "run-end",
166
- summary,
167
- });
168
- assert.ok(text.includes("\u001b[31m"));
169
- });
170
- });