@itmetsam/samurai-framework 0.12.0 → 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
@@ -3,12 +3,16 @@ import path from "path";
3
3
  import { ensureWarm } from "../browser/warm-up.js";
4
4
  import { projectBrowser } from "../browsers/resolve.js";
5
5
  import { loadConfig, projectDir, readConfig } from "../config/config.js";
6
+ import { forbidsOnly } from "../config/forbid-only.js";
6
7
  import logger from "../logger/index.js";
8
+ import { AllHooks } from "./all-hooks.js";
9
+ import { assertNoOnly, narrowToOnly } from "./focus.js";
7
10
  import { DEFAULT_SRC_DIR, DEFAULT_TEST_MATCH } from "./groups.js";
8
11
  import { DEFAULT_PORT } from "./ports.js";
9
12
  import TestReporter from "./reporter.js";
10
- import { planItems, poolMode, runPooled, serialKeyOf, } from "./pooled-run.js";
11
- import { runOneTest } from "./run-test.js";
13
+ import { concurrency, planItems, poolMode, runPooled, serialKeyOf, } from "./pooled-run.js";
14
+ import { allHookFixtures, runOneTest } from "./run-test.js";
15
+ import { staticSkip } from "./skip.js";
12
16
  import { browsersOfRun, loadSpecs, selectTests } from "./select.js";
13
17
  import { resolveWorkers } from "./workers-option.js";
14
18
  import { WorkerPool } from "./worker-pool.js";
@@ -54,7 +58,10 @@ export default class TestRunner {
54
58
  const { reportPath, onEvent, signal } = this.options;
55
59
  // The specs are imported once: their top-level code runs once per run
56
60
  const loaded = await loadSpecs(this.testFiles);
57
- const selected = selectTests(loaded, this.options);
61
+ const config = await loadConfig();
62
+ // A focused test the config refuses stops the run before anything starts; else only the focused ones run
63
+ assertNoOnly(loaded.all, forbidsOnly(config), loaded.srcDir);
64
+ const selected = narrowToOnly(selectTests(loaded, this.options));
58
65
  const browsers = await browsersOfRun(loaded.groups, this.options);
59
66
  const reporter = new TestReporter({
60
67
  environment: this.run.settings.environment,
@@ -63,31 +70,33 @@ export default class TestRunner {
63
70
  ...(onEvent && { onEvent }),
64
71
  });
65
72
  // How the run goes is settled first: a bad `workers` is an error before any browser starts
66
- const config = await loadConfig();
67
73
  const requested = resolveWorkers({
68
74
  flag: this.options.workers,
69
75
  env: process.env.SAMURAI_WORKERS,
70
76
  config: config.workers,
71
77
  });
72
78
  const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir));
79
+ // A pool larger than what can overlap would only hold idle workers: a group that runs one at a time needs one
80
+ const lanes = concurrency(plan.items);
73
81
  const mode = poolMode({
74
82
  workers: requested,
75
- items: plan.items.length,
83
+ items: lanes,
76
84
  inlineConfig: this.project.inlineConfig ?? false,
77
85
  });
78
86
  if (mode === "inline-config" && this.workersAsked(config.workers))
79
87
  logger.warn("workers needs samurai.config.ts in the project folder; running on one worker");
80
88
  // A missing browser is one clear error before the run, not a failure per test, and before any browser has run a
81
89
  // test; a fresh build's slow first start happens here too, outside any test's timeout
