@itmetsam/samurai-framework 0.2.0 → 0.2.2
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 +3 -1
- package/dist/cli/args.js +3 -2
- package/dist/cli/init.js +8 -2
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +3 -2
- package/dist/config/run-settings.d.ts +9 -1
- package/dist/config/run-settings.js +12 -1
- package/dist/recorder/session.d.ts +2 -0
- package/dist/recorder/session.js +4 -1
- package/dist/runner/project.js +2 -1
- package/dist/runner/test-runner.js +37 -6
- package/dist/transport/bidi-connection.d.ts +13 -0
- package/dist/transport/bidi-connection.js +28 -3
- package/dist/types/config.d.ts +7 -0
- package/package.json +28 -3
- package/dist/cli/cli.cli-test.d.ts +0 -1
- package/dist/cli/cli.cli-test.js +0 -170
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
|
+
[](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 (
|
|
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 =
|
|
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]:
|
|
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}";
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -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
|
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { parseCommandLine, parseRunnerFlags, parseRunOverrides } from "./args.js";
|
|
2
|
+
import { parseCommandLine, parseRunnerFlags, parseRunOverrides, } from "./args.js";
|
|
3
3
|
import { recordSpec } from "../recorder/index.js";
|
|
4
4
|
import { listTests, runTests } from "../runner/run.js";
|
|
5
5
|
import { stepToSource } from "../steps/index.js";
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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 */
|
package/dist/recorder/session.js
CHANGED
|
@@ -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", {
|
|
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;
|
package/dist/runner/project.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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.
|
|
26
|
+
this.closed = true;
|
|
27
|
+
this.rejectAll((pending) => new ConnectionClosedError(`Websocket closed${pendingSuffix(pending)}`));
|
|
14
28
|
};
|
|
15
29
|
this.onWebsocketError = (ev) => {
|
|
16
|
-
this.
|
|
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.
|
|
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
|
}
|
package/dist/types/config.d.ts
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "",
|
|
3
|
+
"version": "0.2.2",
|
|
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
|
".": {
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"types": "./dist/recorder/index.d.ts",
|
|
21
21
|
"default": "./dist/recorder/index.js"
|
|
22
22
|
},
|
|
23
|
+
"./init": {
|
|
24
|
+
"types": "./dist/cli/init.d.ts",
|
|
25
|
+
"default": "./dist/cli/init.js"
|
|
26
|
+
},
|
|
23
27
|
"./browser": {
|
|
24
28
|
"types": "./dist/browser/index.d.ts",
|
|
25
29
|
"default": "./dist/browser/index.js"
|
|
@@ -33,7 +37,14 @@
|
|
|
33
37
|
"test:browser": "tsx --test --test-concurrency=1 \"src/**/*.browser-test.ts\"",
|
|
34
38
|
"test:cli": "tsx --test --test-concurrency=1 \"src/**/*.cli-test.ts\""
|
|
35
39
|
},
|
|
36
|
-
"keywords": [
|
|
40
|
+
"keywords": [
|
|
41
|
+
"testing",
|
|
42
|
+
"browser",
|
|
43
|
+
"e2e",
|
|
44
|
+
"webdriver-bidi",
|
|
45
|
+
"firefox",
|
|
46
|
+
"recorder"
|
|
47
|
+
],
|
|
37
48
|
"author": "",
|
|
38
49
|
"license": "ISC",
|
|
39
50
|
"type": "module",
|
|
@@ -60,5 +71,19 @@
|
|
|
60
71
|
"tsx": {
|
|
61
72
|
"optional": true
|
|
62
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"
|
|
63
88
|
}
|
|
64
89
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/cli/cli.cli-test.js
DELETED
|
@@ -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
|
-
});
|