@itmetsam/samurai-framework 0.12.1 → 0.15.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 (41) hide show
  1. package/README.md +29 -29
  2. package/dist/api.d.ts +2 -2
  3. package/dist/api.js +1 -1
  4. package/dist/cli/console-reporter.js +6 -2
  5. package/dist/cli/main.js +1 -1
  6. package/dist/config/config.js +3 -0
  7. package/dist/config/forbid-only.d.ts +5 -0
  8. package/dist/config/forbid-only.js +9 -0
  9. package/dist/flows/run/project.js +22 -9
  10. package/dist/logger/index.d.ts +6 -0
  11. package/dist/logger/index.js +47 -6
  12. package/dist/runner/all-hooks.d.ts +26 -0
  13. package/dist/runner/all-hooks.js +61 -0
  14. package/dist/runner/focus.d.ts +5 -0
  15. package/dist/runner/focus.js +19 -0
  16. package/dist/runner/hooks.d.ts +24 -0
  17. package/dist/runner/hooks.js +79 -0
  18. package/dist/runner/pooled-run.d.ts +7 -1
  19. package/dist/runner/pooled-run.js +38 -10
  20. package/dist/runner/project.js +5 -0
  21. package/dist/runner/registry.d.ts +35 -4
  22. package/dist/runner/registry.js +66 -11
  23. package/dist/runner/reporter.d.ts +8 -0
  24. package/dist/runner/reporter.js +22 -1
  25. package/dist/runner/run-test.d.ts +6 -1
  26. package/dist/runner/run-test.js +27 -1
  27. package/dist/runner/run.d.ts +4 -2
  28. package/dist/runner/run.js +5 -1
  29. package/dist/runner/skip.d.ts +10 -0
  30. package/dist/runner/skip.js +18 -0
  31. package/dist/runner/suite.d.ts +24 -0
  32. package/dist/runner/suite.js +73 -0
  33. package/dist/runner/test-error.js +4 -0
  34. package/dist/runner/test-runner.js +43 -18
  35. package/dist/runner/worker-pool.d.ts +8 -1
  36. package/dist/runner/worker-pool.js +34 -2
  37. package/dist/runner/worker-session.js +11 -1
  38. package/dist/steps/parse.js +16 -0
  39. package/dist/types/config.d.ts +8 -1
  40. package/dist/types/test.d.ts +43 -1
  41. package/package.json +4 -4
