@itmetsam/samurai-framework 0.8.0 → 0.10.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 (66) hide show
  1. package/README.md +9 -9
  2. package/dist/browser/browser-finder.js +2 -2
  3. package/dist/browser/browser.d.ts +11 -15
  4. package/dist/browser/browser.js +79 -58
  5. package/dist/browser/chrome-launch.d.ts +69 -0
  6. package/dist/browser/chrome-launch.js +170 -0
  7. package/dist/browser/download-folders.d.ts +27 -0
  8. package/dist/browser/download-folders.js +97 -0
  9. package/dist/browser/page-logs.d.ts +2 -0
  10. package/dist/browser/page-logs.js +25 -5
  11. package/dist/browser/page.d.ts +18 -0
  12. package/dist/browser/page.js +99 -2
  13. package/dist/browser/screenshot.js +16 -2
  14. package/dist/browser/session-capabilities.d.ts +12 -0
  15. package/dist/browser/session-capabilities.js +8 -0
  16. package/dist/browsers/index.d.ts +2 -2
  17. package/dist/browsers/index.js +2 -2
  18. package/dist/browsers/overrides.d.ts +2 -0
  19. package/dist/browsers/overrides.js +2 -0
  20. package/dist/browsers/pins.d.ts +1 -1
  21. package/dist/browsers/pins.js +2 -2
  22. package/dist/browsers/resolve.d.ts +2 -0
  23. package/dist/browsers/resolve.js +14 -2
  24. package/dist/browsers/store.d.ts +2 -0
  25. package/dist/browsers/store.js +8 -0
  26. package/dist/cli/args.d.ts +7 -2
  27. package/dist/cli/args.js +7 -1
  28. package/dist/cli/console-reporter.d.ts +4 -0
  29. package/dist/cli/console-reporter.js +13 -6
  30. package/dist/cli/init.js +1 -1
  31. package/dist/cli/main.d.ts +1 -1
  32. package/dist/cli/main.js +7 -0
  33. package/dist/config/config.js +5 -1
  34. package/dist/flows/run/handlers/support.d.ts +4 -1
  35. package/dist/flows/run/handlers/support.js +1 -1
  36. package/dist/flows/run/project.js +9 -1
  37. package/dist/flows/run/run.d.ts +5 -0
  38. package/dist/flows/run/run.js +7 -1
  39. package/dist/locator/locator.js +21 -2
  40. package/dist/network/data-collector.d.ts +1 -0
  41. package/dist/network/data-collector.js +23 -5
  42. package/dist/network/network-tracker.d.ts +8 -1
  43. package/dist/network/network-tracker.js +42 -3
  44. package/dist/network/router.d.ts +5 -0
  45. package/dist/network/router.js +33 -11
  46. package/dist/recorder/session.d.ts +3 -0
  47. package/dist/recorder/session.js +7 -3
  48. package/dist/recorder/toolbar/controller.d.ts +6 -5
  49. package/dist/recorder/toolbar/controller.js +39 -7
  50. package/dist/runner/browsers.d.ts +22 -0
  51. package/dist/runner/browsers.js +44 -0
  52. package/dist/runner/groups.d.ts +5 -1
  53. package/dist/runner/groups.js +9 -2
  54. package/dist/runner/reporter.d.ts +10 -2
  55. package/dist/runner/reporter.js +30 -10
  56. package/dist/runner/run.d.ts +1 -0
  57. package/dist/runner/run.js +1 -0
  58. package/dist/runner/test-runner.d.ts +9 -1
  59. package/dist/runner/test-runner.js +46 -19
  60. package/dist/script/preload.js +65 -0
  61. package/dist/transport/bidi-connection.d.ts +19 -0
  62. package/dist/transport/bidi-connection.js +34 -1
  63. package/dist/types/browser.d.ts +1 -1
  64. package/dist/types/config.d.ts +5 -3
  65. package/dist/types/test.d.ts +8 -0
  66. package/package.json +3 -2
@@ -4,6 +4,8 @@ import path from "path";
4
4
  import { pathToFileURL } from "url";
5
5
  import { Browser } from "../browser/browser.js";
6
6
  import { ensureWarm } from "../browser/warm-up.js";
