@itmetsam/samurai-framework 0.15.1 → 0.16.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 (64) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/browser/browser.js +2 -1
  3. package/dist/browser/page-network.js +3 -2
  4. package/dist/browser/page-settings.js +2 -5
  5. package/dist/browser/page.js +2 -1
  6. package/dist/browser/session.d.ts +1 -1
  7. package/dist/browser/session.js +1 -1
  8. package/dist/browser/viewport.d.ts +0 -5
  9. package/dist/browser/viewport.js +0 -2
  10. package/dist/capabilities.d.ts +32 -0
  11. package/dist/capabilities.js +30 -0
  12. package/dist/cli/main.js +1 -1
  13. package/dist/{runner/browsers.js → config/browsers-for.js} +1 -2
  14. package/dist/config/config.d.ts +3 -1
  15. package/dist/config/config.js +12 -11
  16. package/dist/config/defaults.d.ts +41 -0
  17. package/dist/config/defaults.js +38 -0
  18. package/dist/config/resolved-config.d.ts +106 -0
  19. package/dist/config/resolved-config.js +112 -0
  20. package/dist/config/run-settings.d.ts +2 -0
  21. package/dist/config/run-settings.js +2 -3
  22. package/dist/config/timeouts.js +1 -2
  23. package/dist/{runner → config}/workers-option.js +1 -1
  24. package/dist/flows/run/project.js +5 -4
  25. package/dist/network/data-collector.js +1 -2
  26. package/dist/network/router.js +1 -2
  27. package/dist/project/files-store.js +2 -1
  28. package/dist/project/index.d.ts +1 -0
  29. package/dist/project/index.js +1 -0
  30. package/dist/recorder/session-browser.js +1 -1
  31. package/dist/recorder/session.d.ts +5 -2
  32. package/dist/recorder/session.js +18 -3
  33. package/dist/runner/groups.d.ts +4 -5
  34. package/dist/runner/groups.js +2 -5
  35. package/dist/runner/page-logs-report.js +6 -3
  36. package/dist/runner/pooled-run.js +3 -3
  37. package/dist/runner/project.js +2 -2
  38. package/dist/runner/registry.d.ts +13 -4
  39. package/dist/runner/registry.js +26 -9
  40. package/dist/runner/retries-option.js +2 -1
  41. package/dist/runner/run-test.js +1 -1
  42. package/dist/runner/run.d.ts +14 -2
  43. package/dist/runner/run.js +21 -6
  44. package/dist/runner/select.js +4 -4
  45. package/dist/runner/skip.d.ts +2 -1
  46. package/dist/runner/skip.js +3 -2
  47. package/dist/runner/suite.d.ts +10 -4
  48. package/dist/runner/suite.js +19 -10
  49. package/dist/runner/test-dir.js +2 -1
  50. package/dist/runner/test-runner.d.ts +8 -2
  51. package/dist/runner/test-runner.js +14 -10
  52. package/dist/steps/edit.d.ts +10 -0
  53. package/dist/steps/edit.js +4 -0
  54. package/dist/steps/index.d.ts +2 -2
  55. package/dist/steps/index.js +1 -1
  56. package/dist/steps/model.d.ts +19 -0
  57. package/dist/steps/parse.d.ts +6 -1
  58. package/dist/steps/parse.js +89 -19
  59. package/dist/transport/bidi-connection.d.ts +0 -2
  60. package/dist/transport/bidi-connection.js +1 -2
  61. package/dist/types/test.d.ts +10 -0
  62. package/package.json +6 -2
  63. /package/dist/{runner/browsers.d.ts → config/browsers-for.d.ts} +0 -0
  64. /package/dist/{runner → config}/workers-option.d.ts +0 -0
@@ -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,7 @@ 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
20
  import { WorkerPool } from "./worker-pool.js";
21
21
  import { processWorkerFactory } from "./worker-process.js";
22
22
  /** Finds the spec files, picks the tests and browsers of a run and runs each test in each browser */
