@itmetsam/samurai-framework 0.15.2 → 0.17.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 (60) hide show
  1. package/dist/browser/browser.js +2 -1
  2. package/dist/browser/page-network.js +3 -2
  3. package/dist/browser/page-settings.js +2 -5
  4. package/dist/browser/page.js +2 -1
  5. package/dist/browser/session.d.ts +1 -1
  6. package/dist/browser/session.js +1 -1
  7. package/dist/browser/viewport.d.ts +0 -5
  8. package/dist/browser/viewport.js +0 -2
  9. package/dist/capabilities.d.ts +36 -0
  10. package/dist/capabilities.js +34 -0
  11. package/dist/{runner/browsers.js → config/browsers-for.js} +1 -2
  12. package/dist/config/config.d.ts +5 -1
  13. package/dist/config/config.js +18 -12
  14. package/dist/config/defaults.d.ts +41 -0
  15. package/dist/config/defaults.js +38 -0
  16. package/dist/config/resolved-config.d.ts +134 -0
  17. package/dist/config/resolved-config.js +135 -0
  18. package/dist/config/run-settings.d.ts +2 -0
  19. package/dist/config/run-settings.js +2 -3
  20. package/dist/config/timeouts.js +1 -2
  21. package/dist/{runner → config}/workers-option.js +1 -1
  22. package/dist/flows/run/project-options.d.ts +2 -0
  23. package/dist/flows/run/project.js +11 -4
  24. package/dist/network/data-collector.js +1 -2
  25. package/dist/network/router.js +1 -2
  26. package/dist/project/files-store.js +2 -1
  27. package/dist/project/index.d.ts +1 -0
  28. package/dist/project/index.js +1 -0
  29. package/dist/recorder/session-browser.js +1 -1
  30. package/dist/recorder/session.d.ts +5 -2
  31. package/dist/recorder/session.js +18 -3
  32. package/dist/runner/groups.d.ts +4 -5
  33. package/dist/runner/groups.js +2 -5
  34. package/dist/runner/options.d.ts +7 -1
  35. package/dist/runner/output.d.ts +6 -0
  36. package/dist/runner/output.js +14 -0
  37. package/dist/runner/page-logs-report.js +6 -3
  38. package/dist/runner/project.js +2 -2
  39. package/dist/runner/retries-option.js +2 -1
  40. package/dist/runner/run-test.js +2 -5
  41. package/dist/runner/run.d.ts +12 -2
  42. package/dist/runner/run.js +21 -7
  43. package/dist/runner/select.js +4 -4
  44. package/dist/runner/test-dir.js +2 -1
  45. package/dist/runner/test-runner.d.ts +8 -2
  46. package/dist/runner/test-runner.js +16 -10
  47. package/dist/runner/worker-protocol.d.ts +2 -0
  48. package/dist/runner/worker-session.js +3 -0
  49. package/dist/steps/edit.d.ts +10 -0
  50. package/dist/steps/edit.js +4 -0
  51. package/dist/steps/index.d.ts +2 -2
  52. package/dist/steps/index.js +1 -1
  53. package/dist/steps/model.d.ts +17 -0
  54. package/dist/steps/parse.d.ts +6 -1
  55. package/dist/steps/parse.js +54 -10
  56. package/dist/transport/bidi-connection.d.ts +0 -2
  57. package/dist/transport/bidi-connection.js +1 -2
  58. package/package.json +6 -2
  59. /package/dist/{runner/browsers.d.ts → config/browsers-for.d.ts} +0 -0
  60. /package/dist/{runner → config}/workers-option.d.ts +0 -0
@@ -1,5 +1,5 @@
1
1
  import { availableParallelism } from "node:os";
2
- import { parseWorkers } from "../config/workers.js";
2
+ import { parseWorkers } from "./workers.js";
3
3
  /**
4
4
  * How many workers a run uses: the flag, else `SAMURAI_WORKERS`, else the config's `workers`, else half the CPUs.
5
5
  * A percentage is a share of the CPUs. At least 1.
@@ -20,6 +20,8 @@ export interface RunFlowInProjectOptions extends ProjectOptions {
20
20
  /** `"fail"` (default): a flow with a node that can't run is refused. `"skip"`: those nodes are skipped. */
21
21
  unsupported?: "fail" | "skip";
22
22
  port?: number;