package/README.md CHANGED
@@ -19,12 +19,12 @@ test("Visitor can sign in", async ({ page, env, secrets }) => {
19
19
  - **Auto-waiting.** Actions wait until the element is attached, visible, stable, enabled and not covered. Assertions retry until they pass or time out.
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
- - **Parallel runs.** Tests run side by side in worker processes (`--workers`, `workers` in the config; half the CPUs by default), and a flow's Parallel node runs its branches at the same time (see [Running tests and reports](docs/running-and-reports.md#parallel-runs)).
22
+ - **Parallel runs.** Tests run side by side in worker processes (`--workers`, `workers` in the config; half the CPUs by default), and a flow's Parallel node runs its branches at the same time (see [Running tests and reports](https://github.com/degrootsam/samurai-framework/wiki/Running-tests-and-reports#parallel-runs)).
23
23
  - **Network control.** Mock, block or modify requests; wait for requests, responses and network idle; read response bodies.
24
- - **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)).
25
- - **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
+ - **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](https://github.com/degrootsam/samurai-framework/wiki/Flows)).
25
+ - **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](https://github.com/degrootsam/samurai-framework/wiki/Steps-and-the-recorder)). The Electron app that ties them together lives in a separate repository (see the [roadmap](https://github.com/degrootsam/samurai-framework/wiki/Roadmap)).
26
26
 
27
- > **Status:** early. Firefox only. See [what is still open](docs/roadmap.md).
27
+ > **Status:** early. Firefox only. See [what is still open](https://github.com/degrootsam/samurai-framework/wiki/Roadmap).
28
28
 
29
29
  ## Getting started
30
30
 
@@ -39,14 +39,14 @@ npx samurai browsers install firefox # once per machine
39
39
  npx samurai run
40
40
  ```
41
41
 
42
- Specs and the config then import from `@itmetsam/samurai-framework` instead of the relative paths used below. See [Command line](docs/cli.md).
42
+ Specs and the config then import from `@itmetsam/samurai-framework` instead of the relative paths used below. See [Command line](https://github.com/degrootsam/samurai-framework/wiki/Command-line).
43
43
 
44
44
  The rest of this section works in a checkout of this repository.
45
45
 
46
46
  ### 1. Requirements
47
47
 
48
48
  - [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
49
- - 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.
49
+ - 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](https://github.com/degrootsam/samurai-framework/wiki/Pages-and-browsers#which-browser-runs)). The Firefox installed on the machine is not used.
50
50
 
51
51
  ### 2. Install
52
52
 
@@ -103,13 +103,13 @@ bun run dev --env production # another environment (or SAMURAI_ENV=production)
103
103
  bun run dev --timeout 60000 # per-test timeout in ms; also --expect-timeout
104
104
  ```
105
105
 
106
- `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).
106
+ `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](https://github.com/degrootsam/samurai-framework/wiki/Command-line).
107
107
 
108
- 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/`.
108
+ A browser window opens for each test and closes when the test ends (`--headless` hides it; on CI it is the default, see [Configuration](https://github.com/degrootsam/samurai-framework/wiki/Configuration#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/`.
109
109
 
110
110
  ### Flows (optional)
111
111
 
112
- A project can also keep flows in `flows/*.flow.json` (edited in the Samurai app) that chain tests and groups with conditions and variables. `samurai flow check` validates them and `samurai flow run <flow>` runs them, with a report in `result/flows/`. See [Flows](docs/flows.md).
112
+ A project can also keep flows in `flows/*.flow.json` (edited in the Samurai app) that chain tests and groups with conditions and variables. `samurai flow check` validates them and `samurai flow run <flow>` runs them, with a report in `result/flows/`. See [Flows](https://github.com/degrootsam/samurai-framework/wiki/Flows).
113
113
 
114
114
  ### 6. Secrets (optional)
115
115
 
@@ -119,29 +119,29 @@ cp .env.example .env.staging
119
119
  # SAMURAI_SECRET_TEST_PASSWORD=...
120
120
  ```
121
121
 
122
- Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win over the file, and `.env.*` files are git-ignored. See [Environments and secrets](docs/environments-and-secrets.md).
122
+ Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win over the file, and `.env.*` files are git-ignored. See [Environments and secrets](https://github.com/degrootsam/samurai-framework/wiki/Environments-and-secrets).
123
123
 
124
124
  ## Documentation
125
125
 
126
- | Guide | What it covers |
127
- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
128
- | [Writing tests](docs/writing-tests.md) | `test`, `describe`, fixtures, how a test passes or fails |
129
- | [Locators](docs/locators.md) | Finding elements, chaining, fallbacks, actions and their auto-waiting |
130
- | [Assertions](docs/assertions.md) | `expect` for elements and for plain values |
131
- | [Pages and browsers](docs/pages-and-browsers.md) | Navigation, waits, screenshots, PDF, viewport, emulation, dialogs, downloads, contexts, cookies |
132
- | [Network](docs/network.md) | Waiting for traffic, mocking, blocking, response bodies |
133
- | [Configuration](docs/configuration.md) | Every option in `samurai.config.ts`, CLI flags and precedence |
134
- | [Environments and secrets](docs/environments-and-secrets.md) | Per-environment settings, variables, secrets and masking |
135
- | [Command line](docs/cli.md) | `samurai run`, `list`, `record` and `init`, options, output, exit codes, `--json` |
136
- | [Flows](docs/flows.md) | `samurai flow list`, `check` and `run`: graphs of tests, groups and steps, reports, CI |
137
- | [Writing a step](docs/steps.md) | `defineStep`: flow steps of your own, the built-in steps |
138
- | [Running tests and reports](docs/running-and-reports.md) | What a run does, `report.json`, page logs, timeouts |
139
- | [Steps and the recorder](docs/recorder.md) | The step codec, the recording engine and the recorder toolbar |
140
- | [Embedding the framework](docs/embedding.md) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
141
- | [Contributing](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
142
- | [Roadmap](docs/roadmap.md) | What is built and what is still open |
143
-
144
- The [documentation index](docs/README.md) lists every guide by topic.
126
+ | Guide | What it covers |
127
+ | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
128
+ | [Writing tests](https://github.com/degrootsam/samurai-framework/wiki/Writing-tests) | `test`, `describe`, fixtures, how a test passes or fails |
129
+ | [Locators](https://github.com/degrootsam/samurai-framework/wiki/Locators) | Finding elements, chaining, fallbacks, actions and their auto-waiting |
130
+ | [Assertions](https://github.com/degrootsam/samurai-framework/wiki/Assertions) | `expect` for elements and for plain values |
131
+ | [Pages and browsers](https://github.com/degrootsam/samurai-framework/wiki/Pages-and-browsers) | Navigation, waits, screenshots, PDF, viewport, emulation, dialogs, downloads, contexts, cookies |
132
+ | [Network](https://github.com/degrootsam/samurai-framework/wiki/Network) | Waiting for traffic, mocking, blocking, response bodies |
133
+ | [Configuration](https://github.com/degrootsam/samurai-framework/wiki/Configuration) | Every option in `samurai.config.ts`, CLI flags and precedence |
134
+ | [Environments and secrets](https://github.com/degrootsam/samurai-framework/wiki/Environments-and-secrets) | Per-environment settings, variables, secrets and masking |
135
+ | [Command line](https://github.com/degrootsam/samurai-framework/wiki/Command-line) | `samurai run`, `list`, `record` and `init`, options, output, exit codes, `--json` |
136
+ | [Flows](https://github.com/degrootsam/samurai-framework/wiki/Flows) | `samurai flow list`, `check` and `run`: graphs of tests, groups and steps, reports, CI |
137
+ | [Writing a step](https://github.com/degrootsam/samurai-framework/wiki/Writing-a-step) | `defineStep`: flow steps of your own, the built-in steps |
138
+ | [Running tests and reports](https://github.com/degrootsam/samurai-framework/wiki/Running-tests-and-reports) | What a run does, `report.json`, page logs, timeouts |
139
+ | [Steps and the recorder](https://github.com/degrootsam/samurai-framework/wiki/Steps-and-the-recorder) | The step codec, the recording engine and the recorder toolbar |
140
+ | [Embedding the framework](https://github.com/degrootsam/samurai-framework/wiki/Embedding-the-framework) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
141
+ | [Contributing](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
142
+ | [Roadmap](https://github.com/degrootsam/samurai-framework/wiki/Roadmap) | What is built and what is still open |
143
+
144
+ The [documentation index](https://github.com/degrootsam/samurai-framework/wiki/Home) lists every guide by topic.
145
145
 
146
146
  ## Scripts
147
147
 
package/dist/api.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export { test, describe } from "./runner/registry.js";
1
+ export { test, describe, beforeEach, afterEach, beforeAll, afterAll, } from "./runner/registry.js";
2
2
  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
- export type { TestFixtures } from "./types/test.js";
5
+ export type { AllHookFixtures, TestFixtures } from "./types/test.js";
6
6
  export { generatedFile, type GeneratedFile, type FileInput, } from "./browser/generated-files.js";
package/dist/api.js CHANGED
@@ -1,4 +1,4 @@
1
- export { test, describe } from "./runner/registry.js";
1
+ export { test, describe, beforeEach, afterEach, beforeAll, afterAll, } 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";
@@ -32,12 +32,15 @@ export class ConsoleReporter {
32
32
  const took = "duration" in result
33
33
  ? ` ${this.paint(90, `(${seconds(result.duration)})`)}`
34
34
  : "";
35
+ if (result.status === "skipped")
36
+ return this.paint(90, `- ${name}${this.tag(browser)} (${result.annotation === "fixme" ? "fixme" : "skipped"}${result.reason ? `: ${result.reason}` : ""})`);
35
37
  return result.status === "success"
36
38
  ? `${this.paint(32, "✔")} ${name}${this.tag(browser)}${took}`
37
39
  : `${this.paint(31, "✖")} ${name}${this.tag(browser)}${took}`;
38
40
  }
39
41
  summary(summary) {
40
- const failed = summary.tests.filter((test) => test.status !== "success");
42
+ const skipped = summary.tests.filter((test) => test.status === "skipped");
43
+ const failed = summary.tests.filter((test) => test.status !== "success" && test.status !== "skipped");
41
44
  for (const test of failed) {
42
45
  if (!("name" in test))
43
46
  continue;
@@ -50,10 +53,11 @@ export class ConsoleReporter {
50
53
  if ("file" in test)
51
54
  this.write(` ${this.paint(90, test.file)}\n`);
52
55
  }
53
- const passed = summary.tests.length - failed.length;
56
+ const passed = summary.tests.length - failed.length - skipped.length;
54
57
  const parts = [
55
58
  failed.length > 0 && this.paint(31, `${failed.length} failed`),
56
59
  this.paint(32, `${passed} passed`),
60
+ skipped.length > 0 && this.paint(90, `${skipped.length} skipped`),
57
61
  ].filter(Boolean);
58
62
  this.write(`\n${parts.join(", ")} ${this.paint(90, `(${seconds(summary.duration)})`)}\n`);
59
63
  }
package/dist/cli/main.js CHANGED
@@ -59,7 +59,7 @@ export async function main(argv, io) {
59
59
  io.out(`${JSON.stringify(tests, null, 2)}\n`);
60
60
  else
61
61
  for (const found of tests)
62
- io.out(`${found.name}\n ${found.file}\n`);
62
+ io.out(`${found.name}${found.modifier ? ` [${found.modifier}]` : ""}\n ${found.file}\n`);
63
63
  return 0;
64
64
  }
65
65
  const human = values.json
@@ -2,6 +2,7 @@ import path from "path";
2
2
  import logger from "../logger/index.js";
3
3
  import { pathToFileURL } from "url";
4
4
  import { validateBrowserConfig } from "./browsers.js";
5
+ import { validateForbidOnlyConfig } from "./forbid-only.js";
5
6
  import { validateWorkersConfig } from "./workers.js";
6
7
  let activeProject;
7
8
  /** Makes `project` the one config, secrets, reports and downloads are resolved for; `undefined` ends it */
@@ -27,6 +28,7 @@ export async function loadConfig() {
27
28
  if (activeProject?.config) {
28
29
  validateBrowserConfig(activeProject.config);
29
30
  validateWorkersConfig(activeProject.config);
31
+ validateForbidOnlyConfig(activeProject.config);
30
32
  return activeProject.config;
31
33
  }
32
34
  const configUrl = path.join(projectDir(), "samurai.config.ts");
@@ -37,6 +39,7 @@ export async function loadConfig() {
37
39
  }
38
40
  validateBrowserConfig(config.default);
39
41
  validateWorkersConfig(config.default);
42
+ validateForbidOnlyConfig(config.default);
40
43
  return config.default;
41
44
  }
42
45
  /** A project's config without keeping the project active (`withProject` is one at a time): the given one, else its samurai.config.ts */
@@ -0,0 +1,5 @@
1
+ import type { SamuraiTestConfig } from "../types/config.js";
2
+ /** Checks the `forbidOnly` key of a config. Throws an Error that names the key */
3
+ export declare function validateForbidOnlyConfig(config: SamuraiTestConfig): void;
4
+ /** Whether `test.only` is refused: the config's `forbidOnly`, else on when `CI` is set */
5
+ export declare function forbidsOnly(config: SamuraiTestConfig, env?: Partial<NodeJS.ProcessEnv>): boolean;
@@ -0,0 +1,9 @@
1
+ /** Checks the `forbidOnly` key of a config. Throws an Error that names the key */
2
+ export function validateForbidOnlyConfig(config) {
3
+ if (config.forbidOnly !== undefined && typeof config.forbidOnly !== "boolean")
4
+ throw new Error(`forbidOnly must be true or false, got "${String(config.forbidOnly)}"`);
5
+ }
6
+ /** Whether `test.only` is refused: the config's `forbidOnly`, else on when `CI` is set */
7
+ export function forbidsOnly(config, env = process.env) {
8
+ return config.forbidOnly ?? Boolean(env.CI);
9
+ }
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { projectConfig } from "../../config/config.js";
3
3
  import { checkFlow, withKeys, } from "../core/index.js";
4
4
  import { DEFAULT_SRC_DIR, realPath, resolveGroups, testId, toPosix, } from "../../runner/groups.js";
5
- import logger from "../../logger/index.js";
5
+ import logger, { keepLogFiles } from "../../logger/index.js";
6
6
  import { resolveWorkers } from "../../runner/workers-option.js";
7
7
  import { withProject } from "../../runner/project.js";
8
8
  import { browsersFor } from "../../runner/browsers.js";
@@ -56,7 +56,10 @@ async function runOnPool(pool, items, onEvent, signal) {
56
56
  });
57
57
  if (signal.aborted)
58
58
  return "cancelled";
59
- return results.every((r) => r.status === "success") ? "passed" : "failed";
59
+ // A skipped test is not a failure
60
+ return results.every((r) => r.status === "success" || r.status === "skipped")
61
+ ? "passed"
62
+ : "failed";
60
63
  }
61
64
  /** The tests, and the browsers, of each Test and Group node of a flow; a Test node uses the project's browsers */
62
65
  export function nodeTestsOf(flow, tests, groups) {
@@ -152,13 +155,7 @@ export async function checkFlowInProject(options) {
152
155
  const { flow, problems } = await prepare(options);
153
156
  return { flow, problems };
154
157
  }
155
- /**
156
- * Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
157
- * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
158
- * its tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
159
- * One at a time per process.
160
- */
161
- export async function runFlowInProject(options) {
158
+ async function walkFlowInProject(options) {
162
159
  const { projectDir, flow, unsupported, config, steps, name, environment, tests, groups, problems, } = await prepare(options);
163
160
  const errors = problems.filter((p) => p.level === "error");
164
161
  if (errors.length)
@@ -250,3 +247,19 @@ export async function runFlowInProject(options) {
250
247
  await pool?.dispose();
251
248
  }
252
249
  }
250
+ /**
251
+ * Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
252
+ * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
253
+ * its tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
254
+ * One at a time per process.
255
+ */
256
+ export async function runFlowInProject(options) {
257
+ // One log for the whole run, across the project sessions it opens one after the other
258
+ const releaseLogs = keepLogFiles(path.resolve(options.projectDir ?? process.cwd()));
259
+ try {
260
+ return await walkFlowInProject(options);
261
+ }
262
+ finally {
263
+ releaseLogs();
264
+ }
265
+ }
@@ -5,4 +5,10 @@ export declare function consoleLine(entry: {
5
5
  meta?: Record<string, unknown> | undefined;
6
6
  }): string;
7
7
  declare const logger: import("winston").Logger;
8
+ /**
9
+ * Writes the log to `error.log` and `combined.log` in `<projectDir>/logs/` until the returned function is called.
10
+ * Nothing is written to disk before a project is in use, so a command that has none (`samurai --version`) leaves
11
+ * no folder behind. Holders of the same project share the files; the files close with the last one.
12
+ */
13
+ export declare function keepLogFiles(projectDir: string): () => void;
8
14
  export default logger;
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { createLogger, format, transports } from "winston";
2
3
  import { maskFormat } from "../config/mask.js";
3
4
  /** Metadata every line carries (`defaultMeta`): not worth printing on each console line */
@@ -15,7 +16,7 @@ const logger = createLogger({
15
16
  }), format.errors({ stack: true }), format.splat(), format.metadata({ key: "meta" }), maskFormat(), format.json({ space: 2 })),
16
17
  defaultMeta: { service: "samurai-framework" },
17
18
  transports: [
18
- // The console shows the log; `logs/error.log` keeps errors and `logs/combined.log` everything
19
+ // The console shows the log; the files of a project's `logs/` folder are added while a project is in use (`keepLogFiles`)
19
20
  new transports.Console({
20
21
  // The `samurai` command quiets this to "warn"; the log files keep everything
21
22
  level: process.env.SAMURAI_LOG_LEVEL ?? "debug",
@@ -27,11 +28,51 @@ const logger = createLogger({
27
28
  meta: meta,
28
29
  }))),
29
30
  }),
30
- new transports.File({
31
- filename: `logs/error.log`,
32
- level: "error",
33
- }),
34
- new transports.File({ filename: `logs/combined.log` }),
35
31
  ],
36
32
  });
33
+ /** The files the log is written to now: `error.log` keeps errors, `combined.log` everything */
34
+ let files;
35
+ /**
36
+ * Writes the log to `error.log` and `combined.log` in `<projectDir>/logs/` until the returned function is called.
37
+ * Nothing is written to disk before a project is in use, so a command that has none (`samurai --version`) leaves
38
+ * no folder behind. Holders of the same project share the files; the files close with the last one.
39
+ */
40
+ export function keepLogFiles(projectDir) {
41
+ const dir = path.join(projectDir, "logs");
42
+ if (files && files.dir !== dir)
43
+ closeFiles();
44
+ files ??= {
45
+ dir,
46
+ transports: [
47
+ new transports.File({
48
+ filename: path.join(dir, "error.log"),
49
+ level: "error",
50
+ }),
51
+ new transports.File({ filename: path.join(dir, "combined.log") }),
52
+ ],
53
+ holders: 0,
54
+ };
55
+ const mine = files;
56
+ if (mine.holders === 0)
57
+ for (const t of mine.transports)
58
+ logger.add(t);
59
+ mine.holders++;
60
+ let released = false;
61
+ return () => {
62
+ if (released)
63
+ return;
64
+ released = true;
65
+ if (--mine.holders === 0 && files === mine)
66
+ closeFiles();
67
+ };
68
+ }
69
+ function closeFiles() {
70
+ if (!files)
71
+ return;
72
+ for (const t of files.transports) {
73
+ logger.remove(t);
74
+ t.close?.();
75
+ }
76
+ files = undefined;
77
+ }
37
78
  export default logger;
@@ -0,0 +1,26 @@
1
+ import type { SupportedBrowser } from "../types/browser.js";
2
+ import type { AllHookFixtures, RegisteredTestCase } from "../types/test.js";
3
+ import { type HookError } from "./hooks.js";
4
+ export interface AllHooksOptions {
5
+ /** The limit of each beforeAll / afterAll hook, in ms */
6
+ timeout: number;
7
+ /** The fixtures the hooks of a browser get */
8
+ fixtures(browser: SupportedBrowser): AllHookFixtures;
9
+ }
10
+ /**
11
+ * The `beforeAll` and `afterAll` hooks of one worker: each runs once per suite and browser, `beforeAll` when the
12
+ * first test of the suite arrives and `afterAll` when the worker is done
13
+ */
14
+ export declare class AllHooks {
15
+ private readonly options;
16
+ private readonly scopes;
17
+ /** In the order the suites were entered */
18
+ private readonly entered;
19
+ constructor(options: AllHooksOptions);
20
+ /** Runs the `beforeAll` hooks of the suites around `test` that this worker has not run for `browser`. Rejects with the `HookError` of one that failed, now or earlier */
21
+ ensure(test: RegisteredTestCase, browser: SupportedBrowser): Promise<void>;
22
+ /** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
23
+ closeAll(): Promise<HookError[]>;
24
+ private enter;
25
+ private runBefore;
26
+ }
@@ -0,0 +1,61 @@
1
+ import { runHook } from "./hooks.js";
2
+ import { suiteChain } from "./suite.js";
3
+ /**
4
+ * The `beforeAll` and `afterAll` hooks of one worker: each runs once per suite and browser, `beforeAll` when the
5
+ * first test of the suite arrives and `afterAll` when the worker is done
6
+ */
7
+ export class AllHooks {
8
+ constructor(options) {
9
+ this.options = options;
10
+ this.scopes = new Map();
11
+ /** In the order the suites were entered */
12
+ this.entered = [];
13
+ }
14
+ /** Runs the `beforeAll` hooks of the suites around `test` that this worker has not run for `browser`. Rejects with the `HookError` of one that failed, now or earlier */
15
+ async ensure(test, browser) {
16
+ for (const suite of suiteChain(test.suite)) {
17
+ if (!suite.beforeAll?.length && !suite.afterAll?.length)
18
+ continue;
19
+ await this.enter(suite, browser).ready;
20
+ }
21
+ }
22
+ /** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
23
+ async closeAll() {
24
+ const errors = [];
25
+ const entered = this.entered.splice(0).reverse();
26
+ this.scopes.clear();
27
+ for (const { suite, browser, ready } of entered) {
28
+ await ready.catch(() => undefined);
29
+ for (const hook of suite.afterAll ?? []) {
30
+ try {
31
+ await runHook("afterAll", hook, this.options.fixtures(browser), this.options.timeout);
32
+ }
33
+ catch (error) {
34
+ errors.push(error);
35
+ }
36
+ }
37
+ }
38
+ return errors;
39
+ }
40
+ enter(suite, browser) {
41
+ const ofSuite = this.scopes.get(suite) ?? new Map();
42
+ this.scopes.set(suite, ofSuite);
43
+ const known = ofSuite.get(browser);
44
+ if (known)
45
+ return known;
46
+ const scope = {
47
+ suite,
48
+ browser,
49
+ ready: this.runBefore(suite, browser),
50
+ };
51
+ // Rejections are handled by the callers of `ensure`; one nobody waits for is not an error of the process
52
+ scope.ready.catch(() => undefined);
53
+ ofSuite.set(browser, scope);
54
+ this.entered.push(scope);
55
+ return scope;
56
+ }
57
+ async runBefore(suite, browser) {
58
+ for (const hook of suite.beforeAll ?? [])
59
+ await runHook("beforeAll", hook, this.options.fixtures(browser), this.options.timeout);
60
+ }
61
+ }
@@ -0,0 +1,5 @@
1
+ import type { RegisteredTestCase } from "../types/test.js";
2
+ /** `test.only` is in the specs and the config refuses it: one error that lists every focused test */
3
+ export declare function assertNoOnly(all: readonly RegisteredTestCase[], forbid: boolean, srcDir: string): void;
4
+ /** When any of the selected tests is focused, only the focused ones; the others are not run and not reported */
5
+ export declare function narrowToOnly(selected: RegisteredTestCase[]): RegisteredTestCase[];
@@ -0,0 +1,19 @@
1
+ import path from "path";
2
+ import { toPosix } from "./groups.js";
3
+ /** `test.only` is in the specs and the config refuses it: one error that lists every focused test */
4
+ export function assertNoOnly(all, forbid, srcDir) {
5
+ if (!forbid)
6
+ return;
7
+ const focused = all.filter((test) => test.modifier === "only");
8
+ if (focused.length === 0)
9
+ return;
10
+ throw new Error(focused
11
+ .map((test) => `test.only is not allowed (forbidOnly): ${toPosix(path.relative(srcDir, test.file))}: ${test.name}`)
12
+ .join("\n"));
13
+ }
14
+ /** When any of the selected tests is focused, only the focused ones; the others are not run and not reported */
15
+ export function narrowToOnly(selected) {
16
+ return selected.some((test) => test.modifier === "only")
17
+ ? selected.filter((test) => test.modifier === "only")
18
+ : selected;
19
+ }
@@ -0,0 +1,24 @@
1
+ import type { Suite, TestFixtures } from "../types/test.js";
2
+ import { type HookKind } from "./suite.js";
3
+ /** A hook threw or ran out of time: the message names the hook (`beforeEach hook failed: <message>`) */
4
+ export declare class HookError extends Error {
5
+ readonly kind: HookKind;
6
+ readonly cause: unknown;
7
+ constructor(kind: HookKind, cause: unknown);
8
+ }
9
+ /**
10
+ * Runs one hook with its fixtures and waits for it. Anything it throws, and a hook that is still running after
11
+ * `limitMs` (when given), becomes a `HookError` of that kind.
12
+ */
13
+ export declare function runHook<F>(kind: HookKind, hook: (fixtures: F) => Promise<void> | void, fixtures: F, limitMs?: number): Promise<void>;
14
+ /**
15
+ * Runs a test body between the `beforeEach` and `afterEach` hooks of its suites.
16
+ *
17
+ * - A failing `beforeEach` stops the other `beforeEach` hooks and the body; the `afterEach` hooks still run.
18
+ * - The `afterEach` hooks run when the body ended by itself (it passed, threw or skipped), not when `signal` says
19
+ * the test was cut short (timeout, abort, closed browser): the time or the page is gone. They all run, even
20
+ * after one fails.
21
+ * - What it throws: the `beforeEach` failure, else the body's error, else the first `afterEach` failure. A skip is
22
+ * overruled by a failing `afterEach`.
23
+ */
24
+ export declare function runWithEachHooks(suite: Suite, fixtures: TestFixtures, signal: AbortSignal, body: () => Promise<void>): Promise<void>;
@@ -0,0 +1,79 @@
1
+ import { isSkipSignal } from "./skip.js";
2
+ import { afterEachOf, beforeEachOf } from "./suite.js";
3
+ /** A hook threw or ran out of time: the message names the hook (`beforeEach hook failed: <message>`) */
4
+ export class HookError extends Error {
5
+ constructor(kind, cause) {
6
+ super(`${kind} hook failed: ${cause instanceof Error ? cause.message : String(cause)}`);
7
+ this.kind = kind;
8
+ this.cause = cause;
9
+ this.name = "HookError";
10
+ }
11
+ }
12
+ /**
13
+ * Runs one hook with its fixtures and waits for it. Anything it throws, and a hook that is still running after
14
+ * `limitMs` (when given), becomes a `HookError` of that kind.
15
+ */
16
+ export async function runHook(kind, hook, fixtures, limitMs) {
17
+ let timer;
18
+ try {
19
+ const running = Promise.resolve().then(() => hook(fixtures));
20
+ if (limitMs === undefined)
21
+ return await running;
22
+ await Promise.race([
23
+ running,
24
+ new Promise((_, reject) => {
25
+ timer = setTimeout(() => reject(new Error(`timed out after ${limitMs / 1000} seconds`)), limitMs);
26
+ }),
27
+ ]);
28
+ }
29
+ catch (error) {
30
+ // `test.skip(condition)` in a hook skips the test: it is not a failure of the hook
31
+ if (isSkipSignal(error))
32
+ throw error;
33
+ throw new HookError(kind, error);
34
+ }
35
+ finally {
36
+ clearTimeout(timer);
37
+ }
38
+ }
39
+ /**
40
+ * Runs a test body between the `beforeEach` and `afterEach` hooks of its suites.
41
+ *
42
+ * - A failing `beforeEach` stops the other `beforeEach` hooks and the body; the `afterEach` hooks still run.
43
+ * - The `afterEach` hooks run when the body ended by itself (it passed, threw or skipped), not when `signal` says
44
+ * the test was cut short (timeout, abort, closed browser): the time or the page is gone. They all run, even
45
+ * after one fails.
46
+ * - What it throws: the `beforeEach` failure, else the body's error, else the first `afterEach` failure. A skip is
47
+ * overruled by a failing `afterEach`.
48
+ */
49
+ export async function runWithEachHooks(suite, fixtures, signal, body) {
50
+ let primary;
51
+ let failed = false;
52
+ try {
53
+ for (const hook of beforeEachOf(suite))
54
+ await runHook("beforeEach", hook, fixtures);
55
+ await body();
56
+ }
57
+ catch (error) {
58
+ primary = error;
59
+ failed = true;
60
+ }
61
+ let afterError;
62
+ let afterFailed = false;
63
+ if (!signal.aborted) {
64
+ for (const hook of afterEachOf(suite)) {
65
+ try {
66
+ await runHook("afterEach", hook, fixtures);
67
+ }
68
+ catch (error) {
69
+ if (!afterFailed)
70
+ afterError = error;
71
+ afterFailed = true;
72
+ }
73
+ }
74
+ }
75
+ if (afterFailed && (!failed || isSkipSignal(primary)))
76
+ throw afterError;
77
+ if (failed)
78
+ throw primary;
79
+ }
@@ -27,6 +27,11 @@ export declare function planItems(selected: RegisteredTestCase[], browsers: Supp
27
27
  * and `configGroups` are the same list, resolved and as written.
28
28
  */
29
29
  export declare function serialKeyOf(groups: ResolvedGroup[], configGroups: SamuraiGroup[] | undefined, srcDir: string): (test: RegisteredTestCase) => string | undefined;
30
+ /**
31
+ * How many items can run at the same time: each item alone counts one, and the items of one serial key together
32
+ * count one. More workers than this would only sit idle.
33
+ */
34
+ export declare function concurrency(items: WorkItem[]): number;
30
35
  /**
31
36
  * How a run goes: through the pool, in this process, or in this process because the workers could not read the
32
37
  * config (it was given as an object; a worker reads `samurai.config.ts` from the project folder)
@@ -37,7 +42,8 @@ export declare function poolMode(input: {
37
42
  inlineConfig: boolean;
38
43
  }): "pool" | "in-process" | "inline-config";
39
44
  /**
40
- * Runs the planned items on the pool and reports them as they happen. An item the pool answers without events
45
+ * Runs the planned items on the pool and reports them as they happen. Tests that do not run (skip, fixme) are
46
+ * reported first, here, and never reach a worker. An item the pool answers without events
41
47
  * (a queued one cut short by an abort, or one whose worker died before it began) is reported here at the end.
42
48
  */
43
49
  export declare function runPooled(pool: PooledRunner, planned: PlannedTest[], items: WorkItem[], reporter: TestReporter, signal?: AbortSignal): Promise<void>;