@@ -48,9 +48,13 @@ export default class TestRunner {
48
48
  async groups() {
49
49
  return (await loadSpecs(this.testFiles)).groups;
50
50
  }
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);
51
+ /**
52
+ * Imports the spec files and returns the tests the options select (by `group`, `testNames` and `grep`)
53
+ * with the real `srcDir` their ids are relative to
54
+ */
55
+ async list() {
56
+ const loaded = await loadSpecs(this.testFiles);
57
+ return { tests: selectTests(loaded, this.options), srcDir: loaded.srcDir };
54
58
  }
55
59
  /**
56
60
  * Runs the selected tests in every browser of the run, one browser after the other, and returns the summary that
@@ -60,7 +64,7 @@ export default class TestRunner {
60
64
  const { reportPath, onEvent, signal } = this.options;
61
65
  // The specs are imported once: their top-level code runs once per run
62
66
  const loaded = await loadSpecs(this.testFiles);
63
- const config = await loadConfig();
67
+ const config = await loadRawConfig();
64
68
  // A focused test the config refuses stops the run before anything starts; else only the focused ones run
65
69
  assertNoOnly(loaded.all, forbidsOnly(config), loaded.srcDir);
66
70
  const selected = narrowToOnly(selectTests(loaded, this.options));
@@ -123,9 +127,9 @@ export default class TestRunner {
123
127
  try {
124
128
  for (const browser of browsers) {
125
129
  for (const test of selected) {
126
- const annotation = staticSkip(test);
127
- if (annotation) {
128
- reporter.onTestSkipped(test, browser, { annotation });
130
+ const skip = staticSkip(test);
131
+ if (skip) {
132
+ reporter.onTestSkipped(test, browser, skip);
129
133
  continue;
130
134
  }
131
135
  if (signal?.aborted) {
@@ -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)
@@ -1,6 +1,6 @@
1
- export { parseSpec } from "./parse.js";
1
+ export { parseSpec, parseSpecFile } from "./parse.js";
2
2
  export { applyEdit, type StepEdit } from "./edit.js";
3
3
  export { stepToSource, locatorToSource, callToSource, filesToSource, valueToSource, } from "./emit.js";
4
4
  export { formatSize, humanSize, parseSize, sizeFromSource } from "./files.js";
5
5
  export { locatorFromSpec } from "./build.js";
6
- export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, ParsedStep, ParsedTest, Step, TextMatcher, TextValue, } from "./model.js";
6
+ export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, ParsedStep, ParsedTest, Step, TextMatcher, TextValue, UnparsedTest, } from "./model.js";
@@ -1,4 +1,4 @@
1
- export { parseSpec } from "./parse.js";
1
+ export { parseSpec, parseSpecFile } from "./parse.js";
2
2
  export { applyEdit } from "./edit.js";
3
3
  export { stepToSource, locatorToSource, callToSource, filesToSource, valueToSource, } from "./emit.js";
4
4
  export { formatSize, humanSize, parseSize, sizeFromSource } from "./files.js";
@@ -146,6 +146,15 @@ export interface ParsedTest {
146
146
  /** Offset just after the `{` of the test body, and of its closing `}` */
147
147
  bodyStart: number;
148
148
  bodyEnd: number;
149
+ /** Where the whole test starts: the full start of its statement, so the comments and blank line above are included */
150
+ start: number;
151
+ /** Where the whole test ends: the end of its statement, after the `;` */
152
+ end: number;
153
+ /**
154
+ * False when the test call isn't a statement of its own (a loop or `if` body without braces, an arrow's
155
+ * expression body), so it can't be removed without breaking the file
156
+ */
157
+ removable: boolean;
149
158
  /**
150
159
  * The fixtures the test's callback destructures, and the offset where another one goes: after the last one
151
160
  * (`list`), inside an empty `{}` (`empty`) or inside the empty `()` of a callback without parameters (`none`).
@@ -158,4 +167,14 @@ export interface ParsedTest {
158
167
  };
159
168
  /** `skip` or `fixme` (not run), or `only` (focused), as the runner applies it: a modifier on a `describe` counts */
160
169
  modifier?: Modifier;