82
- if (selected.length > 0) {
90
+ // Skipped tests need no browser, so a run of only those needs none installed
91
+ if (selected.some((test) => !staticSkip(test))) {
83
92
  for (const browser of browsers)
84
93
  await projectBrowser(browser);
85
94
  for (const browser of browsers)
86
95
  await ensureWarm(browser, { port: this.options.port ?? DEFAULT_PORT }, signal);
87
96
  }
88
97
  if (mode === "pool") {
89
- const workers = Math.min(requested, plan.items.length);
90
- reporter.onStart(plan.items.length, workers);
98
+ const workers = Math.min(requested, lanes);
99
+ reporter.onStart(plan.planned.length, workers);
91
100
  const pool = this.createPool(workers);
92
101
  try {
93
102
  await runPooled(pool, plan.planned, plan.items, reporter, signal);
@@ -98,19 +107,35 @@ export default class TestRunner {
98
107
  return reporter.onEnd();
99
108
  }
100
109
  reporter.onStart(selected.length * browsers.length);
101
- for (const browser of browsers) {
102
- for (const test of selected) {
103
- if (signal?.aborted) {
104
- reporter.onTestStart(test, browser);
105
- reporter.onTestEnd(test, browser, {
106
- message: "Run aborted",
107
- type: "error",
108
- });
109
- continue;
110
+ // The `afterAll` hooks run when the last test is done, before the run ends
111
+ const allHooks = new AllHooks({
112
+ timeout: this.run.settings.timeout,
113
+ fixtures: (browser) => allHookFixtures(this.run, browser),
114
+ });
115
+ try {
116
+ for (const browser of browsers) {
117
+ for (const test of selected) {
118
+ const annotation = staticSkip(test);
119
+ if (annotation) {
120
+ reporter.onTestSkipped(test, browser, { annotation });
121
+ continue;
122
+ }
123
+ if (signal?.aborted) {
124
+ reporter.onTestStart(test, browser);
125
+ reporter.onTestEnd(test, browser, {
126
+ message: "Run aborted",
127
+ type: "error",
128
+ });
129
+ continue;
130
+ }
131
+ await runOneTest(test, browser, this.run, this.options, loaded.srcDir, reporter, { allHooks });
110
132
  }
111
- await runOneTest(test, browser, this.run, this.options, loaded.srcDir, reporter);
112
133
  }
113
134
  }
135
+ finally {
136
+ for (const error of await allHooks.closeAll())
137
+ logger.error("%s", error.message);
138
+ }
114
139
  return reporter.onEnd();
115
140
  }
116
141
  /** Whether anything but the default says how many workers to use */
@@ -17,6 +17,8 @@ export interface PoolOptions {
17
17
  factory: WorkerFactory;
18
18
  /** The abort grace period in ms; 2000 by default */
19
19
  graceMs?: number;
20
+ /** How long `dispose` waits for idle workers to leave on their own, in ms: their `afterAll` hooks run first. 60000 by default */
21
+ shutdownMs?: number;
20
22
  }
21
23
  export interface RunHooks {
22
24
  onEvent?: (event: PoolEvent) => void;
@@ -40,8 +42,13 @@ export declare class WorkerPool {
40
42
  constructor(options: PoolOptions);
41
43
  /** Results in item order; may be called several times at once (flows): all calls share the cap */
42
44
  run(items: WorkItem[], hooks?: RunHooks): Promise<TestResult[]>;
43
- /** Tells every worker to exit and kills it; safe to call twice */
45
+ /**
46
+ * Ends the pool; safe to call twice. A worker that has nothing to do is told to exit and given time to leave
47
+ * (it runs its `afterAll` hooks first); one that is busy is killed, as nobody waits for its test.
48
+ */
44
49
  dispose(): Promise<void>;
50
+ /** Asks the idle workers to exit and waits for them (up to `shutdownMs`), kills the busy ones and the stragglers */
51
+ private sendHome;
45
52
  /** Hands queued items to ready workers, then starts workers for what is left */
46
53
  private pump;
47
54
  private busyKeys;
@@ -1,5 +1,6 @@
1
1
  /** How long a busy worker may take to wind down after an abort before it is killed */
2
2
  const GRACE_MS = 2000;
3
+ const SHUTDOWN_MS = 60000;
3
4
  /** Workers that die before they are ready this many times in a row fail the run, instead of being replaced for ever */
4
5
  const STARTUP_ATTEMPTS = 3;
5
6
  /**
@@ -49,14 +50,45 @@ export class WorkerPool {
49
50
  this.pump();
50
51
  });
51
52
  }
52
- /** Tells every worker to exit and kills it; safe to call twice */
53
+ /**
54
+ * Ends the pool; safe to call twice. A worker that has nothing to do is told to exit and given time to leave
55
+ * (it runs its `afterAll` hooks first); one that is busy is killed, as nobody waits for its test.
56
+ */
53
57
  async dispose() {
54
58
  if (this.disposed)
55
59
  return;
56
60
  this.disposed = true;
57
61
  this.queue.length = 0;
58
- this.stopWorkers(true);
62
+ const leaving = this.sendHome();
59
63
  this.rejectAll(new Error("WorkerPool.dispose(): the pool was disposed during a run"));
64
+ await leaving;
65
+ }
66
+ /** Asks the idle workers to exit and waits for them (up to `shutdownMs`), kills the busy ones and the stragglers */
67
+ async sendHome() {
68
+ for (const timer of this.timers)
69
+ clearTimeout(timer);
70
+ this.timers.clear();
71
+ const slots = [...this.slots.values()];
72
+ this.slots.clear();
73
+ const gone = [];
74
+ for (const { handle, busy } of slots) {
75
+ if (busy) {
76
+ handle.kill();
77
+ continue;
78
+ }
79
+ gone.push(new Promise((resolve) => handle.onExit(() => resolve())));
80
+ handle.send({ type: "exit" });
81
+ }
82
+ let timer;
83
+ await Promise.race([
84
+ Promise.all(gone),
85
+ new Promise((resolve) => {
86
+ timer = setTimeout(resolve, this.options.shutdownMs ?? SHUTDOWN_MS);
87
+ }),
88
+ ]);
89
+ clearTimeout(timer);
90
+ for (const { handle } of slots)
91
+ handle.kill();
60
92
  }
61
93
  /** Hands queued items to ready workers, then starts workers for what is left */
62
94
  pump() {
@@ -1,7 +1,9 @@
1
1
  import { workerProfileDir } from "../browser/launch-flags.js";
2
+ import logger from "../logger/index.js";
2
3
  import { withProject } from "./project.js";
3
4
  import TestReporter from "./reporter.js";
4
- import { runOneTest } from "./run-test.js";
5
+ import { AllHooks } from "./all-hooks.js";
6
+ import { allHookFixtures, runOneTest } from "./run-test.js";
5
7
  import { loadSpecs } from "./select.js";
6
8
  /**
7
9
  * Opens the project for a worker and keeps it open until `close`: `withProject` holds the config, environment
@@ -26,6 +28,11 @@ export function openSession(init) {
26
28
  ...(headless !== undefined && { headless }),
27
29
  }, async (run) => {
28
30
  const loaded = await loadSpecs(init.files);
31
+ // The `beforeAll` hooks this worker ran: their `afterAll` hooks run when it closes
32
+ const allHooks = new AllHooks({
33
+ timeout: run.settings.timeout,
34
+ fixtures: (browser) => allHookFixtures(run, browser, workerIndex),
35
+ });
29
36
  resolve({
30
37
  tests: loaded.all,
31
38
  async runTest(test, browser, signal, onEvent) {
@@ -46,9 +53,12 @@ export function openSession(init) {
46
53
  port,
47
54
  profileDir: workerProfileDir(browser, workerIndex),
48
55
  workerIndex,
56
+ allHooks,
49
57
  });
50
58
  },
51
59
  async close() {
60
+ for (const error of await allHooks.closeAll())
61
+ logger.error("%s", error.message);
52
62
  release();
53
63
  await finished;
54
64
  },
@@ -44,11 +44,27 @@ 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) &&
50
+ ts.isIdentifier(expression.expression) &&
51
+ expression.expression.text === "describe" &&
52
+ ["skip", "fixme", "only"].includes(expression.name.text));
53
+ }
47
54
  /** The tests in a spec file, with their steps and where each statement sits */
48
55
  export function parseSpec(source) {
49
56
  const sourceFile = ts.createSourceFile("spec.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
50
57
  const tests = [];
51
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
+ }
52
68
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
53
69
  const title = stringOf(node.arguments[0]);
54
70
  const body = callbackBody(node.arguments[1]);
@@ -28,7 +28,8 @@ export interface SamuraiGroup {
28
28
  browsers?: SupportedBrowser[];
29
29
  /**
30
30
  * How many of this group's tests may run at the same time. Only `1` has an effect: the group's tests run one
31
- * after the other, even when the run uses more workers (for tests that share an account or data).
31
+ * after the other, even when the run uses more workers (for tests that share an account or data). A run of
32
+ * nothing but such a group uses one worker.
32
33
  */
33
34
  workers?: number;
34
35
  }
@@ -63,6 +64,12 @@ export interface SamuraiTestConfig {
63
64
  * @default half the CPUs
64
65
  */
65
66
  workers?: number | string;
67
+ /**
68
+ * Refuse a run when a spec contains `test.only` or `describe.only`, so a forgotten focus cannot quietly shrink a
69
+ * suite. The run stops before any test starts and lists the tests.
70
+ * @default true when the `CI` environment variable is set, else false
71
+ */
72
+ forbidOnly?: boolean;
66
73
  /**
67
74
  * Browser binaries to launch instead of the builds SAMURAI downloads, relative to the project.
68
75
  * `SAMURAI_FIREFOX_PATH` / `SAMURAI_CHROME_PATH` win over these.
@@ -18,11 +18,42 @@ export interface TestFixtures {
18
18
 
19
19
  export type TestCase = TestCaseBase;
20
20
 
21
+ /** `skip`: not run. `fixme`: not run, known to be broken. `only`: the only test that runs when any is focused */
22
+ export type Modifier = "skip" | "fixme" | "only";
23
+
24
+ /** Runs before or after each test of a suite, in the test's browser, with the test's fixtures */
25
+ export type EachHook = (fixtures: TestFixtures) => Promise<void> | void;
26
+
27
+ /** What `beforeAll` and `afterAll` receive: no page, as they run once for many tests */
28
+ export type AllHookFixtures = Pick<
29
+ TestFixtures,
30
+ "env" | "secrets" | "browserName" | "workerIndex"
31
+ >;
32
+
33
+ /** Runs once per worker before the first test of a suite, or after the last one the worker ran */
34
+ export type AllHook = (fixtures: AllHookFixtures) => Promise<void> | void;
35
+
36
+ /** A spec file (the root) or a `describe` block: what a modifier or a hook applies to */
37
+ export interface Suite {
38
+ /** The `describe` title; the root of a file has none */
39
+ title?: string;
40
+ parent?: Suite;
41
+ modifier?: Modifier;
42
+ beforeEach?: EachHook[];
43
+ afterEach?: EachHook[];
44
+ beforeAll?: AllHook[];
45
+ afterAll?: AllHook[];
46
+ }
47
+
21
48
  export interface RegisteredTestCase extends TestCaseBase {
22
49
  /** Path of the spec file that registered the test */
23
50
  file: string;
24
51
  /** describe titles, then the test title */
25
52
  titlePath: string[];
53
+ /** The `describe` block (or the file) the test is written in */
54
+ suite: Suite;
55
+ /** What applies to the test: its own modifier, or the one of a suite around it */
56
+ modifier?: Modifier;
26
57
  }
27
58
 
28
59
  export interface TestCaseBase {
@@ -36,7 +67,8 @@ export interface PartialTestResult extends TestResultBase {
36
67
  file: string;
37
68
  }
38
69
 
39
- export type TestResult = TestResultSuccess | TestResultFailed | TestResultBase;
70
+ export type TestResult =
71
+ TestResultSuccess | TestResultFailed | TestResultSkipped | TestResultBase;
40
72
 
41
73
  interface TestResultSuccess extends TestResultIdentity {
42
74
  status: "success";
@@ -58,6 +90,15 @@ interface TestResultFailed extends TestResultIdentity, TestError {
58
90
  screenshots?: { name: string; path: string }[];
59
91
  }
60
92
 
93
+ /** A test that was not run: marked `skip` or `fixme`, or skipped from inside with `test.skip(condition)` */
94
+ interface TestResultSkipped extends TestResultIdentity {
95
+ status: "skipped";
96
+ duration: 0;
97
+ /** `"fixme"` for a test known to be broken, `"skip"` for the rest */
98
+ annotation: "skip" | "fixme";
99
+ reason?: string;
100
+ }
101
+
61
102
  /** One line of the browser's log, as stored in the report */
62
103
  export interface TestLogEntry {
63
104
  level: "debug" | "info" | "warn" | "error";
@@ -100,6 +141,7 @@ export interface TestError {
100
141
  export type TestSummary = TestSummarySuccess | TestSummaryFailed;
101
142
 
102
143
  interface TestSummarySuccess extends TestSummaryBase {
144
+ /** No test failed (skipped tests do not count as failed) */
103
145
  status: "success";
104
146
  }
105
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.12.0",
3
+ "version": "0.15.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": {
@@ -53,10 +53,10 @@
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
- "test": "tsx --test --test-timeout=10000 \"src/**/*.test.ts\"",
57
- "test:browser": "tsx --test --test-concurrency=1 --test-timeout=60000 \"src/**/*.browser-test.ts\"",
56
+ "test": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-timeout=10000 \"src/**/*.test.ts\"",
57
+ "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
58
  "test:browser:chrome": "SAMURAI_TEST_BROWSER=chrome npm run test:browser",
59
- "test:cli": "tsx --test --test-concurrency=1 \"src/**/*.cli-test.ts\""
59
+ "test:cli": "tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 \"src/**/*.cli-test.ts\""
60
60
  },
61
61
  "keywords": [
62
62
  "testing",