@itmetsam/samurai-framework 0.15.0 → 0.15.2

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 (54) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/cli/args.d.ts +4 -1
  3. package/dist/cli/args.js +7 -2
  4. package/dist/cli/console-reporter.js +11 -2
  5. package/dist/cli/flow.js +5 -0
  6. package/dist/cli/main.js +2 -1
  7. package/dist/cli/usage.d.ts +1 -1
  8. package/dist/cli/usage.js +2 -0
  9. package/dist/config/config.js +3 -0
  10. package/dist/config/retries.d.ts +8 -0
  11. package/dist/config/retries.js +19 -0
  12. package/dist/flows/run/node-pool.d.ts +9 -0
  13. package/dist/flows/run/node-pool.js +27 -0
  14. package/dist/flows/run/project-options.d.ts +44 -0
  15. package/dist/flows/run/project-options.js +8 -0
  16. package/dist/flows/run/project.d.ts +4 -40
  17. package/dist/flows/run/project.js +12 -33
  18. package/dist/recorder/candidates.d.ts +2 -1
  19. package/dist/recorder/candidates.js +44 -1
  20. package/dist/recorder/capture.d.ts +4 -2
  21. package/dist/recorder/capture.js +12 -4
  22. package/dist/recorder/page-messages.d.ts +2 -0
  23. package/dist/recorder/ranking.d.ts +2 -0
  24. package/dist/recorder/ranking.js +8 -1
  25. package/dist/recorder/recorder.js +5 -2
  26. package/dist/runner/options.d.ts +6 -0
  27. package/dist/runner/pooled-run.js +8 -7
  28. package/dist/runner/registry.d.ts +13 -4
  29. package/dist/runner/registry.js +26 -9
  30. package/dist/runner/reporter.d.ts +6 -4
  31. package/dist/runner/reporter.js +7 -1
  32. package/dist/runner/retries-option.d.ts +10 -0
  33. package/dist/runner/retries-option.js +12 -0
  34. package/dist/runner/retry.d.ts +13 -0
  35. package/dist/runner/retry.js +43 -0
  36. package/dist/runner/run-test.d.ts +2 -0
  37. package/dist/runner/run-test.js +8 -2
  38. package/dist/runner/run.d.ts +3 -0
  39. package/dist/runner/run.js +3 -1
  40. package/dist/runner/skip.d.ts +2 -1
  41. package/dist/runner/skip.js +3 -2
  42. package/dist/runner/suite.d.ts +10 -4
  43. package/dist/runner/suite.js +19 -10
  44. package/dist/runner/test-runner.js +15 -6
  45. package/dist/runner/worker-pool.d.ts +1 -0
  46. package/dist/runner/worker-pool.js +1 -0
  47. package/dist/runner/worker-protocol.d.ts +3 -0
  48. package/dist/runner/worker-session.js +6 -3
  49. package/dist/runner/worker.js +3 -0
  50. package/dist/steps/model.d.ts +5 -0
  51. package/dist/steps/parse.js +59 -24
  52. package/dist/types/config.d.ts +7 -0
  53. package/dist/types/test.d.ts +22 -2
  54. package/package.json +2 -1