170
+ /** The `{ reason }` written with the `skip` or `fixme` that applies, when it is a string literal */
171
+ reason?: string;
172
+ }
173
+ /** A `test` or `describe` call the codec can't read: its title isn't a plain string, or its function isn't written inline */
174
+ export interface UnparsedTest {
175
+ kind: "test" | "describe";
176
+ /** The statement's range, as for `ParsedTest` */
177
+ start: number;
178
+ end: number;
179
+ reason: "dynamic-title" | "not-inline";
161
180
  }
@@ -1,3 +1,8 @@
1
- import type { ParsedTest } from "./model.js";
1
+ import type { ParsedTest, UnparsedTest } from "./model.js";
2
+ /** The tests in a spec file, with their steps and where each statement sits, and the tests and describes it can't read */
3
+ export declare function parseSpecFile(source: string): {
4
+ tests: ParsedTest[];
5
+ unparsed: UnparsedTest[];
6
+ };
2
7
  /** The tests in a spec file, with their steps and where each statement sits */
3
8
  export declare function parseSpec(source: string): ParsedTest[];
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript";
2
- import { stringOf } from "./parse/literals.js";
2
+ import { readObject, stringOf } from "./parse/literals.js";
3
3
  import { readStep } from "./parse/step.js";
4
4
  function parseStatement(source, statement, sourceFile) {
5
5
  const start = statement.getStart(sourceFile);
@@ -57,45 +57,115 @@ function calleeOf(expression) {
57
57
  };
58
58
  return undefined;
59
59
  }
