@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.
- package/README.md +9 -9
- package/dist/browser/browser-finder.js +2 -2
- package/dist/browser/browser.d.ts +11 -15
- package/dist/browser/browser.js +79 -58
- package/dist/browser/chrome-launch.d.ts +69 -0
- package/dist/browser/chrome-launch.js +170 -0
- package/dist/browser/download-folders.d.ts +27 -0
- package/dist/browser/download-folders.js +97 -0
- package/dist/browser/page-logs.d.ts +2 -0
- package/dist/browser/page-logs.js +25 -5
- package/dist/browser/page.d.ts +18 -0
- package/dist/browser/page.js +99 -2
- package/dist/browser/screenshot.js +16 -2
- package/dist/browser/session-capabilities.d.ts +12 -0
- package/dist/browser/session-capabilities.js +8 -0
- package/dist/browsers/index.d.ts +2 -2
- package/dist/browsers/index.js +2 -2
- package/dist/browsers/overrides.d.ts +2 -0
- package/dist/browsers/overrides.js +2 -0
- package/dist/browsers/pins.d.ts +1 -1
- package/dist/browsers/pins.js +2 -2
- package/dist/browsers/resolve.d.ts +2 -0
- package/dist/browsers/resolve.js +14 -2
- package/dist/browsers/store.d.ts +2 -0
- package/dist/browsers/store.js +8 -0
- package/dist/cli/args.d.ts +7 -2
- package/dist/cli/args.js +7 -1
- package/dist/cli/console-reporter.d.ts +4 -0
- package/dist/cli/console-reporter.js +13 -6
- package/dist/cli/init.js +1 -1
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +7 -0
- package/dist/config/config.js +5 -1
- package/dist/flows/run/handlers/support.d.ts +4 -1
- package/dist/flows/run/handlers/support.js +1 -1
- package/dist/flows/run/project.js +9 -1
- package/dist/flows/run/run.d.ts +5 -0
- package/dist/flows/run/run.js +7 -1
- package/dist/locator/locator.js +21 -2
- package/dist/network/data-collector.d.ts +1 -0
- package/dist/network/data-collector.js +23 -5
- package/dist/network/network-tracker.d.ts +8 -1
- package/dist/network/network-tracker.js +42 -3
- package/dist/network/router.d.ts +5 -0
- package/dist/network/router.js +33 -11
- package/dist/recorder/session.d.ts +3 -0
- package/dist/recorder/session.js +7 -3
- package/dist/recorder/toolbar/controller.d.ts +6 -5
- package/dist/recorder/toolbar/controller.js +39 -7
- package/dist/runner/browsers.d.ts +22 -0
- package/dist/runner/browsers.js +44 -0
- package/dist/runner/groups.d.ts +5 -1
- package/dist/runner/groups.js +9 -2
- package/dist/runner/reporter.d.ts +10 -2
- package/dist/runner/reporter.js +30 -10
- package/dist/runner/run.d.ts +1 -0
- package/dist/runner/run.js +1 -0
- package/dist/runner/test-runner.d.ts +9 -1
- package/dist/runner/test-runner.js +46 -19
- package/dist/script/preload.js +65 -0
- package/dist/transport/bidi-connection.d.ts +19 -0
- package/dist/transport/bidi-connection.js +34 -1
- package/dist/types/browser.d.ts +1 -1
- package/dist/types/config.d.ts +5 -3
- package/dist/types/test.d.ts +8 -0
- package/package.json +3 -2
package/dist/network/router.js
CHANGED
|
@@ -2,13 +2,16 @@ import { STATUS_CODES } from "node:http";
|
|
|
2
2
|
import logger from "../logger/index.js";
|
|
3
3
|
import { BiDiError } from "../transport/bidi-error.js";
|
|
4
4
|
import { toRequest } from "./network-tracker.js";
|
|
5
|
-
import { isExactString, matchUrl, samePattern } from "./url-match.js";
|
|
5
|
+
import { isExactString, matchUrl, samePattern, } from "./url-match.js";
|
|
6
6
|
/** Time (ms) a handler may take to answer a request before the router lets it continue */
|
|
7
7
|
const DEFAULT_ROUTE_TIMEOUT = 30000;
|
|
8
8
|
/** How many handler errors the router keeps */
|
|
9
9
|
const MAX_ERRORS = 100;
|
|
10
10
|
function toHeaders(headers) {
|
|
11
|
-
return Object.entries(headers).map(([name, value]) => ({
|
|
11
|
+
return Object.entries(headers).map(([name, value]) => ({
|
|
12
|
+
name,
|
|
13
|
+
value: { type: "string", value },
|
|
14
|
+
}));
|
|
12
15
|
}
|
|
13
16
|
function toBytes(data) {
|
|
14
17
|
return typeof data === "string"
|
|
@@ -44,7 +47,8 @@ export class Route {
|
|
|
44
47
|
const text = options.json !== undefined ? JSON.stringify(options.json) : undefined;
|
|
45
48
|
const body = options.body ?? text;
|
|
46
49
|
const headers = { ...options.headers };
|
|
47
|
-
const contentType = options.contentType ??
|
|
50
|
+
const contentType = options.contentType ??
|
|
51
|
+
(text !== undefined ? "application/json" : undefined);
|
|
48
52
|
if (contentType && !hasHeader(headers, "content-type"))
|
|
49
53
|
headers["content-type"] = contentType;
|
|
50
54
|
if (!hasHeader(headers, "content-length")) {
|
|
@@ -67,14 +71,21 @@ export class Route {
|
|
|
67
71
|
let headers = options.headers;
|
|
68
72
|
if (options.postData !== undefined) {
|
|
69
73
|
const withoutLength = Object.fromEntries(Object.entries(headers ?? this.info.headers).filter(([name]) => name.toLowerCase() !== "content-length"));
|
|
70
|
-
headers =
|
|
74
|
+
headers = this.deps.sendsContentLength
|
|
75
|
+
? {
|
|
76
|
+
...withoutLength,
|
|
77
|
+
"content-length": String(Buffer.byteLength(options.postData)),
|
|
78
|
+
}
|
|
79
|
+
: withoutLength;
|
|
71
80
|
}
|
|
72
81
|
await this.answer("network.continueRequest", {
|
|
73
82
|
request: this.info.id,
|
|
74
83
|
...(options.url !== undefined && { url: options.url }),
|
|
75
84
|
...(options.method !== undefined && { method: options.method }),
|
|
76
85
|
...(headers !== undefined && { headers: toHeaders(headers) }),
|
|
77
|
-
...(options.postData !== undefined && {
|
|
86
|
+
...(options.postData !== undefined && {
|
|
87
|
+
body: toBytes(options.postData),
|
|
88
|
+
}),
|
|
78
89
|
});
|
|
79
90
|
}
|
|
80
91
|
/** Makes the request fail (the browser reports a network error to the page) */
|
|
@@ -146,7 +157,9 @@ export class Router {
|
|
|
146
157
|
if (!(params.intercepts ?? []).some((id) => this.interceptIds.has(id)))
|
|
147
158
|
return;
|
|
148
159
|
const info = toRequest(params.request, params.navigation, params.isBlocked, null);
|
|
149
|
-
const matching = this.routes
|
|
160
|
+
const matching = this.routes
|
|
161
|
+
.filter((entry) => matchUrl(entry.pattern, info.url))
|
|
162
|
+
.reverse();
|
|
150
163
|
this.dispatch(info, matching, 0).catch((err) => logger.error("Handling the request to %s failed", info.url, { err }));
|
|
151
164
|
};
|
|
152
165
|
this.routeTimeout = options.routeTimeout ?? DEFAULT_ROUTE_TIMEOUT;
|
|
@@ -171,7 +184,8 @@ export class Router {
|
|
|
171
184
|
}
|
|
172
185
|
/** Removes the routes for `pattern` (only the one with `handler`, when given) */
|
|
173
186
|
async unroute(pattern, handler) {
|
|
174
|
-
const removed = this.routes.filter((route) => samePattern(route.pattern, pattern) &&
|
|
187
|
+
const removed = this.routes.filter((route) => samePattern(route.pattern, pattern) &&
|
|
188
|
+
(handler === undefined || route.handler === handler));
|
|
175
189
|
await this.remove(removed);
|
|
176
190
|
}
|
|
177
191
|
async unrouteAll() {
|
|
@@ -189,7 +203,9 @@ export class Router {
|
|
|
189
203
|
return;
|
|
190
204
|
this.routes = this.routes.filter((route) => !removed.includes(route));
|
|
191
205
|
// Requests a removed route still holds would wait for nobody
|
|
192
|
-
await Promise.all([...this.pending]
|
|
206
|
+
await Promise.all([...this.pending]
|
|
207
|
+
.filter((route) => removed.includes(route.entry))
|
|
208
|
+
.map((route) => route.release()));
|
|
193
209
|
await this.enqueue(() => this.sync());
|
|
194
210
|
}
|
|
195
211
|
enqueue(work) {
|
|
@@ -209,7 +225,8 @@ export class Router {
|
|
|
209
225
|
const id = await this.addIntercept(patterns);
|
|
210
226
|
const previous = this.currentId;
|
|
211
227
|
this.currentId = id;
|
|
212
|
-
this.currentKey =
|
|
228
|
+
this.currentKey =
|
|
229
|
+
patterns && !patternsUnsupported.has(this.connector) ? key : "all";
|
|
213
230
|
this.interceptIds.add(id);
|
|
214
231
|
// The new intercept is in place before the old one goes: nothing slips through in between
|
|
215
232
|
if (previous !== undefined)
|
|
@@ -243,7 +260,9 @@ export class Router {
|
|
|
243
260
|
return intercept;
|
|
244
261
|
}
|
|
245
262
|
catch (err) {
|
|
246
|
-
if (patterns &&
|
|
263
|
+
if (patterns &&
|
|
264
|
+
err instanceof BiDiError &&
|
|
265
|
+
err.code === "invalid argument") {
|
|
247
266
|
patternsUnsupported.add(this.connector);
|
|
248
267
|
return this.addIntercept(undefined);
|
|
249
268
|
}
|
|
@@ -267,7 +286,9 @@ export class Router {
|
|
|
267
286
|
return;
|
|
268
287
|
this.connector.onEvent("network.beforeRequestSent", this.onBeforeRequestSent);
|
|
269
288
|
try {
|
|
270
|
-
this.subscription = await this.connector.subscribe([
|
|
289
|
+
this.subscription = await this.connector.subscribe([
|
|
290
|
+
"network.beforeRequestSent",
|
|
291
|
+
]);
|
|
271
292
|
}
|
|
272
293
|
catch (err) {
|
|
273
294
|
this.connector.offEvent("network.beforeRequestSent", this.onBeforeRequestSent);
|
|
@@ -296,6 +317,7 @@ export class Router {
|
|
|
296
317
|
settled: (settled) => {
|
|
297
318
|
this.pending.delete(settled);
|
|
298
319
|
},
|
|
320
|
+
sendsContentLength: this.connector.browserName !== "chrome",
|
|
299
321
|
};
|
|
300
322
|
if (!entry) {
|
|
301
323
|
// Nobody (left) to ask: an unmatched request, or every route fell back
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Browser } from "../browser/browser.js";
|
|
2
2
|
import type Page from "../browser/page.js";
|
|
3
|
+
import type { SupportedBrowser } from "../types/browser.js";
|
|
3
4
|
import type { PreparedRun } from "../runner/prepare-run.js";
|
|
4
5
|
import { type ProjectOptions } from "../runner/project.js";
|
|
5
6
|
import type { SamuraiTestConfig } from "../types/config.js";
|
|
@@ -20,6 +21,8 @@ export interface RecordSpecOptions extends ProjectOptions {
|
|
|
20
21
|
at?: number;
|
|
21
22
|
/** The page to start on. @default the environment's base URL; without one the browser starts blank */
|
|
22
23
|
url?: string;
|
|
24
|
+
/** The browser to record in. @default the first of the config's `browsers`, else Firefox */
|
|
25
|
+
browser?: SupportedBrowser;
|
|
23
26
|
/** The browser's debugging port. @default 9223 */
|
|
24
27
|
port?: number;
|
|
25
28
|
/** Run the browser without a window. A person can't use it then; for tests and machines without a display. @default the config's `use.headless`, else headless on CI and a window otherwise */
|
package/dist/recorder/session.js
CHANGED
|
@@ -2,7 +2,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Browser } from "../browser/browser.js";
|
|
4
4
|
import { ensureWarm } from "../browser/warm-up.js";
|
|
5
|
-
import {
|
|
5
|
+
import { browsersFor } from "../runner/browsers.js";
|
|
6
|
+
import { loadConfig, projectDir, readConfig } from "../config/config.js";
|
|
6
7
|
import { loadSecrets } from "../config/secrets.js";
|
|
7
8
|
import { filesFolder, isFolderIgnored, isGitIgnored, } from "../project/index.js";
|
|
8
9
|
import { withProject } from "../runner/project.js";
|
|
@@ -101,7 +102,10 @@ export async function recordSpec(options) {
|
|
|
101
102
|
throw new Error(`${options.file} does not exist; give a title with --new to create it`);
|
|
102
103
|
}
|
|
103
104
|
// Before the spec is touched: a recording without its browser changes nothing
|
|
104
|
-
|
|
105
|
+
// Recording in two browsers at once makes no sense: the first of the project's, or the one asked for
|
|
106
|
+
const browserName = options.browser ??
|
|
107
|
+
browsersFor({ project: await readConfig("browsers") })[0];
|
|
108
|
+
await ensureWarm(browserName, { port: options.port ?? 9223 }, options.signal);
|
|
105
109
|
options.signal?.throwIfAborted();
|
|
106
110
|
source ??= "";
|
|
107
111
|
if (options.create !== undefined &&
|
|
@@ -117,7 +121,7 @@ export async function recordSpec(options) {
|
|
|
117
121
|
if (!Number.isInteger(at) || at < 0 || at > test.steps.length) {
|
|
118
122
|
throw new RangeError(`--at must be between 0 and ${test.steps.length} (the test has ${test.steps.length} steps)`);
|
|
119
123
|
}
|
|
120
|
-
const { browser, page } = await Browser.launch(
|
|
124
|
+
const { browser, page } = await Browser.launch(browserName, {
|
|
121
125
|
port: options.port ?? 9223,
|
|
122
126
|
headless: options.headless ?? settings.headless,
|
|
123
127
|
}, options.signal);
|
|
@@ -46,11 +46,6 @@ export interface ControllerOptions {
|
|
|
46
46
|
type Message = ToolbarMessage & {
|
|
47
47
|
target?: ElementHandle;
|
|
48
48
|
};
|
|
49
|
-
/**
|
|
50
|
-
* The recorder's side of the toolbar: owns its state (pause, the rail, the environment's variables and
|
|
51
|
-
* secrets) and turns its messages into observations. Secret values stay here; the page only ever gets
|
|
52
|
-
* their names and whether they are set.
|
|
53
|
-
*/
|
|
54
49
|
export declare class ToolbarController {
|
|
55
50
|
private readonly deps;
|
|
56
51
|
private readonly options;
|
|
@@ -79,6 +74,8 @@ export declare class ToolbarController {
|
|
|
79
74
|
private readonly done;
|
|
80
75
|
/** Bumped on every page load: work started for an older page is not answered */
|
|
81
76
|
private page;
|
|
77
|
+
/** Wake-ups for the work that is waiting on the current page: called, and forgotten, when the page changes */
|
|
78
|
+
private pageGone;
|
|
82
79
|
private toolbar;
|
|
83
80
|
/** A `ready` arrived since the last load: it counts for the next one */
|
|
84
81
|
private readySinceLoad;
|
|
@@ -104,6 +101,10 @@ export declare class ToolbarController {
|
|
|
104
101
|
* and doesn't count for the coming load
|
|
105
102
|
*/
|
|
106
103
|
navigating(): void;
|
|
104
|
+
/** Lets what waits on the current document stop waiting: its commands may never be answered once it is gone */
|
|
105
|
+
private leavePage;
|
|
106
|
+
/** Runs `work`, but stops waiting for it when the page is left; resolves `gone` then */
|
|
107
|
+
private whileOnPage;
|
|
107
108
|
/** A step was emitted (any source): count it */
|
|
108
109
|
stepAdded(step: Step): void;
|
|
109
110
|
/** A recorded step was taken out again (a cancelled file picker's click) */
|
|
@@ -22,6 +22,8 @@ const fail = (error, field) => ({
|
|
|
22
22
|
* secrets) and turns its messages into observations. Secret values stay here; the page only ever gets
|
|
23
23
|
* their names and whether they are set.
|
|
24
24
|
*/
|
|
25
|
+
/** What a wait on the page ends with when the page was left first */
|
|
26
|
+
const LEFT = Symbol("left the page");
|
|
25
27
|
export class ToolbarController {
|
|
26
28
|
constructor(deps, options) {
|
|
27
29
|
this.deps = deps;
|
|
@@ -41,6 +43,8 @@ export class ToolbarController {
|
|
|
41
43
|
this.done = new Set();
|
|
42
44
|
/** Bumped on every page load: work started for an older page is not answered */
|
|
43
45
|
this.page = 0;
|
|
46
|
+
/** Wake-ups for the work that is waiting on the current page: called, and forgotten, when the page changes */
|
|
47
|
+
this.pageGone = new Set();
|
|
44
48
|
this.toolbar = true;
|
|
45
49
|
/** A `ready` arrived since the last load: it counts for the next one */
|
|
46
50
|
this.readySinceLoad = false;
|
|
@@ -173,6 +177,7 @@ export class ToolbarController {
|
|
|
173
177
|
if (this.disposed)
|
|
174
178
|
return;
|
|
175
179
|
this.page++;
|
|
180
|
+
this.leavePage();
|
|
176
181
|
this.pick = undefined;
|
|
177
182
|
this.toast = undefined;
|
|
178
183
|
this.push();
|
|
@@ -197,6 +202,28 @@ export class ToolbarController {
|
|
|
197
202
|
*/
|
|
198
203
|
navigating() {
|
|
199
204
|
this.readySinceLoad = false;
|
|
205
|
+
this.leavePage();
|
|
206
|
+
}
|
|
207
|
+
/** Lets what waits on the current document stop waiting: its commands may never be answered once it is gone */
|
|
208
|
+
leavePage() {
|
|
209
|
+
const waiting = [...this.pageGone];
|
|
210
|
+
this.pageGone.clear();
|
|
211
|
+
for (const wake of waiting)
|
|
212
|
+
wake();
|
|
213
|
+
}
|
|
214
|
+
/** Runs `work`, but stops waiting for it when the page is left; resolves `gone` then */
|
|
215
|
+
async whileOnPage(work, gone) {
|
|
216
|
+
let wake;
|
|
217
|
+
const left = new Promise((resolve) => {
|
|
218
|
+
wake = () => resolve(gone);
|
|
219
|
+
this.pageGone.add(wake);
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
return await Promise.race([work(), left]);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
this.pageGone.delete(wake);
|
|
226
|
+
}
|
|
200
227
|
}
|
|
201
228
|
/** A step was emitted (any source): count it */
|
|
202
229
|
stepAdded(step) {
|
|
@@ -310,19 +337,24 @@ export class ToolbarController {
|
|
|
310
337
|
return this.reply(message.id, fail(PAUSED));
|
|
311
338
|
let found;
|
|
312
339
|
try {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
340
|
+
// Chrome never answers a command into a document that is being replaced, so a pick of the old page must not
|
|
341
|
+
// keep the messages behind it waiting
|
|
342
|
+
found = await this.whileOnPage(async () => {
|
|
343
|
+
const locator = message.target &&
|
|
344
|
+
(await this.deps.rank(message.target, message.candidates));
|
|
345
|
+
if (!locator)
|
|
346
|
+
return undefined;
|
|
347
|
+
const inspected = await this.deps.inspect(message.target, locator, message.candidates);
|
|
317
348
|
if (page === this.page)
|
|
318
|
-
this.pick = { id: message.id, locator, counts:
|
|
319
|
-
|
|
349
|
+
this.pick = { id: message.id, locator, counts: inspected.counts };
|
|
350
|
+
return inspected;
|
|
351
|
+
}, LEFT);
|
|
320
352
|
}
|
|
321
353
|
catch {
|
|
322
354
|
found = undefined; // the element went away while it was inspected
|
|
323
355
|
}
|
|
324
356
|
// The page that asked is gone: its successor's toolbar isn't waiting for this
|
|
325
|
-
if (page !== this.page)
|
|
357
|
+
if (found === LEFT || page !== this.page)
|
|
326
358
|
return;
|
|
327
359
|
this.reply(message.id, found ? this.withoutSecrets(found.info) : fail(UNIDENTIFIABLE));
|
|
328
360
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SupportedBrowser } from "../types/browser.js";
|
|
2
|
+
import type { SamuraiTestConfig } from "../types/config.js";
|
|
3
|
+
/** Every browser a config may name, in the order the messages list them */
|
|
4
|
+
export declare const BROWSER_NAMES: readonly SupportedBrowser[];
|
|
5
|
+
/** The browsers of a run that names none */
|
|
6
|
+
export declare const DEFAULT_BROWSERS: readonly SupportedBrowser[];
|
|
7
|
+
/** Throws when `list` is not a non-empty list of distinct browser names; `where` names the key in the message */
|
|
8
|
+
export declare function checkBrowserList(list: unknown, where: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Checks the browser keys of a config: the removed `browser`, and `browsers` on the project and on each group.
|
|
11
|
+
* Throws an Error that says what to write instead.
|
|
12
|
+
*/
|
|
13
|
+
export declare function validateBrowserConfig(config: SamuraiTestConfig): void;
|
|
14
|
+
/**
|
|
15
|
+
* The browsers a run uses, the first that is set winning: the command line, the group being run, the project, then
|
|
16
|
+
* Firefox. An empty list counts as not given.
|
|
17
|
+
*/
|
|
18
|
+
export declare function browsersFor(sources: {
|
|
19
|
+
flag?: readonly SupportedBrowser[] | undefined;
|
|
20
|
+
group?: readonly SupportedBrowser[] | undefined;
|
|
21
|
+
project?: readonly SupportedBrowser[] | undefined;
|
|
22
|
+
}): SupportedBrowser[];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Every browser a config may name, in the order the messages list them */
|
|
2
|
+
export const BROWSER_NAMES = ["firefox", "chrome"];
|
|
3
|
+
/** The browsers of a run that names none */
|
|
4
|
+
export const DEFAULT_BROWSERS = ["firefox"];
|
|
5
|
+
const isBrowser = (value) => typeof value === "string" &&
|
|
6
|
+
BROWSER_NAMES.includes(value);
|
|
7
|
+
/** Throws when `list` is not a non-empty list of distinct browser names; `where` names the key in the message */
|
|
8
|
+
export function checkBrowserList(list, where) {
|
|
9
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
10
|
+
throw new Error(`${where} must list at least one browser.`);
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
for (const name of list) {
|
|
13
|
+
if (!isBrowser(name))
|
|
14
|
+
throw new Error(`Unknown browser "${String(name)}" in ${where}. Choose ${BROWSER_NAMES.join(" or ")}.`);
|
|
15
|
+
if (seen.has(name))
|
|
16
|
+
throw new Error(`Browser "${name}" is listed twice in ${where}.`);
|
|
17
|
+
seen.add(name);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Checks the browser keys of a config: the removed `browser`, and `browsers` on the project and on each group.
|
|
22
|
+
* Throws an Error that says what to write instead.
|
|
23
|
+
*/
|
|
24
|
+
export function validateBrowserConfig(config) {
|
|
25
|
+
const legacy = config.browser;
|
|
26
|
+
if (legacy !== undefined)
|
|
27
|
+
throw new Error(`"browser" was replaced by "browsers": use browsers: [${JSON.stringify(legacy)}]`);
|
|
28
|
+
if (config.browsers !== undefined)
|
|
29
|
+
checkBrowserList(config.browsers, "browsers");
|
|
30
|
+
config.groups?.forEach((group, index) => {
|
|
31
|
+
if (group.browsers !== undefined)
|
|
32
|
+
checkBrowserList(group.browsers, `groups[${index}].browsers`);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The browsers a run uses, the first that is set winning: the command line, the group being run, the project, then
|
|
37
|
+
* Firefox. An empty list counts as not given.
|
|
38
|
+
*/
|
|
39
|
+
export function browsersFor(sources) {
|
|
40
|
+
for (const list of [sources.flag, sources.group, sources.project])
|
|
41
|
+
if (list && list.length > 0)
|
|
42
|
+
return [...list];
|
|
43
|
+
return [...DEFAULT_BROWSERS];
|
|
44
|
+
}
|
package/dist/runner/groups.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SupportedBrowser } from "../types/browser.js";
|
|
1
2
|
import type { SamuraiGroup } from "../types/config.js";
|
|
2
3
|
/** The test folder when the config names none */
|
|
3
4
|
export declare const DEFAULT_SRC_DIR = "./src";
|
|
@@ -22,6 +23,8 @@ export interface ResolvedGroup {
|
|
|
22
23
|
file: string;
|
|
23
24
|
title: string;
|
|
24
25
|
}[];
|
|
26
|
+
/** The browsers the group's tests run in: its own `browsers`, else the project's, else Firefox */
|
|
27
|
+
browsers: SupportedBrowser[];
|
|
25
28
|
}
|
|
26
29
|
/**
|
|
27
30
|
* The real path of `p` (symlinks resolved), or `p` itself when it does not exist. Registered test files come
|
|
@@ -33,10 +36,11 @@ export declare const toPosix: (p: string) => string;
|
|
|
33
36
|
/** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
|
|
34
37
|
export declare const testId: (file: string, name: string) => string;
|
|
35
38
|
/** Works out which of `tests` (files relative to `srcDir`) belong to the group */
|
|
36
|
-
export declare function resolveGroup(group: SamuraiGroup, tests: GroupTest[], projectDir: string, srcDir: string): ResolvedGroup;
|
|
39
|
+
export declare function resolveGroup(group: SamuraiGroup, tests: GroupTest[], projectDir: string, srcDir: string, projectBrowsers?: readonly SupportedBrowser[]): ResolvedGroup;
|
|
37
40
|
/** Resolves every group of the config */
|
|
38
41
|
export declare function resolveGroups(config: {
|
|
39
42
|
groups?: SamuraiGroup[];
|
|
43
|
+
browsers?: SupportedBrowser[];
|
|
40
44
|
}, tests: GroupTest[], projectDir: string, srcDir: string): ResolvedGroup[];
|
|
41
45
|
/** The group called `name`, or an error naming the groups there are */
|
|
42
46
|
export declare function findGroup<T extends {
|
package/dist/runner/groups.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { matchesGlob, relative, resolve } from "node:path";
|
|
3
|
+
import { browsersFor } from "./browsers.js";
|
|
3
4
|
/** The test folder when the config names none */
|
|
4
5
|
export const DEFAULT_SRC_DIR = "./src";
|
|
5
6
|
/** The spec pattern when a group names none */
|
|
@@ -21,7 +22,11 @@ export const toPosix = (p) => p.replace(/\\/g, "/");
|
|
|
21
22
|
/** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
|
|
22
23
|
export const testId = (file, name) => `${toPosix(file)}::${name}`;
|
|
23
24
|
/** Works out which of `tests` (files relative to `srcDir`) belong to the group */
|
|
24
|
-
export function resolveGroup(group, tests, projectDir, srcDir) {
|
|
25
|
+
export function resolveGroup(group, tests, projectDir, srcDir, projectBrowsers) {
|
|
26
|
+
const browsers = browsersFor({
|
|
27
|
+
group: group.browsers,
|
|
28
|
+
project: projectBrowsers,
|
|
29
|
+
});
|
|
25
30
|
const found = tests.map((t) => ({ ...t, id: testId(t.file, t.name) }));
|
|
26
31
|
// Tests picked by hand override src and testMatch
|
|
27
32
|
if (group.tests) {
|
|
@@ -38,6 +43,7 @@ export function resolveGroup(group, tests, projectDir, srcDir) {
|
|
|
38
43
|
missing: picked
|
|
39
44
|
.filter((t) => !known.has(t.id))
|
|
40
45
|
.map(({ file, title }) => ({ file, title })),
|
|
46
|
+
browsers,
|
|
41
47
|
};
|
|
42
48
|
}
|
|
43
49
|
const base = group.src ? resolve(projectDir, group.src) : srcDir;
|
|
@@ -54,11 +60,12 @@ export function resolveGroup(group, tests, projectDir, srcDir) {
|
|
|
54
60
|
pattern: [group.src, group.testMatch].filter(Boolean).join(" · ") || pattern,
|
|
55
61
|
testIds,
|
|
56
62
|
missing: [],
|
|
63
|
+
browsers,
|
|
57
64
|
};
|
|
58
65
|
}
|
|
59
66
|
/** Resolves every group of the config */
|
|
60
67
|
export function resolveGroups(config, tests, projectDir, srcDir) {
|
|
61
|
-
return (config.groups ?? []).map((group) => resolveGroup(group, tests, projectDir, srcDir));
|
|
68
|
+
return (config.groups ?? []).map((group) => resolveGroup(group, tests, projectDir, srcDir, config.browsers));
|
|
62
69
|
}
|
|
63
70
|
/** The group called `name`, or an error naming the groups there are */
|
|
64
71
|
export function findGroup(groups, name) {
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import type { RegisteredTestCase, TestError, TestLogEntry, TestResult, TestSummary } from "../types/test.js";
|
|
2
|
+
import type { SupportedBrowser } from "../types/browser.js";
|
|
2
3
|
/** What a run reports while it happens */
|
|
3
4
|
export type RunEvent = {
|
|
4
5
|
type: "run-start";
|
|
5
6
|
environment: string;
|
|
7
|
+
/** Every selected test in every browser */
|
|
6
8
|
total: number;
|
|
9
|
+
browsers: SupportedBrowser[];
|
|
7
10
|
} | {
|
|
8
11
|
type: "test-start";
|
|
9
12
|
name: string;
|
|
10
13
|
file: string;
|
|
14
|
+
browser: SupportedBrowser;
|
|
11
15
|
} | {
|
|
12
16
|
type: "test-end";
|
|
13
17
|
name: string;
|
|
14
18
|
file: string;
|
|
19
|
+
browser: SupportedBrowser;
|
|
15
20
|
result: TestResult;
|
|
16
21
|
} | {
|
|
17
22
|
type: "run-end";
|
|
@@ -20,6 +25,8 @@ export type RunEvent = {
|
|
|
20
25
|
export interface ReporterOptions {
|
|
21
26
|
/** The environment the run uses, recorded in the report */
|
|
22
27
|
environment: string;
|
|
28
|
+
/** The browsers the run uses, in the order they run; recorded in the report */
|
|
29
|
+
browsers: SupportedBrowser[];
|
|
23
30
|
/** Where the report is written. @default <cwd>/result/report.json */
|
|
24
31
|
output?: string | false;
|
|
25
32
|
/** Called with every event, in order. A throwing listener is ignored */
|
|
@@ -29,14 +36,15 @@ export default class TestReporter {
|
|
|
29
36
|
private testResults;
|
|
30
37
|
private summary;
|
|
31
38
|
private environment;
|
|
39
|
+
private browsers;
|
|
32
40
|
private output;
|
|
33
41
|
private listener;
|
|
34
42
|
constructor(options: ReporterOptions);
|
|
35
43
|
private emit;
|
|
36
44
|
onStart(total?: number): void;
|
|
37
45
|
onEnd(): Promise<TestSummary>;
|
|
38
|
-
onTestStart(test: RegisteredTestCase): void;
|
|
39
|
-
onTestEnd(test: RegisteredTestCase, error?: TestError, extras?: {
|
|
46
|
+
onTestStart(test: RegisteredTestCase, browser: SupportedBrowser): void;
|
|
47
|
+
onTestEnd(test: RegisteredTestCase, browser: SupportedBrowser, error?: TestError, extras?: {
|
|
40
48
|
logs?: TestLogEntry[];
|
|
41
49
|
logsDropped?: number;
|
|
42
50
|
screenshots?: {
|
package/dist/runner/reporter.js
CHANGED
|
@@ -2,14 +2,15 @@ import { mkdir, writeFile } from "fs/promises";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { projectDir } from "../config/config.js";
|
|
4
4
|
import { maskDeep } from "../config/mask.js";
|
|
5
|
-
/** Results are keyed by file
|
|
6
|
-
function keyOf(test) {
|
|
7
|
-
return `${test.file}::${test.name}`;
|
|
5
|
+
/** Results are keyed by file, full title and browser, so equal titles in different files or describe blocks, and the same test in two browsers, stay apart */
|
|
6
|
+
function keyOf(test, browser) {
|
|
7
|
+
return `${test.file}::${test.name}::${browser}`;
|
|
8
8
|
}
|
|
9
9
|
export default class TestReporter {
|
|
10
10
|
constructor(options) {
|
|
11
11
|
this.testResults = new Map();
|
|
12
12
|
this.environment = options.environment;
|
|
13
|
+
this.browsers = [...options.browsers];
|
|
13
14
|
this.output =
|
|
14
15
|
options.output ?? path.join(projectDir(), "result/report.json");
|
|
15
16
|
this.listener = options.onEvent;
|
|
@@ -29,9 +30,15 @@ export default class TestReporter {
|
|
|
29
30
|
status: "failed",
|
|
30
31
|
startTime: t.startTime,
|
|
31
32
|
environment: this.environment,
|
|
33
|
+
browsers: this.browsers,
|
|
32
34
|
tests: [],
|
|
33
35
|
};
|
|
34
|
-
this.emit({
|
|
36
|
+
this.emit({
|
|
37
|
+
type: "run-start",
|
|
38
|
+
environment: this.environment,
|
|
39
|
+
total,
|
|
40
|
+
browsers: this.browsers,
|
|
41
|
+
});
|
|
35
42
|
}
|
|
36
43
|
async onEnd() {
|
|
37
44
|
performance.mark(`group-finish`);
|
|
@@ -40,6 +47,7 @@ export default class TestReporter {
|
|
|
40
47
|
...this.summary,
|
|
41
48
|
duration,
|
|
42
49
|
environment: this.environment,
|
|
50
|
+
browsers: this.browsers,
|
|
43
51
|
tests: Array.from(this.testResults.values()),
|
|
44
52
|
status: Array.from(this.testResults.values()).every((r) => r.status === "success")
|
|
45
53
|
? "success"
|
|
@@ -54,19 +62,25 @@ export default class TestReporter {
|
|
|
54
62
|
this.emit({ type: "run-end", summary: this.summary });
|
|
55
63
|
return this.summary;
|
|
56
64
|
}
|
|
57
|
-
onTestStart(test) {
|
|
58
|
-
const key = keyOf(test);
|
|
65
|
+
onTestStart(test, browser) {
|
|
66
|
+
const key = keyOf(test, browser);
|
|
59
67
|
const t = performance.mark(`${key}-start`);
|
|
60
68
|
this.testResults.set(key, {
|
|
61
69
|
name: test.name,
|
|
62
70
|
file: test.file,
|
|
71
|
+
browser,
|
|
63
72
|
status: "started",
|
|
64
73
|
startTime: t.startTime,
|
|
65
74
|
});
|
|
66
|
-
this.emit({
|
|
75
|
+
this.emit({
|
|
76
|
+
type: "test-start",
|
|
77
|
+
name: test.name,
|
|
78
|
+
file: test.file,
|
|
79
|
+
browser,
|
|
80
|
+
});
|
|
67
81
|
}
|
|
68
|
-
onTestEnd(test, error, extras) {
|
|
69
|
-
const key = keyOf(test);
|
|
82
|
+
onTestEnd(test, browser, error, extras) {
|
|
83
|
+
const key = keyOf(test, browser);
|
|
70
84
|
performance.mark(`${key}-finish`);
|
|
71
85
|
const duration = performance.measure("test-duration", `${key}-start`, `${key}-finish`).duration;
|
|
72
86
|
const result = maskDeep({
|
|
@@ -79,6 +93,12 @@ export default class TestReporter {
|
|
|
79
93
|
...(extras?.screenshots?.length && { screenshots: extras.screenshots }),
|
|
80
94
|
});
|
|
81
95
|
this.testResults.set(key, result);
|
|
82
|
-
this.emit({
|
|
96
|
+
this.emit({
|
|
97
|
+
type: "test-end",
|
|
98
|
+
name: test.name,
|
|
99
|
+
file: test.file,
|
|
100
|
+
browser,
|
|
101
|
+
result,
|
|
102
|
+
});
|
|
83
103
|
}
|
|
84
104
|
}
|
package/dist/runner/run.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface FoundTest {
|
|
|
24
24
|
export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[]>;
|
|
25
25
|
/** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
|
|
26
26
|
export declare function listGroups(options?: RunTestsOptions): Promise<ResolvedGroup[]>;
|
|
27
|
+
export { BROWSER_NAMES, checkBrowserList } from "./browsers.js";
|
|
27
28
|
export type { RunEvent } from "./reporter.js";
|
|
28
29
|
export type { TestError, TestResult, TestSummary } from "../types/test.js";
|
|
29
30
|
export type { ResolvedGroup } from "./groups.js";
|
package/dist/runner/run.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SupportedBrowser } from "../types/browser.js";
|
|
1
2
|
import type { RegisteredTestCase } from "../types/test.js";
|
|
2
3
|
import type { PreparedRun } from "./prepare-run.js";
|
|
3
4
|
import { type RunEvent } from "./reporter.js";
|
|
@@ -13,6 +14,8 @@ export interface RunnerOptions {
|
|
|
13
14
|
testNames?: string[];
|
|
14
15
|
/** Only the tests of this group of the config's `groups` (unknown name: an error). Combines with the other filters */
|
|
15
16
|
group?: string;
|
|
17
|
+
/** The browsers to run in, instead of the group's or the project's `browsers` (the command line's `--browser`) */
|
|
18
|
+
browsers?: SupportedBrowser[];
|
|
16
19
|
/** Run the browser without a window. @default false */
|
|
17
20
|
headless?: boolean;
|
|
18
21
|
/** Remote debugging port of the browser. @default 9223 */
|
|
@@ -49,7 +52,12 @@ export default class TestRunner {
|
|
|
49
52
|
groups(): Promise<ResolvedGroup[]>;
|
|
50
53
|
/** Imports the spec files and returns the tests the options select: by `group`, `testNames` and `grep` */
|
|
51
54
|
register(): Promise<RegisteredTestCase[]>;
|
|
52
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The browsers the run uses: the options' (`--browser`), else the selected group's, else the project's, else
|
|
57
|
+
* Firefox. A group is only looked up when there is something to look up
|
|
58
|
+
*/
|
|
59
|
+
private browsersOfRun;
|
|
60
|
+
/** 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 */
|
|
53
61
|
start(): Promise<import("../types/test.js").TestSummary>;
|
|
54
62
|
private executeTestCase;
|
|
55
63
|
}
|