7
+ import { projectBrowser } from "../browsers/resolve.js";
8
+ import { browsersFor } from "./browsers.js";
7
9
  import { loadConfig, projectDir, readConfig } from "../config/config.js";
8
10
  import logger from "../logger/index.js";
9
11
  import { assertModuleProject } from "./module-type.js";
@@ -108,34 +110,59 @@ export default class TestRunner {
108
110
  }
109
111
  return all.filter((test) => inGroup(test) && isSelected(test.name, this.options));
110
112
  }
111
- /** Runs the selected tests and returns the summary that was also written to the report */
113
+ /**
114
+ * The browsers the run uses: the options' (`--browser`), else the selected group's, else the project's, else
115
+ * Firefox. A group is only looked up when there is something to look up
116
+ */
117
+ async browsersOfRun() {
118
+ const { browsers: flag, group } = this.options;
119
+ const config = await loadConfig();
120
+ let ofGroup;
121
+ if (group !== undefined && !(flag && flag.length > 0)) {
122
+ const { groups } = await this.resolveAll(await this.registerAll());
123
+ ofGroup = findGroup(groups, group).browsers;
124
+ }
125
+ return browsersFor({ flag, group: ofGroup, project: config.browsers });
126
+ }
127
+ /** Runs the selected tests in every browser of the run, one browser after the other, and returns the summary that was also written to the report */
112
128
  async start() {
113
129
  const { reportPath, onEvent, signal } = this.options;
130
+ const selected = await this.register();
131
+ const browsers = await this.browsersOfRun();
114
132
  const reporter = new TestReporter({
115
133
  environment: this.run.settings.environment,
134
+ browsers,
116
135
  ...(reportPath !== undefined && { output: reportPath }),
117
136
  ...(onEvent && { onEvent }),
118
137
  });
119
- const selected = await this.register();
120
138
  this.srcDir = realDirs((await loadConfig()).srcDir).srcDir;
121
- // A missing browser is one clear error before the run, not a failure per test; a fresh build's slow
122
- // first start happens here, outside any test's timeout
123
- if (selected.length > 0)
124
- await ensureWarm((await readConfig("browser")) ?? "firefox", { port: this.options.port ?? 9223 }, signal);
125
- reporter.onStart(selected.length);
126
- for (const test of selected) {
127
- if (signal?.aborted) {
128
- reporter.onTestStart(test);
129
- reporter.onTestEnd(test, { message: "Run aborted", type: "error" });
130
- continue;
139
+ // A missing browser is one clear error before the run, not a failure per test, and before any browser has run a
140
+ // test; a fresh build's slow first start happens here too, outside any test's timeout
141
+ if (selected.length > 0) {
142
+ for (const browser of browsers)
143
+ await projectBrowser(browser);
144
+ for (const browser of browsers)
145
+ await ensureWarm(browser, { port: this.options.port ?? 9223 }, signal);
146
+ }
147
+ reporter.onStart(selected.length * browsers.length);
148
+ for (const browser of browsers) {
149
+ for (const test of selected) {
150
+ if (signal?.aborted) {
151
+ reporter.onTestStart(test, browser);
152
+ reporter.onTestEnd(test, browser, {
153
+ message: "Run aborted",
154
+ type: "error",
155
+ });
156
+ continue;
157
+ }
158
+ await this.executeTestCase(test, browser, reporter);
131
159
  }
132
- await this.executeTestCase(test, reporter);
133
160
  }
134
161
  return reporter.onEnd();
135
162
  }
136
- async executeTestCase(test, reporter) {
137
- logger.verbose("Starting test: %s", test.name);
138
- reporter.onTestStart(test);
163
+ async executeTestCase(test, selectedBrowser, reporter) {
164
+ logger.verbose("Starting test: %s in %s", test.name, selectedBrowser);
165
+ reporter.onTestStart(test, selectedBrowser);
139
166
  // A test ends once, whichever of finishing, failing, timing out, an abort or a closed browser comes first
140
167
  let ended = false;
141
168
  let evidence;
@@ -143,7 +170,7 @@ export default class TestRunner {
143
170
  if (ended)
144
171
  return;
145
172
  ended = true;
146
- reporter.onTestEnd(test, error, {
173
+ reporter.onTestEnd(test, selectedBrowser, error, {
147
174
  ...logs,
148
175
  screenshots: evidence?.screenshots,
149
176
  });
@@ -151,7 +178,6 @@ export default class TestRunner {
151
178
  let browserClosed = false;
152
179
  /** Set once the runner itself closes the browser */
153
180
  let closing = false;
154
- const selectedBrowser = (await readConfig("browser")) ?? "firefox";
155
181
  const { settings, secrets } = this.run;
156
182
  const timeout = settings.timeout;
157
183
  const { reportPath } = this.options;
@@ -205,11 +231,12 @@ export default class TestRunner {
205
231
  resolve();
206
232
  });
207
233
  page = launched.page;
208
- evidence = new Evidence(resultDir, testDirOf(test, this.srcDir));
234
+ evidence = new Evidence(resultDir, `${selectedBrowser}/${testDirOf(test, this.srcDir)}`);
209
235
  launched.page.setEvidence(evidence);
210
236
  const fixtures = {
211
237
  page: launched.page,
212
238
  browser: launched.browser,
239
+ browserName: selectedBrowser,
213
240
  env: createEnvFixture(settings.environment, settings.variables),
214
241
  secrets: createSecretsFixture(settings.environment, secrets),
215
242
  };
@@ -16,11 +16,24 @@ export async function addPreload(connector, options) {
16
16
  arguments: options.arguments.map((arg) => toLocalValue(arg)),
17
17
  }),
18
18
  });
19
+ let stopRerun;
20
+ if (needsRerun(connector, options)) {
21
+ try {
22
+ stopRerun = await rerunInNewRealms(connector, options);
23
+ }
24
+ catch (err) {
25
+ await connector
26
+ .send("script.removePreloadScript", { script })
27
+ .catch(() => undefined);
28
+ throw err;
29
+ }
30
+ }
19
31
  let removed = false;
20
32
  const dispose = async () => {
21
33
  if (removed)
22
34
  return;
23
35
  removed = true;
36
+ await stopRerun?.();
24
37
  try {
25
38
  await connector.send("script.removePreloadScript", { script });
26
39
  }
@@ -42,6 +55,58 @@ export async function addPreload(connector, options) {
42
55
  }
43
56
  return { id: script, dispose };
44
57
  }
58
+ /**
59
+ * Chrome drops the messages of a channel that a preload script in a sandbox was given, while a channel handed to a
60
+ * call into the same sandbox works. So there the script is also run, with its channels, in every new sandbox realm.
61
+ */
62
+ function needsRerun(connector, options) {
63
+ return (connector.browserName === "chrome" &&
64
+ options.sandbox !== undefined &&
65
+ (options.arguments?.length ?? 0) > 0);
66
+ }
67
+ async function rerunInNewRealms(connector, options) {
68
+ const { contexts, sandbox, source } = options;
69
+ const inScope = async (context) => {
70
+ if (!contexts)
71
+ return true;
72
+ if (contexts.includes(context))
73
+ return true;
74
+ const trees = await Promise.all(contexts.map((root) => connector.send("browsingContext.getTree", { root })));
75
+ return trees.some((tree) => tree.contexts.flatMap(flatten).includes(context));
76
+ };
77
+ const onRealm = (realm) => {
78
+ if (realm.type !== "window" || realm.sandbox !== sandbox || !realm.context)
79
+ return;
80
+ const context = realm.context;
81
+ void (async () => {
82
+ try {
83
+ if (!(await inScope(context)))
84
+ return;
85
+ await callFunction(connector, context, source, options.arguments ?? [], {
86
+ awaitPromise: false,
87
+ ...(sandbox !== undefined && { sandbox }),
88
+ });
89
+ }
90
+ catch (err) {
91
+ // The document went away again, or the script threw: the registration covers what it can
92
+ logger.debug("Could not run a preload script in a new realm", { err });
93
+ }
94
+ })();
95
+ };
96
+ connector.onEvent("script.realmCreated", onRealm);
97
+ let subscription;
98
+ try {
99
+ subscription = await connector.subscribe(["script.realmCreated"]);
100
+ }
101
+ catch (err) {
102
+ connector.offEvent("script.realmCreated", onRealm);
103
+ throw err;
104
+ }
105
+ return async () => {
106
+ connector.offEvent("script.realmCreated", onRealm);
107
+ await subscription.unsubscribe().catch(() => undefined);
108
+ };
109
+ }
45
110
  /** Runs `source` once in every loaded context (iframes included) under `contexts` */
46
111
  export async function runInLoadedContexts(connector, { source, contexts, sandbox, onRunError = "throw", arguments: args = [], }) {
47
112
  const trees = contexts
@@ -25,12 +25,18 @@ export declare class BiDiConnector {
25
25
  private currentId;
26
26
  /** Set once the socket closed or failed: nothing sent after that gets an answer */
27
27
  private closed;
28
+ /** Which browser is on the other end, when the launcher says; code that must treat browsers apart asks this */
29
+ browserName?: "firefox" | "chrome";
28
30
  private webSocket;
29
31
  private eventEmitter;
30
32
  private commandTimeout;
31
33
  private subscriptions;
32
34
  constructor(ws: WebSocket, options?: ConnectorOptions);
33
35
  static connect(url: string, options?: ConnectorOptions): Promise<BiDiConnector>;
36
+ /** Calls `listener` once when the socket closes or fails (not when `kill()` ends it) */
37
+ onClosed(listener: () => void): void;
38
+ private closeListeners;
39
+ private notifyClosed;
34
40
  private onWebsocketClose;
35
41
  private onWebsocketError;
36
42
  private get isClosed();
@@ -59,3 +65,16 @@ export declare class BiDiConnector {
59
65
  private messageListener;
60
66
  kill(): void;
61
67
  }
68
+ /**
69
+ * The message as the debug log gets it: a `script.message` on a private channel is logged by channel and
70
+ * action only, so a value the tester types or a secret added from the recorder toolbar never reaches the log
71
+ * (an added secret is only registered for masking after it is saved).
72
+ */
73
+ export declare function forLog(message: {
74
+ type?: string;
75
+ method?: string;
76
+ params?: {
77
+ channel?: unknown;
78
+ data?: unknown;
79
+ };
80
+ }): unknown;
@@ -22,12 +22,15 @@ export class BiDiConnector {
22
22
  this.currentId = 0;
23
23
  /** Set once the socket closed or failed: nothing sent after that gets an answer */
24
24
  this.closed = false;
25
+ this.closeListeners = new Set();
25
26
  this.onWebsocketClose = () => {
26
27
  this.closed = true;
28
+ this.notifyClosed();
27
29
  this.rejectAll((pending) => new ConnectionClosedError(`Websocket closed${pendingSuffix(pending)}`));
28
30
  };
29
31
  this.onWebsocketError = (ev) => {
30
32
  this.closed = true;
33
+ this.notifyClosed();
31
34
  this.rejectAll((pending) => new ConnectionClosedError(`Websocket error${pendingSuffix(pending)}`, {
32
35
  cause: ev,
33
36
  }));
@@ -84,6 +87,16 @@ export class BiDiConnector {
84
87
  });
85
88
  });
86
89
  }
90
+ /** Calls `listener` once when the socket closes or fails (not when `kill()` ends it) */
91
+ onClosed(listener) {
92
+ this.closeListeners.add(listener);
93
+ }
94
+ notifyClosed() {
95
+ const listeners = [...this.closeListeners];
96
+ this.closeListeners.clear();
97
+ for (const listener of listeners)
98
+ listener();
99
+ }
87
100
  get isClosed() {
88
101
  // A socket that is closing or closed (readyState 2 or 3) has already lost the browser
89
102
  const state = this.webSocket.readyState;
@@ -203,7 +216,27 @@ const PRIVATE_CHANNEL = /^samurai-(recorder|toolbar)-/;
203
216
  * action only, so a value the tester types or a secret added from the recorder toolbar never reaches the log
204
217
  * (an added secret is only registered for masking after it is saved).
205
218
  */
206
- function forLog(message) {
219
+ export function forLog(message) {
220
+ return shortened(redacted(message));
221
+ }
222
+ /** A string longer than this is cut in the log: a file's bytes travel in 1 MB chunks, 50 MB makes 70 MB of debug log */
223
+ const LOG_STRING_LIMIT = 1000;
224
+ /** A copy of `value` with every long string cut to its start and its length; short values come back as they are */
225
+ function shortened(value, depth = 0) {
226
+ if (typeof value === "string")
227
+ return value.length > LOG_STRING_LIMIT
228
+ ? `${value.slice(0, 200)}… (${value.length} characters)`
229
+ : value;
230
+ if (value === null || typeof value !== "object" || depth > 30)
231
+ return value;
232
+ if (Array.isArray(value))
233
+ return value.map((item) => shortened(item, depth + 1));
234
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
235
+ key,
236
+ shortened(item, depth + 1),
237
+ ]));
238
+ }
239
+ function redacted(message) {
207
240
  const channel = message.params?.channel;
208
241
  if (message.type !== "event" ||
209
242
  message.method !== "script.message" ||
@@ -1 +1 @@
1
- export type SupportedBrowser = "firefox";
1
+ export type SupportedBrowser = "firefox" | "chrome";
@@ -24,6 +24,8 @@ export interface SamuraiGroup {
24
24
  * Overrides 'src' and 'testMatch' options.
25
25
  */
26
26
  tests?: SamuraiTest[];
27
+ /** The browsers this group's tests run in, instead of the project's `browsers` */
28
+ browsers?: SupportedBrowser[];
27
29
  }
28
30
 
29
31
  export interface SamuraiEnvironment {
@@ -45,10 +47,10 @@ export interface SamuraiTestConfig {
45
47
  */
46
48
  srcDir?: string;
47
49
  /**
48
- * The browser to use for all tests. Only "firefox" can launch today
49
- * @default "firefox"
50
+ * The browsers the tests run in, one after the other: every selected test runs once in each. A group can name its own.
51
+ * @default ["firefox"]
50
52
  */
51
- browser?: SupportedBrowser;
53
+ browsers?: SupportedBrowser[];
52
54
  /**
53
55
  * Browser binaries to launch instead of the builds SAMURAI downloads, relative to the project.
54
56
  * `SAMURAI_FIREFOX_PATH` / `SAMURAI_CHROME_PATH` win over these.
@@ -1,10 +1,13 @@
1
1
  import type { Browser } from "../browser/browser.ts";
2
2
  import type Page from "../browser/page.ts";
3
+ import type { SupportedBrowser } from "./browser.ts";
3
4
 
4
5
  /** What a test function receives */
5
6
  export interface TestFixtures {
6
7
  page: Page;
7
8
  browser: Browser;
9
+ /** The browser this run of the test is in */
10
+ browserName: SupportedBrowser;
8
11
  /** The chosen environment's variables; reading an unknown name throws */
9
12
  env: Readonly<Record<string, string | number | boolean>>;
10
13
  /** Secrets by name (SAMURAI_SECRET_<NAME>); reading an unset name throws */
@@ -66,12 +69,15 @@ export interface TestLogEntry {
66
69
  interface TestResultIdentity {
67
70
  name: string;
68
71
  file: string;
72
+ /** The browser the test ran in */
73
+ browser: SupportedBrowser;
69
74
  startTime: number;
70
75
  }
71
76
 
72
77
  interface TestResultBase {
73
78
  name: string;
74
79
  file: string;
80
+ browser: SupportedBrowser;
75
81
  startTime: number;
76
82
  status: "started";
77
83
  }
@@ -102,4 +108,6 @@ interface TestSummaryBase {
102
108
  tests: TestResult[];
103
109
  /** The environment the run used */
104
110
  environment: string;
111
+ /** The browsers the run used, in the order they ran */
112
+ browsers: SupportedBrowser[];
105
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.8.0",
3
+ "version": "0.10.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": {
@@ -54,7 +54,8 @@
54
54
  "dev": "tsx src/index.ts",
55
55
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-types.mjs",
56
56
  "test": "tsx --test --test-timeout=10000 \"src/**/*.test.ts\"",
57
- "test:browser": "tsx --test --test-concurrency=1 \"src/**/*.browser-test.ts\"",
57
+ "test:browser": "tsx --test --test-concurrency=1 --test-timeout=60000 \"src/**/*.browser-test.ts\"",
58
+ "test:browser:chrome": "SAMURAI_TEST_BROWSER=chrome npm run test:browser",
58
59
  "test:cli": "tsx --test --test-concurrency=1 \"src/**/*.cli-test.ts\""
59
60
  },
60
61
  "keywords": [