60
- /** What the runner applies to a test (see `modifierOf` in the runner): the innermost `skip` or `fixme`, else `only` */
61
- function effectiveModifier(own, describes) {
60
+ /**
61
+ * The callback of `test(title, fn)` and `describe(title, fn)`, and for a skip or fixme also of
62
+ * `(title, { reason }, fn)` with the reason when it is a literal
63
+ */
64
+ function callbackOf(node, modifier) {
65
+ const [, second, third] = node.arguments;
66
+ if ((modifier === "skip" || modifier === "fixme") &&
67
+ second &&
68
+ ts.isObjectLiteralExpression(second)) {
69
+ const reason = readObject(second, ["reason"])?.reason;
70
+ return {
71
+ callback: third,
72
+ ...(typeof reason === "string" && { reason }),
73
+ };
74
+ }
75
+ return { callback: second };
76
+ }
77
+ /** What the runner applies to a test (see `modifierOf` in the runner): the innermost `skip` or `fixme` with its reason, else `only` */
78
+ function effectiveMark(own, describes) {
62
79
  const chain = [own, ...[...describes].reverse()];
63
- return (chain.find((m) => m === "skip" || m === "fixme") ??
64
- (chain.includes("only") ? "only" : undefined));
80
+ const skip = chain.find((m) => m.modifier === "skip" || m.modifier === "fixme");
81
+ if (skip)
82
+ return {
83
+ modifier: skip.modifier,
84
+ ...(skip.reason !== undefined && { reason: skip.reason }),
85
+ };
86
+ return chain.some((m) => m.modifier === "only") ? { modifier: "only" } : {};
65
87
  }
66
- /** The tests in a spec file, with their steps and where each statement sits */
67
- export function parseSpec(source) {
88
+ /** The range of a whole test: its statement with the comments above it, or the call alone when it isn't a statement */
89
+ function wholeRange(call) {
90
+ const statement = ts.isExpressionStatement(call.parent) ? call.parent : call;
91
+ return { start: statement.getFullStart(), end: statement.end };
92
+ }
93
+ /** Whether the call is a statement directly inside a block, a module block or the file, so removing its range leaves valid code */
94
+ function isRemovable(call) {
95
+ const statement = call.parent;
96
+ if (!ts.isExpressionStatement(statement))
97
+ return false;
98
+ const container = statement.parent;
99
+ return (ts.isBlock(container) ||
100
+ ts.isSourceFile(container) ||
101
+ ts.isModuleBlock(container));
102
+ }
103
+ /** A test or describe the codec can't read, with where its statement sits */
104
+ function unreadable(node, kind, reason) {
105
+ return { kind, reason, ...wholeRange(node) };
106
+ }
107
+ /** `test.skip(condition, reason)` inside a body: it takes a condition where a test takes a title, so it is neither a test nor unparsed */
108
+ function isConditionForm(node, callee) {
109
+ const [first] = node.arguments;
110
+ return ((callee.modifier === "skip" || callee.modifier === "fixme") &&
111
+ !(first &&
112
+ (ts.isStringLiteral(first) ||
113
+ ts.isNoSubstitutionTemplateLiteral(first) ||
114
+ ts.isTemplateExpression(first))));
115
+ }
116
+ /** The tests in a spec file, with their steps and where each statement sits, and the tests and describes it can't read */
117
+ export function parseSpecFile(source) {
68
118
  const sourceFile = ts.createSourceFile("spec.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
69
119
  const tests = [];
120
+ const unparsed = [];
70
121
  const visit = (node, titles, describes) => {
71
122
  const callee = ts.isCallExpression(node)
72
123
  ? calleeOf(node.expression)
73
124
  : undefined;
74
- if (ts.isCallExpression(node) && callee) {
125
+ if (ts.isCallExpression(node) &&
126
+ callee &&
127
+ (callee.name === "test" || callee.name === "describe") &&
128
+ !(callee.name === "test" && isConditionForm(node, callee))) {
129
+ const kind = callee.name;
75
130
  const title = stringOf(node.arguments[0]);
76
- const body = callbackBody(node.arguments[1]);
77
- if (title !== undefined && body && callee.name === "test") {
78
- // `test.skip(condition, reason)` has no title and no body, so it stays a step of the test it is in
131
+ const { callback, reason } = callbackOf(node, callee.modifier);
132
+ const body = callbackBody(callback);
133
+ if (title === undefined) {
134
+ unparsed.push(unreadable(node, kind, "dynamic-title"));
135
+ return;
136
+ }
137
+ if (!body) {
138
+ unparsed.push(unreadable(node, kind, "not-inline"));
139
+ return;
140
+ }
141
+ const mark = {
142
+ ...(callee.modifier && { modifier: callee.modifier }),
143
+ ...(reason !== undefined && { reason }),
144
+ };
145
+ if (kind === "test") {
79
146
  const titlePath = [...titles, title];
80
- const modifier = effectiveModifier(callee.modifier, describes);
81
147
  tests.push({
82
148
  titlePath,
83
149
  name: titlePath.join(" > "),
84
150
  steps: body.statements.map((statement) => parseStatement(source, statement, sourceFile)),
85
151
  bodyStart: body.getStart(sourceFile) + 1,
86
152
  bodyEnd: body.end - 1,
87
- fixtures: callbackFixtures(node.arguments[1], sourceFile),
88
- ...(modifier && { modifier }),
153
+ ...wholeRange(node),
154
+ removable: isRemovable(node),
155
+ fixtures: callbackFixtures(callback, sourceFile),
156
+ ...effectiveMark(mark, describes),
89
157
  });
90
158
  return;
91
159
  }
92
- if (title !== undefined && body && callee.name === "describe") {
93
- ts.forEachChild(body, (child) => visit(child, [...titles, title], [...describes, callee.modifier]));
94
- return;
95
- }
160
+ ts.forEachChild(body, (child) => visit(child, [...titles, title], [...describes, mark]));
161
+ return;
96
162
  }
97
163
  ts.forEachChild(node, (child) => visit(child, titles, describes));
98
164
  };
99
165
  visit(sourceFile, [], []);
100
- return tests;
166
+ return { tests, unparsed };
167
+ }
168
+ /** The tests in a spec file, with their steps and where each statement sits */
169
+ export function parseSpec(source) {
170
+ return parseSpecFile(source).tests;
101
171
  }
@@ -1,7 +1,5 @@
1
1
  import type { BiDiCommands, BiDiEvents } from "../types/bidi.js";
2
2
  import { type EventName, type Subscription, type SubscribeOptions } from "./subscriptions.js";
3
- /** Time (ms) a command may wait for its reply before it fails with code "timeout" */
4
- export declare const DEFAULT_COMMAND_TIMEOUT = 30000;
5
3
  export interface ConnectorOptions {
6
4
  /** @default DEFAULT_COMMAND_TIMEOUT */
7
5
  commandTimeout?: number;
@@ -1,11 +1,10 @@
1
1
  import EventEmitter from "node:events";
2
+ import { DEFAULT_COMMAND_TIMEOUT } from "../config/defaults.js";
2
3
  import logger from "../logger/index.js";
3
4
  import { WaitTimeoutError } from "../wait/wait-until.js";
4
5
  import { BiDiError } from "./bidi-error.js";
5
6
  import { forLog } from "./log-redaction.js";
6
7
  import { SubscriptionManager, } from "./subscriptions.js";
7
- /** Time (ms) a command may wait for its reply before it fails with code "timeout" */
8
- export const DEFAULT_COMMAND_TIMEOUT = 30000;
9
8
  /**
10
9
  * The connection to the browser is gone (the browser exited or was closed, or the socket dropped). Commands that
11
10
  * were waiting fail with it, and so does every command sent afterwards: a closed socket would swallow them silently.
@@ -21,6 +21,12 @@ export type TestCase = TestCaseBase;
21
21
  /** `skip`: not run. `fixme`: not run, known to be broken. `only`: the only test that runs when any is focused */
22
22
  export type Modifier = "skip" | "fixme" | "only";
23
23
 
24
+ /** The optional second argument of `test.skip`, `test.fixme`, `describe.skip` and `describe.fixme` */
25
+ export interface TestDetails {
26
+ /** Why the tests are not run; shown in the report and by `samurai list` */
27
+ reason?: string;
28
+ }
29
+
24
30
  /** Runs before or after each test of a suite, in the test's browser, with the test's fixtures */
25
31
  export type EachHook = (fixtures: TestFixtures) => Promise<void> | void;
26
32
 
@@ -39,6 +45,8 @@ export interface Suite {
39
45
  title?: string;
40
46
  parent?: Suite;
41
47
  modifier?: Modifier;
48
+ /** The `{ reason }` of a `describe.skip` or `describe.fixme` */
49
+ reason?: string;
42
50
  beforeEach?: EachHook[];
43
51
  afterEach?: EachHook[];
44
52
  beforeAll?: AllHook[];
@@ -54,6 +62,8 @@ export interface RegisteredTestCase extends TestCaseBase {
54
62
  suite: Suite;
55
63
  /** What applies to the test: its own modifier, or the one of a suite around it */
56
64
  modifier?: Modifier;
65
+ /** The reason given with the `skip` or `fixme` that applies */
66
+ reason?: string;
57
67
  }
58
68
 
59
69
  export interface TestCaseBase {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {
@@ -48,6 +48,10 @@
48
48
  "types": "./dist/project/index.d.ts",
49
49
  "default": "./dist/project/index.js"
50
50
  },
51
+ "./capabilities": {
52
+ "types": "./dist/capabilities.d.ts",
53
+ "default": "./dist/capabilities.js"
54
+ },
51
55
  "./package.json": "./package.json"
52
56
  },
53
57
  "scripts": {
@@ -55,7 +59,7 @@
55
59
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-types.mjs",
56
60
  "prepack": "npm run build",
57
61
  "test": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-timeout=10000 \"src/**/*.test.ts\"",
58
- "test:browser": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 --test-timeout=60000 \"src/**/*.browser-test.ts\"",
62
+ "test:browser": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 --test-timeout=60000 ${SAMURAI_TEST_SHARD:+--test-shard=$SAMURAI_TEST_SHARD} \"src/**/*.browser-test.ts\"",
59
63
  "test:browser:chrome": "SAMURAI_TEST_BROWSER=chrome npm run test:browser",
60
64
  "test:cli": "tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 \"src/**/*.cli-test.ts\""
61
65
  },
File without changes