23
+ /** The folder the tests' named screenshots go in, instead of `<project>/result` (see `runTests`). Relative to the project folder */
24
+ outputDir?: string;
23
25
  /**
24
26
  * How many worker processes the tests of the whole run share: a number, or a percentage like `"50%"`. Unset
25
27
  * takes `SAMURAI_WORKERS` (`workersEnv`), then the config's `workers`, then half the CPUs. 1 runs each Test
@@ -1,12 +1,13 @@
1
1
  import path from "node:path";
2
+ import { DEFAULT_SRC_DIR } from "../../config/defaults.js";
2
3
  import { projectConfig } from "../../config/config.js";
3
4
  import { checkFlow, withKeys, } from "../core/index.js";
4
- import { DEFAULT_SRC_DIR, realPath, resolveGroups, testId, toPosix, } from "../../runner/groups.js";
5
+ import { realPath, resolveGroups, testId, toPosix, } from "../../runner/groups.js";
5
6
  import logger, { keepLogFiles } from "../../logger/index.js";
6
7
  import { resolveRetries } from "../../runner/retries-option.js";
7
- import { resolveWorkers } from "../../runner/workers-option.js";
8
+ import { resolveWorkers } from "../../config/workers-option.js";
8
9
  import { withProject } from "../../runner/project.js";
9
- import { browsersFor } from "../../runner/browsers.js";
10
+ import { browsersFor } from "../../config/browsers-for.js";
10
11
  import { DEFAULT_PORT } from "../../runner/ports.js";
11
12
  import { runTests } from "../../runner/run.js";
12
13
  import TestRunner from "../../runner/test-runner.js";
@@ -84,7 +85,7 @@ async function prepare(options) {
84
85
  ...(options.dataDir !== undefined && { dataDir: options.dataDir }),
85
86
  environment: name,
86
87
  }, async (run) => {
87
- const found = await (await TestRunner.init(run)).register();
88
+ const { tests: found } = await (await TestRunner.init(run)).list();
88
89
  const tests = found.map((t) => ({
89
90
  id: testId(toPosix(path.relative(srcDir, t.file)), t.name),
90
91
  file: t.file,
@@ -175,6 +176,9 @@ async function walkFlowInProject(options) {
175
176
  ...(options.headless !== undefined && {
176
177
  headless: options.headless,
177
178
  }),
179
+ ...(options.outputDir !== undefined && {
180
+ outputDir: options.outputDir,
181
+ }),
178
182
  files,
179
183
  basePort: options.port ?? DEFAULT_PORT,
180
184
  ...(retries > 0 && { retries }),
@@ -213,6 +217,9 @@ async function walkFlowInProject(options) {
213
217
  files: [...new Set(tests.map((t) => t.file))],
214
218
  testNames: tests.map((t) => t.name),
215
219
  reportPath: false,
220
+ ...(options.outputDir !== undefined && {
221
+ outputDir: options.outputDir,
222
+ }),
216
223
  signal,
217
224
  onEvent,
218
225
  });
@@ -1,6 +1,5 @@
1
+ import { DEFAULT_MAX_BODY_SIZE } from "../config/defaults.js";
1
2
  import { BiDiError } from "../transport/bidi-error.js";
2
- /** How large a response body may be to be kept: 10 MiB */
3
- const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
4
3
  /**
5
4
  * Chrome counts the headers of a response in the size it compares with the collector's limit (a 14-byte body
6
5
  * arrives as about 200 bytes), Firefox counts the body. Chrome's limit is raised by this much and the body is
@@ -1,11 +1,10 @@
1
+ import { DEFAULT_ROUTE_TIMEOUT } from "../config/defaults.js";
1
2
  import logger from "../logger/index.js";
2
3
  import { toRequest } from "./bidi-wire.js";
3
4
  import { Intercept } from "./intercept.js";
4
5
  import { PendingRoute, } from "./route.js";
5
6
  import { matchUrl, samePattern } from "./url-match.js";
6
7
  export { Route } from "./route.js";
7
- /** Time (ms) a handler may take to answer a request before the router lets it continue */
8
- const DEFAULT_ROUTE_TIMEOUT = 30000;
9
8
  /** How many handler errors the router keeps */
10
9
  const MAX_ERRORS = 100;
11
10
  /**
@@ -2,6 +2,7 @@ import { constants, createReadStream } from "node:fs";
2
2
  import { copyFile, mkdir, readFile, stat } from "node:fs/promises";
3
3
  import { createHash } from "node:crypto";
4
4
  import path from "node:path";
5
+ import { DEFAULT_FILES_DIR } from "../config/defaults.js";
5
6
  /** Files bigger than this aren't stored while recording */
6
7
  export const RECORD_LIMIT = 50 * 1024 * 1024;
7
8
  const NAME_LIMIT = 120;
@@ -20,7 +21,7 @@ export function cleanFileName(name) {
20
21
  return cleaned === "" ? "file" : cleaned;
21
22
  }
