@itmetsam/samurai-framework 0.7.0 → 0.8.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.
- package/README.md +25 -6
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/file-chooser.d.ts +4 -3
- package/dist/browser/file-chooser.js +9 -8
- package/dist/browser/generated-files.d.ts +30 -0
- package/dist/browser/generated-files.js +116 -0
- package/dist/browser/page.d.ts +19 -0
- package/dist/browser/page.js +30 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +22 -7
- package/dist/locator/locator.d.ts +17 -5
- package/dist/locator/locator.js +56 -8
- package/dist/project/files-store.d.ts +28 -0
- package/dist/project/files-store.js +106 -0
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +1 -0
- package/dist/recorder/apply.js +8 -6
- package/dist/recorder/files-capture.d.ts +26 -0
- package/dist/recorder/files-capture.js +92 -0
- package/dist/recorder/files-reader.d.ts +7 -0
- package/dist/recorder/files-reader.js +25 -0
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/normalise.d.ts +26 -2
- package/dist/recorder/normalise.js +48 -1
- package/dist/recorder/recorder.d.ts +38 -0
- package/dist/recorder/recorder.js +160 -1
- package/dist/recorder/session.d.ts +16 -1
- package/dist/recorder/session.js +36 -6
- package/dist/recorder/toolbar/controller.d.ts +9 -0
- package/dist/recorder/toolbar/controller.js +22 -0
- package/dist/recorder/toolbar/protocol.d.ts +10 -0
- package/dist/recorder/toolbar/source.d.ts +1 -1
- package/dist/recorder/toolbar/source.js +35 -2
- package/dist/recorder/toolbar/styles.d.ts +1 -1
- package/dist/recorder/toolbar/styles.js +31 -0
- package/dist/runner/project.js +2 -0
- package/dist/steps/edit.d.ts +3 -2
- package/dist/steps/edit.js +64 -5
- package/dist/steps/emit.d.ts +3 -1
- package/dist/steps/emit.js +19 -0
- package/dist/steps/files.d.ts +8 -0
- package/dist/steps/files.js +33 -0
- package/dist/steps/index.d.ts +3 -2
- package/dist/steps/index.js +2 -1
- package/dist/steps/model.d.ts +38 -0
- package/dist/steps/parse.js +102 -0
- package/dist/types/config.d.ts +7 -2
- package/package.json +1 -1
- package/dist/browser/file-paths.d.ts +0 -4
- package/dist/browser/file-paths.js +0 -18
package/README.md
CHANGED
|
@@ -20,16 +20,32 @@ test("Visitor can sign in", async ({ page, env, secrets }) => {
|
|
|
20
20
|
- **Semantic locators.** By role, label, text, test id, CSS or XPath, with ordered fallbacks.
|
|
21
21
|
- **Environments and secrets.** One test file runs against `dev`, `staging` or `production`; secrets come from the environment or a `.env.<name>` file and are masked in logs and reports.
|
|
22
22
|
- **Network control.** Mock, block or modify requests; wait for requests, responses and network idle; read response bodies.
|
|
23
|
+
- **Flows.** Chain tests and groups into a graph with conditions, variables, waits and HTTP requests, and run it from the command line or CI (see [Flows](docs/flows.md)).
|
|
23
24
|
- **Recorder building blocks.** Specs are parsed into steps and edited back with minimal diffs, and an engine records what a person does in the browser. A toolbar in the recorded page adds assertions, variables and secrets, waits, screenshots and pause (see [Steps and the recorder](docs/recorder.md)). The Electron app that ties them together lives in a separate repository (see the [roadmap](docs/roadmap.md)).
|
|
24
25
|
|
|
25
26
|
> **Status:** early. Firefox only. See [what is still open](docs/roadmap.md).
|
|
26
27
|
|
|
27
28
|
## Getting started
|
|
28
29
|
|
|
30
|
+
### In your own project
|
|
31
|
+
|
|
32
|
+
The package is published on npm as [`@itmetsam/samurai-framework`](https://www.npmjs.com/package/@itmetsam/samurai-framework). In an empty folder:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npx -p @itmetsam/samurai-framework samurai init # config, an example test, .env.example, .gitignore
|
|
36
|
+
npm install
|
|
37
|
+
npx samurai browsers install firefox # once per machine
|
|
38
|
+
npx samurai run
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Specs and the config then import from `@itmetsam/samurai-framework` instead of the relative paths used below. See [Command line](docs/cli.md).
|
|
42
|
+
|
|
43
|
+
The rest of this section works in a checkout of this repository.
|
|
44
|
+
|
|
29
45
|
### 1. Requirements
|
|
30
46
|
|
|
31
|
-
- [Node.js](https://nodejs.org)
|
|
32
|
-
- Firefox, downloaded by the framework: run `
|
|
47
|
+
- [Node.js](https://nodejs.org) 24 or newer (`engines` in `package.json`; CI uses 26) and, to work on the framework, [Bun](https://bun.sh) 1.3 or newer
|
|
48
|
+
- Firefox, downloaded by the framework: run `bun run dev browsers install firefox` once in this checkout (`npx samurai browsers install firefox` in a project; see [Which browser runs](docs/pages-and-browsers.md#which-browser-runs)). The Firefox installed on the machine is not used.
|
|
33
49
|
|
|
34
50
|
### 2. Install
|
|
35
51
|
|
|
@@ -86,9 +102,9 @@ bun run dev --env production # another environment (or SAMURAI_ENV=production)
|
|
|
86
102
|
bun run dev --timeout 60000 # per-test timeout in ms; also --expect-timeout
|
|
87
103
|
```
|
|
88
104
|
|
|
89
|
-
|
|
105
|
+
`bun run dev` is `samurai run` straight from the sources, so it takes the same options (`--grep`, `--file`, `--group`, `--headless`, `--json`, …) and the other commands too (`bun run dev list`, `bun run dev browsers install firefox`). See [Command line](docs/cli.md).
|
|
90
106
|
|
|
91
|
-
A browser window opens for each test and closes when the test ends
|
|
107
|
+
A browser window opens for each test and closes when the test ends (`--headless` hides it; on CI it is the default, see [Configuration](docs/configuration.md#headless)). Each test's result is printed as it finishes, followed by a summary, and the full report is written to `result/report.json`. The process exits with code 0 when every test passed, 1 otherwise and 2 for a usage or configuration error. Framework logs go to stderr and `logs/`.
|
|
92
108
|
|
|
93
109
|
> `src/tests/index.spec.ts` is an example that submits a real contact form. Delete it or replace it before running everything.
|
|
94
110
|
|
|
@@ -100,6 +116,7 @@ A project can also keep flows in `flows/*.flow.json` (edited in the Samurai app)
|
|
|
100
116
|
|
|
101
117
|
```sh
|
|
102
118
|
cp .env.example .env.staging
|
|
119
|
+
# then add a line per secret, e.g.
|
|
103
120
|
# SAMURAI_SECRET_TEST_PASSWORD=...
|
|
104
121
|
```
|
|
105
122
|
|
|
@@ -118,19 +135,21 @@ Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win ove
|
|
|
118
135
|
| [Environments and secrets](docs/environments-and-secrets.md) | Per-environment settings, variables, secrets and masking |
|
|
119
136
|
| [Command line](docs/cli.md) | `samurai run`, `list`, `record` and `init`, options, output, exit codes, `--json` |
|
|
120
137
|
| [Flows](docs/flows.md) | `samurai flow list`, `check` and `run`: graphs of tests, groups and steps, reports, CI |
|
|
138
|
+
| [Writing a step](docs/steps.md) | `defineStep`: flow steps of your own, the built-in steps |
|
|
121
139
|
| [Running tests and reports](docs/running-and-reports.md) | What a run does, `report.json`, page logs, timeouts |
|
|
122
140
|
| [Steps and the recorder](docs/recorder.md) | The step codec, the recording engine and the recorder toolbar |
|
|
123
141
|
| [Embedding the framework](docs/embedding.md) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
|
|
124
142
|
| [Contributing](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
|
|
125
143
|
| [Roadmap](docs/roadmap.md) | What is built and what is still open |
|
|
126
144
|
|
|
127
|
-
Design notes for every feature live in [`docs/superpowers/specs`](docs/superpowers/specs).
|
|
145
|
+
The [documentation index](docs/README.md) lists every guide and design note by topic. Design notes for every feature live in [`docs/superpowers/specs`](docs/superpowers/specs).
|
|
128
146
|
|
|
129
147
|
## Scripts
|
|
130
148
|
|
|
131
149
|
| Command | Does |
|
|
132
150
|
| ---------------------- | ----------------------------------------------- |
|
|
133
|
-
| `bun run dev` |
|
|
151
|
+
| `bun run dev` | `samurai` from the sources (`tsx src/index.ts`) |
|
|
134
152
|
| `bun run test` | Unit tests of the framework (no browser needed) |
|
|
135
153
|
| `bun run test:browser` | Browser tests of the framework (needs Firefox) |
|
|
154
|
+
| `bun run test:cli` | Tests of the `samurai` command (build first) |
|
|
136
155
|
| `bun run build` | Typechecks and compiles to `dist/` |
|
package/dist/api.d.ts
CHANGED
|
@@ -3,3 +3,4 @@ export { expect } from "./assert/expect.js";
|
|
|
3
3
|
export { defineConfig } from "./config/config.js";
|
|
4
4
|
export { defineStep, type StepDefinition, type StepResult, type StepRunInput, } from "./flows/steps/index.js";
|
|
5
5
|
export type { TestFixtures } from "./types/test.js";
|
|
6
|
+
export { generatedFile, type GeneratedFile, type FileInput, } from "./browser/generated-files.js";
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { BiDiConnector } from "../transport/bidi-connection.js";
|
|
2
2
|
import type { FileDialogInfo } from "../types/bidi-modules/input.js";
|
|
3
3
|
import type { ContextTree } from "./context-tree.js";
|
|
4
|
+
import { type FileInput } from "./generated-files.js";
|
|
4
5
|
/**
|
|
5
6
|
* A native file picker the page asked for (a click on an `<input type=file>` or its label). Nothing appears on screen:
|
|
6
7
|
* answer it with `setFiles()`.
|
|
@@ -12,10 +13,10 @@ export declare class FileChooser {
|
|
|
12
13
|
/** Whether the input lets the user pick several files */
|
|
13
14
|
isMultiple(): boolean;
|
|
14
15
|
/**
|
|
15
|
-
* Picks the files (an empty list picks none).
|
|
16
|
-
* machine the browser runs on.
|
|
16
|
+
* Picks the files (an empty list picks none). Relative paths resolve against the project folder and must exist on
|
|
17
|
+
* the machine the browser runs on; `generatedFile(…)` is accepted too.
|
|
17
18
|
*/
|
|
18
|
-
setFiles(files:
|
|
19
|
+
setFiles(files: FileInput | FileInput[]): Promise<void>;
|
|
19
20
|
}
|
|
20
21
|
interface EventMap {
|
|
21
22
|
filechooser: [FileChooser];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import logger from "../logger/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveFileInputs } from "./generated-files.js";
|
|
4
4
|
/**
|
|
5
5
|
* A native file picker the page asked for (a click on an `<input type=file>` or its label). Nothing appears on screen:
|
|
6
6
|
* answer it with `setFiles()`.
|
|
@@ -15,16 +15,15 @@ export class FileChooser {
|
|
|
15
15
|
return this.info.multiple;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* Picks the files (an empty list picks none).
|
|
19
|
-
* machine the browser runs on.
|
|
18
|
+
* Picks the files (an empty list picks none). Relative paths resolve against the project folder and must exist on
|
|
19
|
+
* the machine the browser runs on; `generatedFile(…)` is accepted too.
|
|
20
20
|
*/
|
|
21
21
|
async setFiles(files) {
|
|
22
|
-
const sharedId = this.info.element
|
|
22
|
+
const sharedId = this.info.element
|
|
23
|
+
?.sharedId;
|
|
23
24
|
if (!sharedId)
|
|
24
25
|
throw new Error("file chooser has no element reference");
|
|
25
|
-
const paths =
|
|
26
|
-
for (const file of paths)
|
|
27
|
-
await assertIsFile(file, "setFiles");
|
|
26
|
+
const paths = await resolveFileInputs(files, "setFiles");
|
|
28
27
|
if (paths.length > 1 && !this.info.multiple) {
|
|
29
28
|
throw new Error("file chooser does not accept multiple files");
|
|
30
29
|
}
|
|
@@ -61,7 +60,9 @@ export class FileChooserTracker {
|
|
|
61
60
|
const tracker = new FileChooserTracker(connector, tree, contextId);
|
|
62
61
|
connector.onEvent("input.fileDialogOpened", tracker.onOpened);
|
|
63
62
|
try {
|
|
64
|
-
tracker.subscription = await connector.subscribe([
|
|
63
|
+
tracker.subscription = await connector.subscribe([
|
|
64
|
+
"input.fileDialogOpened",
|
|
65
|
+
]);
|
|
65
66
|
}
|
|
66
67
|
catch (err) {
|
|
67
68
|
connector.offEvent("input.fileDialogOpened", tracker.onOpened);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
declare const GENERATED: unique symbol;
|
|
2
|
+
/** A file `setInputFiles` and `dropFiles` write when the step runs, instead of one stored in the project */
|
|
3
|
+
export interface GeneratedFile {
|
|
4
|
+
readonly [GENERATED]: true;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/** Bytes */
|
|
7
|
+
readonly size: number;
|
|
8
|
+
readonly type?: string;
|
|
9
|
+
}
|
|
10
|
+
/** What `setInputFiles` and `dropFiles` take: a path (relative to the project folder) or a generated file */
|
|
11
|
+
export type FileInput = string | GeneratedFile;
|
|
12
|
+
/**
|
|
13
|
+
* A file of `size` written when the step runs: seeded random bytes, the same in every run and not
|
|
14
|
+
* compressible, under `result/files/`, removed when the run ends. For upload limits and load tests.
|
|
15
|
+
* `size` is bytes or a text with a unit (`"60 MB"`, `"1.5 GB"`; powers of 1024). `type` is the MIME type
|
|
16
|
+
* `dropFiles` gives the page; for `setInputFiles` the browser takes it from the name's extension.
|
|
17
|
+
* @example
|
|
18
|
+
* await page.getByLabel("Upload").setInputFiles(generatedFile({ name: "huge.pdf", size: "60 MB" }));
|
|
19
|
+
*/
|
|
20
|
+
export declare function generatedFile(options: {
|
|
21
|
+
name: string;
|
|
22
|
+
size: number | string;
|
|
23
|
+
type?: string;
|
|
24
|
+
}): GeneratedFile;
|
|
25
|
+
export declare const isGeneratedFile: (value: unknown) => value is GeneratedFile;
|
|
26
|
+
/** Removes this run's generated files (`withProject` calls it when the run ends) */
|
|
27
|
+
export declare function removeGeneratedFiles(): Promise<void>;
|
|
28
|
+
/** Absolute paths for `setInputFiles` / `dropFiles`: generated files are written, project files must exist */
|
|
29
|
+
export declare function resolveFileInputs(files: FileInput | FileInput[], operation: string): Promise<string[]>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, open, rm, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { projectDir } from "../config/config.js";
|
|
5
|
+
import { parseSize } from "../steps/files.js";
|
|
6
|
+
const GENERATED = Symbol.for("samurai.generatedFile");
|
|
7
|
+
const CHUNK = 1024 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* A file of `size` written when the step runs: seeded random bytes, the same in every run and not
|
|
10
|
+
* compressible, under `result/files/`, removed when the run ends. For upload limits and load tests.
|
|
11
|
+
* `size` is bytes or a text with a unit (`"60 MB"`, `"1.5 GB"`; powers of 1024). `type` is the MIME type
|
|
12
|
+
* `dropFiles` gives the page; for `setInputFiles` the browser takes it from the name's extension.
|
|
13
|
+
* @example
|
|
14
|
+
* await page.getByLabel("Upload").setInputFiles(generatedFile({ name: "huge.pdf", size: "60 MB" }));
|
|
15
|
+
*/
|
|
16
|
+
export function generatedFile(options) {
|
|
17
|
+
const size = parseSize(options.size);
|
|
18
|
+
if (size === undefined)
|
|
19
|
+
throw new Error(`generatedFile(): size must be a number of bytes or a text like "60 MB", got ${JSON.stringify(options.size)}`);
|
|
20
|
+
const name = path.basename(String(options.name ?? ""));
|
|
21
|
+
if (name === "" || name === "." || name === "..")
|
|
22
|
+
throw new Error(`generatedFile(): name must be a file name, got ${JSON.stringify(options.name)}`);
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
[GENERATED]: true,
|
|
25
|
+
name,
|
|
26
|
+
size,
|
|
27
|
+
...(options.type !== undefined && { type: options.type }),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export const isGeneratedFile = (value) => typeof value === "object" &&
|
|
31
|
+
value !== null &&
|
|
32
|
+
value[GENERATED] === true;
|
|
33
|
+
let folder;
|
|
34
|
+
const written = new Map();
|
|
35
|
+
/** xorshift32, seeded from the name and size */
|
|
36
|
+
function seedOf(file) {
|
|
37
|
+
const seed = createHash("sha256")
|
|
38
|
+
.update(`${file.size}:${file.name}`)
|
|
39
|
+
.digest()
|
|
40
|
+
.readUInt32LE(0);
|
|
41
|
+
return seed === 0 ? 1 : seed;
|
|
42
|
+
}
|
|
43
|
+
async function write(target, size, seed) {
|
|
44
|
+
const handle = await open(target, "w");
|
|
45
|
+
try {
|
|
46
|
+
const chunk = Buffer.alloc(CHUNK);
|
|
47
|
+
let state = seed;
|
|
48
|
+
for (let done = 0; done < size; done += CHUNK) {
|
|
49
|
+
for (let i = 0; i < CHUNK; i += 4) {
|
|
50
|
+
state ^= state << 13;
|
|
51
|
+
state ^= state >>> 17;
|
|
52
|
+
state ^= state << 5;
|
|
53
|
+
state >>>= 0;
|
|
54
|
+
chunk.writeUInt32LE(state, i);
|
|
55
|
+
}
|
|
56
|
+
await handle.write(chunk, 0, Math.min(CHUNK, size - done));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The generated file on disk; written once per run for the same name and size */
|
|
64
|
+
function generatedPath(file) {
|
|
65
|
+
folder ??= path.join(projectDir(), "result", "files");
|
|
66
|
+
const key = `${file.size}/${file.name}`;
|
|
67
|
+
let pending = written.get(key);
|
|
68
|
+
if (!pending) {
|
|
69
|
+
const dir = folder;
|
|
70
|
+
pending = (async () => {
|
|
71
|
+
const target = path.join(dir, key);
|
|
72
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
73
|
+
await write(target, file.size, seedOf(file));
|
|
74
|
+
return target;
|
|
75
|
+
})();
|
|
76
|
+
written.set(key, pending);
|
|
77
|
+
pending.catch(() => written.delete(key));
|
|
78
|
+
}
|
|
79
|
+
return pending;
|
|
80
|
+
}
|
|
81
|
+
/** Removes this run's generated files (`withProject` calls it when the run ends) */
|
|
82
|
+
export async function removeGeneratedFiles() {
|
|
83
|
+
const dir = folder;
|
|
84
|
+
folder = undefined;
|
|
85
|
+
const pending = [...written.values()];
|
|
86
|
+
written.clear();
|
|
87
|
+
await Promise.allSettled(pending);
|
|
88
|
+
if (dir)
|
|
89
|
+
await rm(dir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
/** Absolute paths for `setInputFiles` / `dropFiles`: generated files are written, project files must exist */
|
|
92
|
+
export async function resolveFileInputs(files, operation) {
|
|
93
|
+
const paths = [];
|
|
94
|
+
for (const file of Array.isArray(files) ? files : [files]) {
|
|
95
|
+
if (isGeneratedFile(file)) {
|
|
96
|
+
paths.push(await generatedPath(file));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (typeof file !== "string")
|
|
100
|
+
throw new TypeError(`${operation}(): expected a path or generatedFile(…), got ${typeof file}`);
|
|
101
|
+
const absolute = path.resolve(projectDir(), file);
|
|
102
|
+
let info;
|
|
103
|
+
try {
|
|
104
|
+
info = await stat(absolute);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error(path.isAbsolute(file)
|
|
108
|
+
? `${operation}(): file not found: ${absolute}`
|
|
109
|
+
: `${operation}(): ${file.replace(/\\/g, "/")} doesn't exist in the project`);
|
|
110
|
+
}
|
|
111
|
+
if (!info.isFile())
|
|
112
|
+
throw new Error(`${operation}(): not a file: ${absolute}`);
|
|
113
|
+
paths.push(absolute);
|
|
114
|
+
}
|
|
115
|
+
return paths;
|
|
116
|
+
}
|
package/dist/browser/page.d.ts
CHANGED
|
@@ -46,6 +46,16 @@ interface PageEvents extends NetworkEvents {
|
|
|
46
46
|
pageerror: [PageError];
|
|
47
47
|
}
|
|
48
48
|
export type PageEventName = keyof PageEvents;
|
|
49
|
+
/** Events `page.waitForEvent` can wait for */
|
|
50
|
+
export type WaitableEventName = "download" | "filechooser" | "request" | "response";
|
|
51
|
+
export interface WaitForEventOptions<T> {
|
|
52
|
+
/** Only an event this accepts counts */
|
|
53
|
+
predicate?: (item: T) => boolean;
|
|
54
|
+
/** Time (ms) to wait. @default config `navigation.timeout`, then 30000 */
|
|
55
|
+
timeout?: number;
|
|
56
|
+
/** Runs once the wait is set up; what it causes cannot be missed */
|
|
57
|
+
trigger?: () => Promise<unknown>;
|
|
58
|
+
}
|
|
49
59
|
export default class Page {
|
|
50
60
|
private biDiConnector;
|
|
51
61
|
private id;
|
|
@@ -297,6 +307,15 @@ export default class Page {
|
|
|
297
307
|
timeout?: number;
|
|
298
308
|
trigger?: () => Promise<unknown>;
|
|
299
309
|
}): Promise<FileChooser>;
|
|
310
|
+
/**
|
|
311
|
+
* Playwright-style wait for one page event: `download`, `filechooser`, `request` or `response`. Same as the named
|
|
312
|
+
* `waitFor…` method, with an optional predicate (a function, or `predicate` in the options). Call it before the event
|
|
313
|
+
* is caused, or pass `trigger`, which runs once the wait is set up.
|
|
314
|
+
* @example
|
|
315
|
+
* const chooser = await page.waitForEvent("filechooser", { trigger: () => page.getByText("Upload file").click() });
|
|
316
|
+
* await chooser.setFiles("./myfile.pdf");
|
|
317
|
+
*/
|
|
318
|
+
waitForEvent<E extends WaitableEventName>(event: E, optionsOrPredicate?: ((item: PageEvents[E][0]) => boolean) | WaitForEventOptions<PageEvents[E][0]>): Promise<PageEvents[E][0]>;
|
|
300
319
|
/**
|
|
301
320
|
* Starts keeping response bodies, which is what makes `response.body()` possible: the browser only keeps a body
|
|
302
321
|
* when this ran before the request started. `page.on("response")` and `waitForResponse` call it; config
|
package/dist/browser/page.js
CHANGED
|
@@ -784,6 +784,36 @@ export default class Page {
|
|
|
784
784
|
await this.startFileChooserTracking();
|
|
785
785
|
return this.waitForPageEvent("filechooser", () => true, options, (timeout) => new FileChooserTimeoutError(timeout));
|
|
786
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* Playwright-style wait for one page event: `download`, `filechooser`, `request` or `response`. Same as the named
|
|
789
|
+
* `waitFor…` method, with an optional predicate (a function, or `predicate` in the options). Call it before the event
|
|
790
|
+
* is caused, or pass `trigger`, which runs once the wait is set up.
|
|
791
|
+
* @example
|
|
792
|
+
* const chooser = await page.waitForEvent("filechooser", { trigger: () => page.getByText("Upload file").click() });
|
|
793
|
+
* await chooser.setFiles("./myfile.pdf");
|
|
794
|
+
*/
|
|
795
|
+
async waitForEvent(event, optionsOrPredicate = {}) {
|
|
796
|
+
const { predicate, ...options } = typeof optionsOrPredicate === "function"
|
|
797
|
+
? { predicate: optionsOrPredicate }
|
|
798
|
+
: optionsOrPredicate;
|
|
799
|
+
const accepts = (predicate ?? (() => true));
|
|
800
|
+
switch (event) {
|
|
801
|
+
case "request":
|
|
802
|
+
return this.waitForRequest(accepts, options);
|
|
803
|
+
case "response":
|
|
804
|
+
return this.waitForResponse(accepts, options);
|
|
805
|
+
case "download":
|
|
806
|
+
this.assertOpen();
|
|
807
|
+
await this.startDownloadTracking();
|
|
808
|
+
return this.waitForPageEvent("download", accepts, options, (timeout) => new DownloadWaitTimeoutError(timeout));
|
|
809
|
+
case "filechooser":
|
|
810
|
+
this.assertOpen();
|
|
811
|
+
await this.startFileChooserTracking();
|
|
812
|
+
return this.waitForPageEvent("filechooser", accepts, options, (timeout) => new FileChooserTimeoutError(timeout));
|
|
813
|
+
default:
|
|
814
|
+
throw new Error(`page.waitForEvent() does not support "${String(event)}": use page.on()`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
787
817
|
/**
|
|
788
818
|
* Starts keeping response bodies, which is what makes `response.body()` possible: the browser only keeps a body
|
|
789
819
|
* when this ran before the request started. `page.on("response")` and `waitForResponse` call it; config
|
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 samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\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 --group <name> Only the tests of this group of the config's groups\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.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\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 samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\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 --group <name> Only the tests of this group of the config's groups\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, --headless, --no-headless):\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.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\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
|
@@ -31,7 +31,7 @@ Options for run:
|
|
|
31
31
|
Default: the config's use.headless, else headless when CI is set
|
|
32
32
|
--port <n> Browser debugging port (default 9223)
|
|
33
33
|
|
|
34
|
-
Options for record (also --env, --port, --json):
|
|
34
|
+
Options for record (also --env, --port, --json, --headless, --no-headless):
|
|
35
35
|
--new <title> Add a test with this title (creates the spec file if needed)
|
|
36
36
|
--test <name|index> Record into this test; not needed when the file has one
|
|
37
37
|
--at <n> Insert from step n (default: after the last step)
|
|
@@ -114,6 +114,7 @@ export async function main(argv, io) {
|
|
|
114
114
|
...(values.new !== undefined && { create: values.new }),
|
|
115
115
|
...(at !== undefined && { at }),
|
|
116
116
|
...(values.url !== undefined && { url: values.url }),
|
|
117
|
+
...(flags.headless !== undefined && { headless: flags.headless }),
|
|
117
118
|
...(flags.port !== undefined && { port: flags.port }),
|
|
118
119
|
...(io.signal && { signal: io.signal }),
|
|
119
120
|
onStarted: () => {
|
|
@@ -132,22 +133,36 @@ export async function main(argv, io) {
|
|
|
132
133
|
io.out(state === "paused" ? "Paused\n" : "Recording\n");
|
|
133
134
|
}
|
|
134
135
|
},
|
|
136
|
+
onMissingFile: (_file, text) => {
|
|
137
|
+
if (!values.json)
|
|
138
|
+
io.out(`${text}\n`);
|
|
139
|
+
},
|
|
135
140
|
onEvent: (event) => {
|
|
136
141
|
if (values.json)
|
|
137
142
|
return void io.out(`${JSON.stringify(event)}\n`);
|
|
138
|
-
if (event.op === "
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
if (event.op === "remove") {
|
|
144
|
+
count--;
|
|
145
|
+
return void io.out(` - step ${event.index + 1} (the click that opened the file picker)\n`);
|
|
146
|
+
}
|
|
147
|
+
io.out(` ${event.op === "insert" ? "+" : "~"} ${stepToSource(event.step)}\n`);
|
|
142
148
|
if (event.op === "insert")
|
|
143
149
|
count++;
|
|
144
150
|
},
|
|
145
151
|
});
|
|
146
152
|
if (!values.json) {
|
|
147
153
|
io.out(`\nRecorded ${count} ${count === 1 ? "step" : "steps"} into "${result.test}" in ${result.file}\n`);
|
|
148
|
-
if (result.
|
|
149
|
-
io.out(`Set ${result.
|
|
154
|
+
if (result.unsetSecrets.length > 0) {
|
|
155
|
+
io.out(`Set ${result.unsetSecrets.map((name) => `SAMURAI_SECRET_${name}`).join(", ")} (environment variable or .env.<environment>) before running it.\n`);
|
|
156
|
+
}
|
|
157
|
+
if (result.missingFiles.length > 0) {
|
|
158
|
+
io.out(`Add ${result.missingFiles.map((f) => f.path).join(", ")} before running it (or use generatedFile).\n`);
|
|
150
159
|
}
|
|
160
|
+
const hasFiles = result.files.length + result.missingFiles.length > 0;
|
|
161
|
+
const folder = result.filesFolder === "" ? "." : result.filesFolder;
|
|
162
|
+
if (hasFiles && result.filesIgnored)
|
|
163
|
+
io.out(`${folder}/ is ignored by git; CI won't have these files.\n`);
|
|
164
|
+
if (hasFiles && result.firstFiles)
|
|
165
|
+
io.out(`Recorded files are stored in ${folder}/ and committed with your tests.\n`);
|
|
151
166
|
}
|
|
152
167
|
return 0;
|
|
153
168
|
}
|
|
@@ -3,6 +3,7 @@ import type { BrowsingContext, ElementRectangle } from "../types/bidi-modules/br
|
|
|
3
3
|
import { type HelperInstaller } from "../script/helpers.js";
|
|
4
4
|
import { ElementHandle } from "../script/element-handle.js";
|
|
5
5
|
import { type ElementScreenshotOptions } from "../browser/screenshot.js";
|
|
6
|
+
import { type FileInput } from "../browser/generated-files.js";
|
|
6
7
|
import { type WaitForState } from "./element-state.js";
|
|
7
8
|
import { type Selector, type TextOptions } from "./selector.js";
|
|
8
9
|
export type { WaitForState } from "./element-state.js";
|
|
@@ -116,14 +117,25 @@ export default class Locator {
|
|
|
116
117
|
*/
|
|
117
118
|
clickRect(rect: Pick<ElementRectangle, "x" | "y" | "width" | "height">): Promise<void>;
|
|
118
119
|
/**
|
|
119
|
-
* Sets the files of an `<input type=file
|
|
120
|
-
*
|
|
120
|
+
* Sets the files of an `<input type=file>`; `[]` clears it. Paths are relative to the project folder (absolute
|
|
121
|
+
* ones are used as they are); `generatedFile(…)` writes a file of a given size when the step runs.
|
|
121
122
|
* Waits for the input to be attached and enabled; it need not be visible, file inputs are often hidden behind a label.
|
|
122
123
|
* @example
|
|
123
|
-
* await page.
|
|
124
|
-
* await page.
|
|
124
|
+
* await page.getByLabel("Avatar").setInputFiles("files/avatar.png");
|
|
125
|
+
* await page.getByLabel("Upload").setInputFiles(generatedFile({ name: "huge.pdf", size: "60 MB" }));
|
|
125
126
|
*/
|
|
126
|
-
setInputFiles(files:
|
|
127
|
+
setInputFiles(files: FileInput | FileInput[], options?: {
|
|
128
|
+
timeout?: number;
|
|
129
|
+
}): Promise<void>;
|
|
130
|
+
/**
|
|
131
|
+
* Drops files on the element, as dragging them from the desktop does: `dragenter`, `dragover` and `drop`
|
|
132
|
+
* with a `DataTransfer` holding the files. Takes what `setInputFiles` takes. The events are synthetic
|
|
133
|
+
* (`isTrusted` is false); a page that only accepts trusted drops ignores them.
|
|
134
|
+
* Waits for the element to be attached and visible.
|
|
135
|
+
* @example
|
|
136
|
+
* await page.getByTestId("dropzone").dropFiles(["files/a.png", "files/b.png"]);
|
|
137
|
+
*/
|
|
138
|
+
dropFiles(files: FileInput | FileInput[], options?: {
|
|
127
139
|
timeout?: number;
|
|
128
140
|
}): Promise<void>;
|
|
129
141
|
/**
|
package/dist/locator/locator.js
CHANGED
|
@@ -3,7 +3,7 @@ import { callFunction, ScriptError } from "../script/call-function.js";
|
|
|
3
3
|
import { CALL_PROBE, HELPER_SANDBOX, HELPERS_MISSING, } from "../script/helpers.js";
|
|
4
4
|
import { ElementHandle } from "../script/element-handle.js";
|
|
5
5
|
import { takeScreenshot, } from "../browser/screenshot.js";
|
|
6
|
-
import {
|
|
6
|
+
import { isGeneratedFile, resolveFileInputs, } from "../browser/generated-files.js";
|
|
7
7
|
import { resolveTimeout, waitUntil, WaitTimeoutError, } from "../wait/wait-until.js";
|
|
8
8
|
import { ActionTimeoutError } from "./action-timeout-error.js";
|
|
9
9
|
import { allPass, DETACHED_STATE, evaluateChecks, parseElementState, PROBE_ELEMENT, } from "./element-state.js";
|
|
@@ -27,6 +27,31 @@ const FILE_INPUT_INFO = `(el) => ({
|
|
|
27
27
|
multiple: Boolean(el.multiple),
|
|
28
28
|
})`;
|
|
29
29
|
const FILE_INPUT_CHECKS = ["attached", "enabled"];
|
|
30
|
+
const DROP_CHECKS = ["attached", "visible"];
|
|
31
|
+
/** The sandbox `dropFiles` builds its files in */
|
|
32
|
+
const DROP_SANDBOX = "samurai-drop";
|
|
33
|
+
const DROP_KEY = `globalThis[Symbol.for("samurai.drop")]`;
|
|
34
|
+
const DROP_INPUT = `() => {
|
|
35
|
+
const input = document.createElement("input");
|
|
36
|
+
input.type = "file";
|
|
37
|
+
input.multiple = true;
|
|
38
|
+
${DROP_KEY} = input;
|
|
39
|
+
return input;
|
|
40
|
+
}`;
|
|
41
|
+
/** Fires dragenter, dragover and drop at the element's centre with the input's files (retyped where asked) */
|
|
42
|
+
const DROP_EVENTS = `(el, types) => {
|
|
43
|
+
const input = ${DROP_KEY};
|
|
44
|
+
delete ${DROP_KEY};
|
|
45
|
+
const data = new DataTransfer();
|
|
46
|
+
Array.from(input.files).forEach((file, i) => {
|
|
47
|
+
data.items.add(types[i] ? new File([file], file.name, { type: types[i], lastModified: file.lastModified }) : file);
|
|
48
|
+
});
|
|
49
|
+
const box = el.getBoundingClientRect();
|
|
50
|
+
const at = { clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 };
|
|
51
|
+
for (const type of ["dragenter", "dragover", "drop"]) {
|
|
52
|
+
el.dispatchEvent(new DragEvent(type, Object.assign({ bubbles: true, cancelable: true, composed: true, dataTransfer: data }, at)));
|
|
53
|
+
}
|
|
54
|
+
}`;
|
|
30
55
|
const SCREENSHOT_CHECKS = ["attached", "visible"];
|
|
31
56
|
const CLICK_CHECKS = [
|
|
32
57
|
"attached",
|
|
@@ -410,17 +435,15 @@ export default class Locator {
|
|
|
410
435
|
});
|
|
411
436
|
}
|
|
412
437
|
/**
|
|
413
|
-
* Sets the files of an `<input type=file
|
|
414
|
-
*
|
|
438
|
+
* Sets the files of an `<input type=file>`; `[]` clears it. Paths are relative to the project folder (absolute
|
|
439
|
+
* ones are used as they are); `generatedFile(…)` writes a file of a given size when the step runs.
|
|
415
440
|
* Waits for the input to be attached and enabled; it need not be visible, file inputs are often hidden behind a label.
|
|
416
441
|
* @example
|
|
417
|
-
* await page.
|
|
418
|
-
* await page.
|
|
442
|
+
* await page.getByLabel("Avatar").setInputFiles("files/avatar.png");
|
|
443
|
+
* await page.getByLabel("Upload").setInputFiles(generatedFile({ name: "huge.pdf", size: "60 MB" }));
|
|
419
444
|
*/
|
|
420
445
|
async setInputFiles(files, options) {
|
|
421
|
-
const paths =
|
|
422
|
-
for (const file of paths)
|
|
423
|
-
await assertIsFile(file, "setInputFiles");
|
|
446
|
+
const paths = await resolveFileInputs(files, "setInputFiles");
|
|
424
447
|
await this.waitForActionable("setInputFiles", FILE_INPUT_CHECKS, { scroll: false, hitTest: false }, options?.timeout === undefined ? undefined : { timeout: options.timeout });
|
|
425
448
|
const missing = () => new Error(`Failed to locate element ${this.selector}. Details: no element matches`);
|
|
426
449
|
const el = await this.resolveOne();
|
|
@@ -450,6 +473,31 @@ export default class Locator {
|
|
|
450
473
|
files: paths,
|
|
451
474
|
});
|
|
452
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* Drops files on the element, as dragging them from the desktop does: `dragenter`, `dragover` and `drop`
|
|
478
|
+
* with a `DataTransfer` holding the files. Takes what `setInputFiles` takes. The events are synthetic
|
|
479
|
+
* (`isTrusted` is false); a page that only accepts trusted drops ignores them.
|
|
480
|
+
* Waits for the element to be attached and visible.
|
|
481
|
+
* @example
|
|
482
|
+
* await page.getByTestId("dropzone").dropFiles(["files/a.png", "files/b.png"]);
|
|
483
|
+
*/
|
|
484
|
+
async dropFiles(files, options) {
|
|
485
|
+
const list = Array.isArray(files) ? files : [files];
|
|
486
|
+
const paths = await resolveFileInputs(list, "dropFiles");
|
|
487
|
+
const types = list.map((file) => isGeneratedFile(file) ? (file.type ?? "") : "");
|
|
488
|
+
await this.waitForActionable("dropFiles", DROP_CHECKS, { scroll: true, hitTest: false }, options?.timeout === undefined ? undefined : { timeout: options.timeout });
|
|
489
|
+
const el = await this.resolveOne();
|
|
490
|
+
if (!el)
|
|
491
|
+
throw new Error(`Failed to locate element ${this.selector}. Details: no element matches`);
|
|
492
|
+
// A file input of our own, made in a sandbox and never attached: the page doesn't see it
|
|
493
|
+
const input = await this.call(DROP_INPUT, [], DROP_SANDBOX);
|
|
494
|
+
await this.biDiConnector.send("input.setFiles", {
|
|
495
|
+
context: this.contextId,
|
|
496
|
+
element: { sharedId: input.sharedId },
|
|
497
|
+
files: paths,
|
|
498
|
+
});
|
|
499
|
+
await this.call(DROP_EVENTS, [el, types], DROP_SANDBOX);
|
|
500
|
+
}
|
|
453
501
|
/**
|
|
454
502
|
* Takes a screenshot of just this element, after scrolling it into view and waiting until it is attached and
|
|
455
503
|
* visible. Returns the image (also written to `options.path`).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Files bigger than this aren't stored while recording */
|
|
2
|
+
export declare const RECORD_LIMIT: number;
|
|
3
|
+
/** A picked file's name as a file name in the files folder: no folders, control characters or leading dots */
|
|
4
|
+
export declare function cleanFileName(name: string): string;
|
|
5
|
+
/** The files folder: absolute, and relative to the project with forward slashes */
|
|
6
|
+
export declare function filesFolder(projectDir: string, filesDir?: string): {
|
|
7
|
+
absolute: string;
|
|
8
|
+
relative: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Stores the bytes in `content` in the files folder under their own (cleaned) name, so the page sees at
|
|
12
|
+
* replay the name it saw while recording. If the name is free the file is copied there; if a file of that
|
|
13
|
+
* name holds the same content it is reused. If the name is taken by other content, the file goes in a
|
|
14
|
+
* folder named after the first 8 hex characters of its SHA-256 (`<H>/<name>`; 16, then all 64, on a
|
|
15
|
+
* collision). Different names with the same content are stored separately. Nothing is overwritten and
|
|
16
|
+
* nothing is written outside the folder. `content` is left in place. Returns the project-relative path.
|
|
17
|
+
*/
|
|
18
|
+
export declare function storeFile(options: {
|
|
19
|
+
projectDir: string;
|
|
20
|
+
filesDir?: string;
|
|
21
|
+
name: string;
|
|
22
|
+
content: string;
|
|
23
|
+
}): Promise<{
|
|
24
|
+
path: string;
|
|
25
|
+
reused: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
/** Whether the project's .gitignore ignores the folder (`files`, `files/`, `/files`, `files/*`, `files/**`) */
|
|
28
|
+
export declare function isFolderIgnored(projectDir: string, relative: string): Promise<boolean>;
|