@@ -20,6 +20,8 @@ export interface WorkerInit {
20
20
  timeout?: number;
21
21
  expectTimeout?: number;
22
22
  headless?: boolean;
23
+ /** How many times a failing test is run again in this worker. @default 0 */
24
+ retries?: number;
23
25
  /** Where the run's report goes when it is not the default; screenshots are kept beside it */
24
26
  reportPath?: string;
25
27
  /** Spec files to import, absolute */
@@ -48,6 +50,7 @@ export type FromWorker = {
48
50
  } | {
49
51
  type: "test-start";
50
52
  id: number;
53
+ retry?: number;
51
54
  } | {
52
55
  type: "test-end";
53
56
  id: number;
@@ -4,6 +4,7 @@ import { withProject } from "./project.js";
4
4
  import TestReporter from "./reporter.js";
5
5
  import { AllHooks } from "./all-hooks.js";
6
6
  import { allHookFixtures, runOneTest } from "./run-test.js";
7
+ import { runWithRetries } from "./retry.js";
7
8
  import { loadSpecs } from "./select.js";
8
9
  /**
9
10
  * Opens the project for a worker and keeps it open until `close`: `withProject` holds the config, environment
@@ -42,19 +43,21 @@ export function openSession(init) {
42
43
  output: false,
43
44
  onEvent,
44
45
  });
45
- await runOneTest(test, browser, run, {
46
+ const options = {
46
47
  ...(headless !== undefined && { headless }),
47
48
  ...(init.reportPath !== undefined && {
48
49
  reportPath: init.reportPath,
49
50
  }),
50
51
  port,
51
52
  signal,
52
- }, loaded.srcDir, reporter, {
53
+ };
54
+ await runWithRetries(test, browser, init.retries ?? 0, signal, (sink, attempt) => runOneTest(test, browser, run, options, loaded.srcDir, sink, {
53
55
  port,
54
56
  profileDir: workerProfileDir(browser, workerIndex),
55
57
  workerIndex,
56
58
  allHooks,
57
- });
59
+ attempt,
60
+ }), reporter);
58
61
  },
59
62
  async close() {
60
63
  for (const error of await allHooks.closeAll())
@@ -43,6 +43,9 @@ export function handleMessages(channel, { open, exit }) {
43
43
  current = new AbortController();
44
44
  try {
45
45
  await session.runTest(test, item.browser, current.signal, (event) => {
46
+ // The first start was sent above; a test run again starts again, saying which attempt
47
+ if (event.type === "test-start" && event.retry)
48
+ channel.send({ type: "test-start", id, retry: event.retry });
46
49
  if (event.type !== "test-end")
47
50
  return;
48
51
  ended = true;
@@ -1,3 +1,4 @@
1
+ import type { Modifier } from "../types/test.js";
1
2
  /** One call of a locator expression, as written in a spec: `page.getByRole("button", { name: "Save" })` */
2
3
  export type LocatorCall = {
3
4
  method: "locator";
@@ -155,4 +156,8 @@ export interface ParsedTest {
155
156
  at: number;
156
157
  shape: "list" | "empty" | "none";
157
158
  };
159
+ /** `skip` or `fixme` (not run), or `only` (focused), as the runner applies it: a modifier on a `describe` counts */
160
+ modifier?: Modifier;
161
+ /** The `{ reason }` written with the `skip` or `fixme` that applies, when it is a string literal */
162
+ reason?: string;
158
163
  }
@@ -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);
@@ -44,31 +44,65 @@ function callbackBody(node) {
44
44
  ? node.body
45
45
  : undefined;
46
46
  }
47
- /** `describe.skip`, `describe.fixme` and `describe.only`: tests written inside keep their place in the title */
48
- function isDescribeWithModifier(expression) {
49
- return (ts.isPropertyAccessExpression(expression) &&
47
+ /** `test(...)` or `describe(...)` with its modifier: `test.skip(...)` gives `{ name: "test", modifier: "skip" }` */
48
+ function calleeOf(expression) {
49
+ if (ts.isIdentifier(expression))
50
+ return { name: expression.text };
51
+ if (ts.isPropertyAccessExpression(expression) &&
50
52
  ts.isIdentifier(expression.expression) &&
51
- expression.expression.text === "describe" &&
52
- ["skip", "fixme", "only"].includes(expression.name.text));
53
+ ["skip", "fixme", "only"].includes(expression.name.text))
54
+ return {
55
+ name: expression.expression.text,
56
+ modifier: expression.name.text,
57
+ };
58
+ return undefined;
59
+ }
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) {
79
+ const chain = [own, ...[...describes].reverse()];
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" } : {};
53
87
  }
54
88
  /** The tests in a spec file, with their steps and where each statement sits */
55
89
  export function parseSpec(source) {
56
90
  const sourceFile = ts.createSourceFile("spec.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
57
91
  const tests = [];
58
- const visit = (node, titles) => {
59
- if (ts.isCallExpression(node) && isDescribeWithModifier(node.expression)) {
60
- // `describe.skip("Old", () => ...)`: a describe block for the names of the tests inside it
61
- const title = stringOf(node.arguments[0]);
62
- const body = callbackBody(node.arguments[1]);
63
- if (title !== undefined && body) {
64
- ts.forEachChild(body, (child) => visit(child, [...titles, title]));
65
- return;
66
- }
67
- }
68
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
92
+ const visit = (node, titles, describes) => {
93
+ const callee = ts.isCallExpression(node)
94
+ ? calleeOf(node.expression)
95
+ : undefined;
96
+ if (ts.isCallExpression(node) && callee) {
69
97
  const title = stringOf(node.arguments[0]);
70
- const body = callbackBody(node.arguments[1]);
71
- if (title !== undefined && body && node.expression.text === "test") {
98
+ const { callback, reason } = callbackOf(node, callee.modifier);
99
+ const body = callbackBody(callback);
100
+ const mark = {
101
+ ...(callee.modifier && { modifier: callee.modifier }),
102
+ ...(reason !== undefined && { reason }),
103
+ };
104
+ if (title !== undefined && body && callee.name === "test") {
105
+ // `test.skip(condition, reason)` has no title and no body, so it stays a step of the test it is in
72
106
  const titlePath = [...titles, title];
73
107
  tests.push({
74
108
  titlePath,
@@ -76,17 +110,18 @@ export function parseSpec(source) {
76
110
  steps: body.statements.map((statement) => parseStatement(source, statement, sourceFile)),
77
111
  bodyStart: body.getStart(sourceFile) + 1,
78
112
  bodyEnd: body.end - 1,
79
- fixtures: callbackFixtures(node.arguments[1], sourceFile),
113
+ fixtures: callbackFixtures(callback, sourceFile),
114
+ ...effectiveMark(mark, describes),
80
115
  });
81
116
  return;
82
117
  }
83
- if (title !== undefined && body && node.expression.text === "describe") {
84
- ts.forEachChild(body, (child) => visit(child, [...titles, title]));
118
+ if (title !== undefined && body && callee.name === "describe") {
119
+ ts.forEachChild(body, (child) => visit(child, [...titles, title], [...describes, mark]));
85
120
  return;
86
121
  }
87
122
  }
88
- ts.forEachChild(node, (child) => visit(child, titles));
123
+ ts.forEachChild(node, (child) => visit(child, titles, describes));
89
124
  };
90
- visit(sourceFile, []);
125
+ visit(sourceFile, [], []);
91
126
  return tests;
92
127
  }
@@ -64,6 +64,13 @@ export interface SamuraiTestConfig {
64
64
  * @default half the CPUs
65
65
  */
66
66
  workers?: number | string;
67
+ /**
68
+ * How many times a failing test is run again, each time with a fresh browser. A test that passes on a retry is
69
+ * reported as flaky and does not fail the run; a run that is aborted is never retried. `--retries` and
70
+ * `SAMURAI_RETRIES` win over it.
71
+ * @default 0
72
+ */
73
+ retries?: number;
67
74
  /**
68
75
  * Refuse a run when a spec contains `test.only` or `describe.only`, so a forgotten focus cannot quietly shrink a
69
76
  * suite. The run stops before any test starts and lists the tests.
@@ -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 {
@@ -70,7 +80,7 @@ export interface PartialTestResult extends TestResultBase {
70
80
  export type TestResult =
71
81
  TestResultSuccess | TestResultFailed | TestResultSkipped | TestResultBase;
72
82
 
73
- interface TestResultSuccess extends TestResultIdentity {
83
+ interface TestResultSuccess extends TestResultIdentity, TestRetries {
74
84
  status: "success";
75
85
  duration: number;
76
86
  logs?: TestLogEntry[];
@@ -81,7 +91,7 @@ interface TestResultSuccess extends TestResultIdentity {
81
91
  }
82
92
 
83
93
  /** The error sits flat on the entry, as in the report: `message`, `type`, `stack`, and for assertions `expected` and `actual` */
84
- interface TestResultFailed extends TestResultIdentity, TestError {
94
+ interface TestResultFailed extends TestResultIdentity, TestError, TestRetries {
85
95
  status: "failed";
86
96
  duration: number;
87
97
  logs?: TestLogEntry[];
@@ -90,6 +100,16 @@ interface TestResultFailed extends TestResultIdentity, TestError {
90
100
  screenshots?: { name: string; path: string }[];
91
101
  }
92
102
 
103
+ /** What a result says about the retries of its test; absent when it passed or failed on the first attempt */
104
+ export interface TestRetries {
105
+ /** The attempt this result is from, counting from 1: `retry: 1` is the first run again */
106
+ retry?: number;
107
+ /** The test passed after at least one failed attempt */
108
+ flaky?: true;
109
+ /** The errors of the failed attempts before this one, in order */
110
+ previousAttempts?: TestError[];
111
+ }
112
+
93
113
  /** A test that was not run: marked `skip` or `fixme`, or skipped from inside with `test.skip(condition)` */
94
114
  interface TestResultSkipped extends TestResultIdentity {
95
115
  status: "skipped";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
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": {
@@ -53,6 +53,7 @@
53
53
  "scripts": {
54
54
  "dev": "tsx src/index.ts",
55
55
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-types.mjs",
56
+ "prepack": "npm run build",
56
57
  "test": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-timeout=10000 \"src/**/*.test.ts\"",
57
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\"",
58
59
  "test:browser:chrome": "SAMURAI_TEST_BROWSER=chrome npm run test:browser",