22
23
  /** The files folder: absolute, and relative to the project with forward slashes */
23
- export function filesFolder(projectDir, filesDir = "files") {
24
+ export function filesFolder(projectDir, filesDir = DEFAULT_FILES_DIR) {
24
25
  const absolute = path.resolve(projectDir, filesDir);
25
26
  return {
26
27
  absolute,
@@ -2,3 +2,4 @@ export { ConfigEditError, editConfig, hasConfigPath, propertyText, type ConfigVa
2
2
  export { addVariable, ExistsError, VARIABLE_NAME } from "./variables.js";
3
3
  export { isGitIgnored, SECRET_NAME, setSecret } from "./secrets-file.js";
4
4
  export { cleanFileName, filesFolder, isFolderIgnored, RECORD_LIMIT, storeFile, } from "./files-store.js";
5
+ export { loadConfig, writtenConfig, type ConfigViews, type ResolvedConfig, type ResolvedEnvironment, type ResolvedIgnoreError, type WrittenConfig, } from "../config/resolved-config.js";
@@ -2,3 +2,4 @@ export { ConfigEditError, editConfig, hasConfigPath, propertyText, } from "./con
2
2
  export { addVariable, ExistsError, VARIABLE_NAME } from "./variables.js";
3
3
  export { isGitIgnored, SECRET_NAME, setSecret } from "./secrets-file.js";
4
4
  export { cleanFileName, filesFolder, isFolderIgnored, RECORD_LIMIT, storeFile, } from "./files-store.js";
5
+ export { loadConfig, writtenConfig, } from "../config/resolved-config.js";
@@ -1,7 +1,7 @@
1
1
  import { Browser } from "../browser/browser.js";
2
2
  import { ensureWarm } from "../browser/warm-up.js";
3
3
  import { readConfig } from "../config/config.js";
4
- import { browsersFor } from "../runner/browsers.js";
4
+ import { browsersFor } from "../config/browsers-for.js";
5
5
  import { DEFAULT_PORT } from "../runner/ports.js";
6
6
  /**
7
7
  * The browser a recording uses: the one asked for, else the first of the project's (recording in two browsers at
@@ -31,8 +31,10 @@ export interface RecordSpecOptions extends ProjectOptions {
31
31
  /** Called after each step is written to the file, with the file's new content */
32
32
  onEvent?: (event: RecorderEvent, source: string) => void;
33
33
  /**
34
- * Called when the recording is paused or resumed, or the toolbar fails (or comes back) on a page.
35
- * There is no initial call: until the first one the recording is `{ state: "recording", toolbar: true }`
34
+ * Called once with the recording's first status right after `onStarted` resolves (`{ state: "recording" }`,
35
+ * and `toolbar: false` if the toolbar had already failed to load), then whenever the recording is paused
36
+ * or resumed, or the toolbar fails (or comes back) on a page. Statuses that arrive while `onStarted` runs are held
37
+ * and sent once it resolves, after the first status.
36
38
  */
37
39
  onStatus?: (status: {
38
40
  state: "recording" | "paused";
@@ -46,6 +48,7 @@ export interface RecordSpecOptions extends ProjectOptions {
46
48
  browser: Browser;
47
49
  }) => void | Promise<void>;
48
50
  }
51
+ /** What a recording returns; every field is always present, empty when there is nothing to report. */
49
52
  export interface RecordResult {
50
53
  file: string;
51
54
  /** Full name of the test the steps went into */
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { loadConfig, projectDir } from "../config/config.js";
3
+ import { loadRawConfig, projectDir } from "../config/config.js";
4
4
  import { loadSecrets } from "../config/secrets.js";
5
5
  import { filesFolder, isFolderIgnored } from "../project/index.js";
6
6
  import { withProject } from "../runner/project.js";
@@ -30,11 +30,21 @@ export async function recordSpec(options) {
30
30
  ...options,
31
31
  headless: options.headless ?? settings.headless,
32
32
  });
33
- const loaded = await loadConfig();
33
+ const loaded = await loadRawConfig();
34
34
  const folder = filesFolder(projectDir(), loaded.filesDir);
35
35
  const hadFolder = existsSync(folder.absolute);
36
36
  let recorder;
37
37
  const addedSecrets = new Set();
38
+ // Statuses that come while `onStarted` runs wait behind the first one, which is always the first
39
+ let waiting = [];
40
+ let toolbarLoaded = true;
41
+ const onStatus = (status) => {
42
+ toolbarLoaded = status.toolbar;
43
+ if (waiting)
44
+ waiting.push(status);
45
+ else
46
+ options.onStatus?.(status);
47
+ };
38
48
  let stopRequested;
39
49
  const stopped = new Promise((resolve) => (stopRequested = resolve));
40
50
  try {
@@ -50,7 +60,7 @@ export async function recordSpec(options) {
50
60
  toolbar: {
51
61
  ...(await toolbarOptions(run, spec.source, projectDir(), loaded, testIndex)),
52
62
  projectDir: projectDir(),
53
- ...(options.onStatus && { onStatus: options.onStatus }),
63
+ ...(options.onStatus && { onStatus }),
54
64
  onSecretAdded: (name) => addedSecrets.add(name),
55
65
  onStop: () => stopRequested(),
56
66
  },
@@ -65,6 +75,11 @@ export async function recordSpec(options) {
65
75
  },
66
76
  });
67
77
  await options.onStarted?.({ page, browser });
78
+ const queued = waiting ?? [];
79
+ waiting = undefined;
80
+ options.onStatus?.({ state: "recording", toolbar: toolbarLoaded });
81
+ for (const status of queued)
82
+ options.onStatus?.(status);
68
83
  let windowClosed = false;
69
84
  void browser.exited.then(() => (windowClosed = true));
70
85
  await new Promise((resolve) => {
@@ -1,9 +1,5 @@
1
1
  import type { SupportedBrowser } from "../types/browser.js";
2
2
  import type { SamuraiGroup } from "../types/config.js";
3
- /** The test folder when the config names none */
4
- export declare const DEFAULT_SRC_DIR = "./src";
5
- /** The spec pattern when a group names none */
6
- export declare const DEFAULT_TEST_MATCH = "**/*.spec.ts";
7
3
  /** A test as groups see it: its spec file (relative to `srcDir`, or any path inside it) and full name */
8
4
  export interface GroupTest {
9
5
  file: string;
@@ -16,7 +12,10 @@ export interface ResolvedGroup {
16
12
  kind: "picked" | "pattern";
17
13
  /** What selects the tests of a pattern group, for display */
18
14
  pattern?: string;
19
- /** Ids (see `testId`) of the found tests in the group */
15
+ /**
16
+ * Ids of the found tests in the group: the spec file relative to `srcDir` with `/` separators, `::`, then the
17
+ * full name (`FoundTest.id` is the same)
18
+ */
20
19
  testIds: string[];
21
20
  /** Picked tests that no longer exist */
22
21
  missing: {
@@ -1,10 +1,7 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { matchesGlob, relative, resolve } from "node:path";
3
- import { browsersFor } from "./browsers.js";
4
- /** The test folder when the config names none */
5
- export const DEFAULT_SRC_DIR = "./src";
6
- /** The spec pattern when a group names none */
7
- export const DEFAULT_TEST_MATCH = "**/*.spec.ts";
3
+ import { DEFAULT_TEST_MATCH } from "../config/defaults.js";
4
+ import { browsersFor } from "../config/browsers-for.js";
8
5
  /**
9
6
  * The real path of `p` (symlinks resolved), or `p` itself when it does not exist. Registered test files come
10
7
  * from stack traces, which resolve symlinks, so paths compared with them must be real too
@@ -28,7 +28,13 @@ export interface RunnerOptions {
28
28
  headless?: boolean;
29
29
  /** Remote debugging port of the browser. @default DEFAULT_PORT (9223) */
30
30
  port?: number;
31
- /** Where the report is written; `false` writes none. @default `<project>/result/report.json` */
31
+ /**
32
+ * The folder for what the run writes, instead of `<project>/result`: `report.json` and the named screenshots
33
+ * (their paths in the results are relative to it). Relative to the project folder.
34
+ * @default `<project>/result`
35
+ */
36
+ outputDir?: string;
37
+ /** Where the report is written; `false` writes none. Beats `outputDir` for the report. @default `report.json` in `outputDir` */
32
38
  reportPath?: string | false;
33
39
  /** Receives start and end of the run and of every test, as they happen */
34
40
  onEvent?: (event: RunEvent) => void;
@@ -0,0 +1,6 @@
1
+ import type { RunnerOptions } from "./options.js";
2
+ /** Where a run writes: the folder its screenshots go in, and its report (`false`: none) */
3
+ export declare function runOutput(options: Pick<RunnerOptions, "outputDir" | "reportPath">, projectDir: string): {
4
+ resultDir: string;
5
+ report: string | false;
6
+ };
@@ -0,0 +1,14 @@
1
+ import path from "node:path";
2
+ /** Where a run writes: the folder its screenshots go in, and its report (`false`: none) */
3
+ export function runOutput(options, projectDir) {
4
+ const { outputDir, reportPath } = options;
5
+ const resultDir = outputDir !== undefined
6
+ ? path.resolve(projectDir, outputDir)
7
+ : typeof reportPath === "string"
8
+ ? path.dirname(reportPath)
9
+ : path.join(projectDir, "result");
10
+ return {
11
+ resultDir,
12
+ report: reportPath ?? path.join(resultDir, "report.json"),
13
+ };
14
+ }
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_LOGS_CAPTURE } from "../config/defaults.js";
1
2
  import { toTestError } from "./test-error.js";
2
3
  /** The page threw exceptions nobody caught, and config `logs.failOnPageError` says that fails the test */
3
4
  export class PageErrorsError extends Error {
@@ -16,15 +17,17 @@ export class RouteErrorsError extends Error {
16
17
  }
17
18
  }
18
19
  function isIgnored(error, ignore) {
19
- return ignore.some((pattern) => typeof pattern === "string" ? error.message.includes(pattern) : pattern.test(error.message));
20
+ return ignore.some((pattern) => typeof pattern === "string"
21
+ ? error.message.includes(pattern)
22
+ : pattern.test(error.message));
20
23
  }
21
24
  /**
22
25
  * Decides, once a test body is done, whether page errors fail it and which log entries go in the report:
23
26
  * `capture` "failures" (default) attaches them to failed tests, "all" always, "off" never.
24
27
  */
25
28
  export function judgePageLogs(input) {
26
- const { config, entries, errors, dropped, testFailed, allowPageErrors, routeErrors = [] } = input;
27
- const capture = config?.capture ?? "failures";
29
+ const { config, entries, errors, dropped, testFailed, allowPageErrors, routeErrors = [], } = input;
30
+ const capture = config?.capture ?? DEFAULT_LOGS_CAPTURE;
28
31
  let error;
29
32
  if (routeErrors.length > 0 && !testFailed) {
30
33
  error = toTestError(new RouteErrorsError(routeErrors));
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { removeGeneratedFiles } from "../browser/generated-files.js";
3
- import { loadConfig, projectDir, setActiveProject } from "../config/config.js";
3
+ import { loadRawConfig, projectDir, setActiveProject, } from "../config/config.js";
4
4
  import { resetSecrets } from "../config/mask.js";
5
5
  import { setRunSettings } from "../config/run-settings.js";
6
6
  import { keepLogFiles } from "../logger/index.js";
@@ -20,7 +20,7 @@ export async function withProject(options, work) {
20
20
  const releaseLogs = keepLogFiles(path.resolve(dir ?? previousDir));
21
21
  try {
22
22
  setActiveProject({ dir: dir ?? previousDir, config, dataDir });
23
- const loaded = config ?? (await loadConfig());
23
+ const loaded = config ?? (await loadRawConfig());
24
24
  setActiveProject({ dir: dir ?? previousDir, config: loaded, dataDir });
25
25
  resetSecrets();
26
26
  const run = prepareRun(loaded, {
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_RETRIES } from "../config/defaults.js";
1
2
  import { parseRetries } from "../config/retries.js";
2
3
  /** How many times a failing test is run again: the flag, else `SAMURAI_RETRIES`, else the config's `retries`, else 0 */
3
4
  export function resolveRetries({ flag, env, config }) {
@@ -8,5 +9,5 @@ export function resolveRetries({ flag, env, config }) {
8
9
  return parseRetries(fromEnv, "SAMURAI_RETRIES");
9
10
  if (config !== undefined)
10
11
  return parseRetries(config, "retries");
11
- return 0;
12
+ return DEFAULT_RETRIES;
12
13
  }
@@ -1,4 +1,3 @@
1
- import path from "path";
2
1
  import { takePendingAssertions } from "../assert/expect.js";
3
2
  import { Browser } from "../browser/browser.js";
4
3
  import { Evidence } from "../browser/evidence.js";
@@ -11,6 +10,7 @@ import { judgePageLogs } from "./page-logs-report.js";
11
10
  import { DEFAULT_PORT } from "./ports.js";
12
11
  import { runWithEachHooks } from "./hooks.js";
13
12
  import { isSkipSignal, staticSkip } from "./skip.js";
13
+ import { runOutput } from "./output.js";
14
14
  import { testDirOf } from "./test-dir.js";
15
15
  import { toTestError } from "./test-error.js";
16
16
  /**
@@ -75,10 +75,7 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
75
75
  const outcome = new TestOutcome(reporter, test, browserName);
76
76
  const { settings, secrets } = run;
77
77
  const timeout = settings.timeout;
78
- const { reportPath } = options;
79
- const resultDir = typeof reportPath === "string"
80
- ? path.dirname(reportPath)
81
- : path.join(projectDir(), "result");
78
+ const { resultDir } = runOutput(options, projectDir());
82
79
  let browser;
83
80
  let page;
84
81
  let timeoutTimer;
@@ -1,6 +1,6 @@
1
1
  import type { Modifier, TestSummary } from "../types/test.js";
2
2
  import { type ProjectOptions } from "./project.js";
3
- import type { ResolvedGroup } from "./groups.js";
3
+ import { type ResolvedGroup } from "./groups.js";
4
4
  import type { RunnerOptions } from "./options.js";
5
5
  export interface RunTestsOptions extends RunnerOptions, ProjectOptions {
6
6
  }
@@ -16,15 +16,25 @@ export interface RunTestsOptions extends RunnerOptions, ProjectOptions {
16
16
  export declare function runTests(options?: RunTestsOptions): Promise<TestSummary>;
17
17
  /** A test found in a spec file */
18
18
  export interface FoundTest {
19
+ /**
20
+ * The id the groups use for this test (`ResolvedGroup.testIds`): its spec file relative to `srcDir` with `/`
21
+ * separators, then `::` and its full name, e.g. `checkout/cart.spec.ts::Cart > adds`
22
+ */
23
+ id: string;
19
24
  /** Full name: `describe` titles and the test title joined with " > " */
20
25
  name: string;
21
26
  file: string;
27
+ /** The spec file relative to `srcDir`, with `/` separators */
28
+ relativeFile: string;
22
29
  /** `skip` or `fixme` (not run), or `only` (focused); absent for a plain test. A modifier on a `describe` counts. */
23
30
  modifier?: Modifier;
24
31
  /** The `{ reason }` given with the `skip` or `fixme` that applies */
25
32
  reason?: string;
26
33
  }
27
- /** Imports the project's specs without running anything and lists the tests the options select */
34
+ /**
35
+ * Imports the project's specs without running anything and lists the tests the options select, each with the id
36
+ * its group uses and its spec file relative to `srcDir`
37
+ */
28
38
  export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[]>;
29
39
  /** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
30
40
  export declare function listGroups(options?: RunTestsOptions): Promise<ResolvedGroup[]>;
@@ -1,4 +1,6 @@
1
+ import path from "path";
1
2
  import { withProject } from "./project.js";
3
+ import { testId, toPosix } from "./groups.js";
2
4
  import TestRunner from "./test-runner.js";
3
5
  /**
4
6
  * Sets the project up (config, environment, secrets), builds a runner from the rest of the options and hands both to
@@ -33,14 +35,26 @@ function inProject(options, work) {
33
35
  export async function runTests(options = {}) {
34
36
  return inProject(options, (runner) => runner.start());
35
37
  }
36
- /** Imports the project's specs without running anything and lists the tests the options select */
38
+ /**
39
+ * Imports the project's specs without running anything and lists the tests the options select, each with the id
40
+ * its group uses and its spec file relative to `srcDir`
41
+ */
37
42
  export async function listTests(options = {}) {
38
- return inProject(options, async (runner) => (await runner.register()).map(({ name, file, modifier, reason }) => ({
39
- name,
40
- file,
41
- ...(modifier && { modifier }),
42
- ...(reason !== undefined && { reason }),
43
- })));
43
+ return inProject(options, async (runner) => {
44
+ const { tests, srcDir } = await runner.list();
45
+ return tests.map((test) => {
46
+ const { name, file, modifier, reason } = test;
47
+ const relativeFile = toPosix(path.relative(srcDir, file));
48
+ return {
49
+ id: testId(relativeFile, name),
50
+ name,
51
+ file,
52
+ relativeFile,
53
+ ...(modifier && { modifier }),
54
+ ...(reason !== undefined && { reason }),
55
+ };
56
+ });
57
+ });
44
58
  }
45
59
  /** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
46
60
  export async function listGroups(options = {}) {
@@ -1,8 +1,8 @@
1
1
  import path from "path";
2
2
  import { pathToFileURL } from "url";
3
- import { loadConfig } from "../config/config.js";
3
+ import { loadRawConfig } from "../config/config.js";
4
4
  import logger from "../logger/index.js";
5
- import { browsersFor } from "./browsers.js";
5
+ import { browsersFor } from "../config/browsers-for.js";
6
6
  import { findGroup, resolveGroups, testId, toPosix, } from "./groups.js";
7
7
  import { assertModuleProject } from "./module-type.js";
8
8
  import { clearRegistry, registeredTests } from "./registry.js";
@@ -40,7 +40,7 @@ export function idOf(test, srcDir) {
40
40
  }
41
41
  /** The config's groups, resolved against the tests the specs register */
42
42
  async function resolveAll(tests) {
43
- const config = await loadConfig();
43
+ const config = await loadRawConfig();
44
44
  const { dir, srcDir } = realDirs(config.srcDir);
45
45
  const groups = resolveGroups(config, tests.map(({ file, name }) => ({
46
46
  file: toPosix(path.relative(srcDir, file)),
@@ -69,7 +69,7 @@ export function selectTests({ all, groups, srcDir }, options) {
69
69
  */
70
70
  export async function browsersOfRun(groups, options) {
71
71
  const { browsers: flag, group } = options;
72
- const config = await loadConfig();
72
+ const config = await loadRawConfig();
73
73
  const ofGroup = group !== undefined && !(flag && flag.length > 0)
74
74
  ? findGroup(groups, group).browsers
75
75
  : undefined;
@@ -1,7 +1,8 @@
1
1
  import path from "path";
2
2
  import { projectDir } from "../config/config.js";
3
3
  import { cleanName } from "../browser/evidence.js";
4
- import { DEFAULT_SRC_DIR, realPath, toPosix } from "./groups.js";
4
+ import { DEFAULT_SRC_DIR } from "../config/defaults.js";
5
+ import { realPath, toPosix } from "./groups.js";
5
6
  /** The project and source folders as real paths: registered test files come from stack traces, which resolve symlinks */
6
7
  export function realDirs(configuredSrcDir) {
7
8
  const dir = realPath(projectDir());
@@ -26,8 +26,14 @@ export default class TestRunner {
26
26
  static init(run: PreparedRun, options?: RunnerOptions, project?: ProjectInfo): Promise<TestRunner>;
27
27
  /** The groups of the config with the tests each holds; see `listGroups` */
28
28
  groups(): Promise<ResolvedGroup[]>;
29
- /** Imports the spec files and returns the tests the options select: by `group`, `testNames` and `grep` */
30
- register(): Promise<RegisteredTestCase[]>;
29
+ /**
30
+ * Imports the spec files and returns the tests the options select (by `group`, `testNames` and `grep`)
31
+ * with the real `srcDir` their ids are relative to
32
+ */
33
+ list(): Promise<{
34
+ tests: RegisteredTestCase[];
35
+ srcDir: string;
36
+ }>;
31
37
  /**
32
38
  * Runs the selected tests in every browser of the run, one browser after the other, and returns the summary that
33
39
  * was also written to the report. With more than one worker the tests run at the same time in worker processes.
@@ -2,12 +2,12 @@ import { glob } from "node:fs/promises";
2
2
  import path from "path";
3
3
  import { ensureWarm } from "../browser/warm-up.js";
4
4
  import { projectBrowser } from "../browsers/resolve.js";
5
- import { loadConfig, projectDir, readConfig } from "../config/config.js";
5
+ import { loadRawConfig, projectDir, readConfig } from "../config/config.js";
6
6
  import { forbidsOnly } from "../config/forbid-only.js";
7
7
  import logger from "../logger/index.js";
8
8
  import { AllHooks } from "./all-hooks.js";
9
9
  import { assertNoOnly, narrowToOnly } from "./focus.js";
10
- import { DEFAULT_SRC_DIR, DEFAULT_TEST_MATCH } from "./groups.js";
10
+ import { DEFAULT_SRC_DIR, DEFAULT_TEST_MATCH } from "../config/defaults.js";
11
11
  import { DEFAULT_PORT } from "./ports.js";
12
12
  import TestReporter from "./reporter.js";
13
13
  import { concurrency, planItems, poolMode, runPooled, serialKeyOf, } from "./pooled-run.js";
@@ -16,7 +16,8 @@ import { staticSkip } from "./skip.js";
16
16
  import { browsersOfRun, loadSpecs, selectTests } from "./select.js";
17
17
  import { resolveRetries } from "./retries-option.js";
18
18
  import { runWithRetries } from "./retry.js";
19
- import { resolveWorkers } from "./workers-option.js";
19
+ import { resolveWorkers } from "../config/workers-option.js";
20
+ import { runOutput } from "./output.js";
20
21
  import { WorkerPool } from "./worker-pool.js";
21
22
  import { processWorkerFactory } from "./worker-process.js";
22
23
  /** Finds the spec files, picks the tests and browsers of a run and runs each test in each browser */
@@ -48,19 +49,23 @@ export default class TestRunner {
48
49
  async groups() {
49
50
  return (await loadSpecs(this.testFiles)).groups;
50
51
  }
51
- /** Imports the spec files and returns the tests the options select: by `group`, `testNames` and `grep` */
52
- async register() {
53
- return selectTests(await loadSpecs(this.testFiles), this.options);
52
+ /**
53
+ * Imports the spec files and returns the tests the options select (by `group`, `testNames` and `grep`)
54
+ * with the real `srcDir` their ids are relative to
55
+ */
56
+ async list() {
57
+ const loaded = await loadSpecs(this.testFiles);
58
+ return { tests: selectTests(loaded, this.options), srcDir: loaded.srcDir };
54
59
  }
55
60
  /**
56
61
  * Runs the selected tests in every browser of the run, one browser after the other, and returns the summary that
57
62
  * was also written to the report. With more than one worker the tests run at the same time in worker processes.
58
63
  */
59
64
  async start() {
60
- const { reportPath, onEvent, signal } = this.options;
65
+ const { onEvent, signal } = this.options;
61
66
  // The specs are imported once: their top-level code runs once per run
62
67
  const loaded = await loadSpecs(this.testFiles);
63
- const config = await loadConfig();
68
+ const config = await loadRawConfig();
64
69
  // A focused test the config refuses stops the run before anything starts; else only the focused ones run
65
70
  assertNoOnly(loaded.all, forbidsOnly(config), loaded.srcDir);
66
71
  const selected = narrowToOnly(selectTests(loaded, this.options));
@@ -68,7 +73,7 @@ export default class TestRunner {
68
73
  const reporter = new TestReporter({
69
74
  environment: this.run.settings.environment,
70
75
  browsers,
71
- ...(reportPath !== undefined && { output: reportPath }),
76
+ output: runOutput(this.options, projectDir()).report,
72
77
  ...(onEvent && { onEvent }),
73
78
  });
74
79
  // How the run goes is settled first: a bad `workers` is an error before any browser starts
@@ -155,7 +160,7 @@ export default class TestRunner {
155
160
  /** The pool of a run: up to `workers` worker processes that read the same project and settings as this run */
156
161
  createPool(workers, retries) {
157
162
  const { settings } = this.run;
158
- const { reportPath, headless, port } = this.options;
163
+ const { reportPath, outputDir, headless, port } = this.options;
159
164
  const options = {
160
165
  workers,
161
166
  factory: processWorkerFactory(),
@@ -169,6 +174,7 @@ export default class TestRunner {
169
174
  expectTimeout: settings.expectTimeout,
170
175
  ...(headless !== undefined && { headless }),
171
176
  ...(typeof reportPath === "string" && { reportPath }),
177
+ ...(outputDir !== undefined && { outputDir }),
172
178
  files: this.testFiles,
173
179
  basePort: port ?? DEFAULT_PORT,
174
180
  ...(retries > 0 && { retries }),
@@ -24,6 +24,8 @@ export interface WorkerInit {
24
24
  retries?: number;
25
25
  /** Where the run's report goes when it is not the default; screenshots are kept beside it */
26
26
  reportPath?: string;
27
+ /** The run's `outputDir`: screenshots go there */
28
+ outputDir?: string;
27
29
  /** Spec files to import, absolute */
28
30
  files: string[];
29
31
  /** 0 for the first worker; picks the worker's browser port and profile folder */
@@ -48,6 +48,9 @@ export function openSession(init) {
48
48
  ...(init.reportPath !== undefined && {
49
49
  reportPath: init.reportPath,
50
50
  }),
51
+ ...(init.outputDir !== undefined && {
52
+ outputDir: init.outputDir,
53
+ }),
51
54
  port,
52
55
  signal,
53
56
  };
@@ -22,6 +22,16 @@ export type StepEdit = {
22
22
  test: number;
23
23
  from: number;
24
24
  to: number;
25
+ }
26
+ /**
27
+ * Removes the whole test, with the comments and the blank line above it. For several tests, apply from the last index to the first.
28
+ * It removes all leading trivia from the end of the previous statement, so comments above the test go with it: a
29
+ * file-header comment when the test is the first statement, and a trailing `// comment` on the previous test's line.
30
+ * Throws for a test that isn't `removable`
31
+ */
32
+ | {
33
+ op: "remove-test";
34
+ test: number;
25
35
  };
26
36
  /**
27
37
  * Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
@@ -125,6 +125,10 @@ export function applyEdit(source, edit) {
125
125
  }
126
126
  case "remove":
127
127
  return removeStep(source, parsed, edit.index);
128
+ case "remove-test":
129
+ if (!parsed.removable)
130
+ throw new Error(`Cannot remove test "${parsed.name}": it is not a statement of its own`);
131
+ return splice(source, parsed.start, parsed.end, "");
128
132
  case "move": {
129
133
  const { start, end } = pickStep(parsed, edit.from);
130
134
  if (edit.to < 0 || edit.to >= parsed.steps.length)