@itmetsam/samurai-framework 0.7.1 → 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.
Files changed (51) hide show
  1. package/README.md +25 -6
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +1 -0
  4. package/dist/browser/file-chooser.d.ts +4 -3
  5. package/dist/browser/file-chooser.js +9 -8
  6. package/dist/browser/generated-files.d.ts +30 -0
  7. package/dist/browser/generated-files.js +116 -0
  8. package/dist/browser/page.d.ts +19 -0
  9. package/dist/browser/page.js +30 -0
  10. package/dist/cli/main.d.ts +1 -1
  11. package/dist/cli/main.js +20 -5
  12. package/dist/locator/locator.d.ts +17 -5
  13. package/dist/locator/locator.js +56 -8
  14. package/dist/project/files-store.d.ts +28 -0
  15. package/dist/project/files-store.js +106 -0
  16. package/dist/project/index.d.ts +1 -0
  17. package/dist/project/index.js +1 -0
  18. package/dist/recorder/apply.js +8 -6
  19. package/dist/recorder/files-capture.d.ts +26 -0
  20. package/dist/recorder/files-capture.js +92 -0
  21. package/dist/recorder/files-reader.d.ts +7 -0
  22. package/dist/recorder/files-reader.js +25 -0
  23. package/dist/recorder/index.d.ts +1 -1
  24. package/dist/recorder/index.js +1 -1
  25. package/dist/recorder/normalise.d.ts +26 -2
  26. package/dist/recorder/normalise.js +48 -1
  27. package/dist/recorder/recorder.d.ts +38 -0
  28. package/dist/recorder/recorder.js +160 -1
  29. package/dist/recorder/session.d.ts +14 -1
  30. package/dist/recorder/session.js +32 -6
  31. package/dist/recorder/toolbar/controller.d.ts +9 -0
  32. package/dist/recorder/toolbar/controller.js +22 -0
  33. package/dist/recorder/toolbar/protocol.d.ts +10 -0
  34. package/dist/recorder/toolbar/source.d.ts +1 -1
  35. package/dist/recorder/toolbar/source.js +35 -2
  36. package/dist/recorder/toolbar/styles.d.ts +1 -1
  37. package/dist/recorder/toolbar/styles.js +31 -0
  38. package/dist/runner/project.js +2 -0
  39. package/dist/steps/edit.js +38 -3
  40. package/dist/steps/emit.d.ts +3 -1
  41. package/dist/steps/emit.js +19 -0
  42. package/dist/steps/files.d.ts +8 -0
  43. package/dist/steps/files.js +33 -0
  44. package/dist/steps/index.d.ts +3 -2
  45. package/dist/steps/index.js +2 -1
  46. package/dist/steps/model.d.ts +28 -0
  47. package/dist/steps/parse.js +79 -0
  48. package/dist/types/config.d.ts +7 -2
  49. package/package.json +1 -1
  50. package/dist/browser/file-paths.d.ts +0 -4
  51. 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) 26 or newer and [Bun](https://bun.sh) 1.3 or newer (CI uses these versions)
32
- - Firefox, downloaded by the framework: run `npx samurai browsers install firefox` once in a project (`bun run dev browsers install firefox` in this checkout; see [Which browser runs](docs/pages-and-browsers.md#which-browser-runs)). The Firefox installed on the machine is not used.
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
- 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)).
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. 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/`.
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` | Runs the test suite (`tsx src/index.ts`) |
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
@@ -2,3 +2,4 @@ export { test, describe } from "./runner/registry.js";
2
2
  export { expect } from "./assert/expect.js";
3
3
  export { defineConfig } from "./config/config.js";
4
4
  export { defineStep, } from "./flows/steps/index.js";
5
+ export { generatedFile, } from "./browser/generated-files.js";
@@ -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). Paths resolve against the working directory and must exist on the
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: string | string[]): Promise<void>;
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 { assertIsFile, resolveFiles } from "./file-paths.js";
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). Paths resolve against the working directory and must exist on the
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?.sharedId;
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 = resolveFiles(files);
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(["input.fileDialogOpened"]);
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
+ }
@@ -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
@@ -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
@@ -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,13 +133,18 @@ 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 === "insert")
139
- io.out(` + ${stepToSource(event.step)}\n`);
140
- else
141
- io.out(` ~ ${stepToSource(event.step)}\n`);
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
  },
@@ -148,6 +154,15 @@ export async function main(argv, io) {
148
154
  if (result.unsetSecrets.length > 0) {
149
155
  io.out(`Set ${result.unsetSecrets.map((name) => `SAMURAI_SECRET_${name}`).join(", ")} (environment variable or .env.<environment>) before running it.\n`);
150
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`);
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>` (an empty list clears them); the browser fires `input` and `change`.
120
- * Paths are resolved against the working directory and must exist on the machine the browser runs on.
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.locator("input[@type='file']").setInputFiles("./fixtures/avatar.png");
124
- * await page.locator("input[@type='file']").setInputFiles([]);
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: string | string[], options?: {
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
  /**
@@ -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 { assertIsFile, resolveFiles } from "../browser/file-paths.js";
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>` (an empty list clears them); the browser fires `input` and `change`.
414
- * Paths are resolved against the working directory and must exist on the machine the browser runs on.
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.locator("input[@type='file']").setInputFiles("./fixtures/avatar.png");
418
- * await page.locator("input[@type='file']").setInputFiles([]);
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 = resolveFiles(files);
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>;