@itmetsam/samurai-framework 0.21.5 → 0.22.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/dist/api.d.ts +6 -1
- package/dist/api.js +1 -0
- package/dist/browser/browser-context.d.ts +18 -6
- package/dist/browser/browser-context.js +25 -7
- package/dist/browser/browser.d.ts +4 -4
- package/dist/browser/browser.js +11 -8
- package/dist/browser/chrome-launch.js +9 -1
- package/dist/browser/context-cookie-store.d.ts +17 -0
- package/dist/browser/context-cookie-store.js +90 -0
- package/dist/browser/index.d.ts +3 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/keyboard.d.ts +35 -0
- package/dist/browser/keyboard.js +83 -0
- package/dist/browser/mouse.d.ts +42 -0
- package/dist/browser/mouse.js +66 -0
- package/dist/browser/page-navigation.d.ts +0 -2
- package/dist/browser/page-navigation.js +1 -6
- package/dist/browser/page.d.ts +18 -8
- package/dist/browser/page.js +18 -9
- package/dist/browser/storage-state.d.ts +0 -2
- package/dist/browser/storage-state.js +0 -20
- package/dist/browser/use-applier.d.ts +16 -5
- package/dist/browser/use-applier.js +44 -17
- package/dist/browser/use-commands.d.ts +36 -0
- package/dist/browser/use-commands.js +120 -0
- package/dist/cli/args.d.ts +8 -4
- package/dist/cli/args.js +8 -5
- package/dist/cli/console-reporter.js +5 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/usage.d.ts +1 -1
- package/dist/cli/usage.js +4 -1
- package/dist/config/base-url.d.ts +4 -0
- package/dist/config/base-url.js +6 -0
- package/dist/config/config.js +6 -0
- package/dist/config/fully-parallel.d.ts +3 -0
- package/dist/config/fully-parallel.js +6 -0
- package/dist/config/group-use.d.ts +8 -0
- package/dist/config/group-use.js +41 -0
- package/dist/config/resolved-config.d.ts +2 -0
- package/dist/config/resolved-config.js +3 -0
- package/dist/config/storage-state-file.d.ts +3 -0
- package/dist/config/storage-state-file.js +20 -0
- package/dist/config/use-options.d.ts +3 -1
- package/dist/config/use-options.js +70 -14
- package/dist/config/web-server-options.d.ts +7 -0
- package/dist/config/web-server-options.js +111 -0
- package/dist/locator/check-actions.js +1 -1
- package/dist/locator/drag-actions.d.ts +17 -0
- package/dist/locator/drag-actions.js +80 -0
- package/dist/locator/element-actions.d.ts +10 -4
- package/dist/locator/element-actions.js +23 -16
- package/dist/locator/element-state.d.ts +5 -0
- package/dist/locator/element-state.js +2 -1
- package/dist/locator/event-actions.d.ts +10 -0
- package/dist/locator/event-actions.js +81 -0
- package/dist/locator/input-actions.d.ts +2 -11
- package/dist/locator/input-actions.js +3 -56
- package/dist/locator/key-actions.d.ts +11 -0
- package/dist/locator/key-actions.js +52 -0
- package/dist/locator/key-map.d.ts +16 -0
- package/dist/locator/key-map.js +76 -0
- package/dist/locator/locator.d.ts +35 -5
- package/dist/locator/locator.js +70 -16
- package/dist/locator/page-scripts.d.ts +2 -0
- package/dist/locator/page-scripts.js +7 -0
- package/dist/locator/pointer-actions.d.ts +44 -0
- package/dist/locator/pointer-actions.js +117 -0
- package/dist/locator/pointer-element-actions.d.ts +21 -0
- package/dist/locator/pointer-element-actions.js +42 -0
- package/dist/request/api-request-context.d.ts +65 -0
- package/dist/request/api-request-context.js +198 -0
- package/dist/request/api-response.d.ts +34 -0
- package/dist/request/api-response.js +57 -0
- package/dist/request/cookie-jar.d.ts +48 -0
- package/dist/request/cookie-jar.js +164 -0
- package/dist/request/http-transport.d.ts +31 -0
- package/dist/request/http-transport.js +113 -0
- package/dist/request/redirect.d.ts +10 -0
- package/dist/request/redirect.js +21 -0
- package/dist/request/request-body.d.ts +27 -0
- package/dist/request/request-body.js +71 -0
- package/dist/request/request.d.ts +16 -0
- package/dist/request/request.js +20 -0
- package/dist/runner/all-hooks.d.ts +7 -1
- package/dist/runner/all-hooks.js +17 -1
- package/dist/runner/fixture-defs.js +1 -0
- package/dist/runner/fixtures.d.ts +4 -1
- package/dist/runner/fixtures.js +3 -2
- package/dist/runner/global-hooks.d.ts +13 -0
- package/dist/runner/global-hooks.js +66 -0
- package/dist/runner/options.d.ts +13 -2
- package/dist/runner/output-tail.d.ts +14 -0
- package/dist/runner/output-tail.js +30 -0
- package/dist/runner/pool-schedule.d.ts +22 -0
- package/dist/runner/pool-schedule.js +55 -0
- package/dist/runner/pool-units.d.ts +34 -0
- package/dist/runner/pool-units.js +85 -0
- package/dist/runner/pooled-run.d.ts +7 -5
- package/dist/runner/pooled-run.js +19 -9
- package/dist/runner/registry.js +6 -2
- package/dist/runner/reporter.d.ts +10 -0
- package/dist/runner/reporter.js +12 -2
- package/dist/runner/request-fixture.d.ts +14 -0
- package/dist/runner/request-fixture.js +25 -0
- package/dist/runner/run-hooks.d.ts +16 -0
- package/dist/runner/run-hooks.js +52 -0
- package/dist/runner/run-test.js +7 -2
- package/dist/runner/run.d.ts +1 -0
- package/dist/runner/run.js +1 -0
- package/dist/runner/select.d.ts +15 -4
- package/dist/runner/select.js +38 -12
- package/dist/runner/serial-run.d.ts +23 -0
- package/dist/runner/serial-run.js +88 -0
- package/dist/runner/serial.d.ts +34 -0
- package/dist/runner/serial.js +63 -0
- package/dist/runner/shard.d.ts +22 -0
- package/dist/runner/shard.js +73 -0
- package/dist/runner/suite.d.ts +3 -3
- package/dist/runner/suite.js +4 -3
- package/dist/runner/test-function.d.ts +13 -1
- package/dist/runner/test-info.js +2 -1
- package/dist/runner/test-runner.d.ts +1 -0
- package/dist/runner/test-runner.js +62 -18
- package/dist/runner/use-options.d.ts +4 -4
- package/dist/runner/use-options.js +10 -3
- package/dist/runner/wait-for-url.d.ts +20 -0
- package/dist/runner/wait-for-url.js +65 -0
- package/dist/runner/web-server.d.ts +16 -0
- package/dist/runner/web-server.js +165 -0
- package/dist/runner/worker-pool.d.ts +13 -5
- package/dist/runner/worker-pool.js +85 -51
- package/dist/runner/worker-protocol.d.ts +8 -0
- package/dist/runner/worker-session.js +3 -1
- package/dist/runner/worker.d.ts +2 -0
- package/dist/runner/worker.js +7 -1
- package/dist/types/config.d.ts +83 -0
- package/dist/types/test.d.ts +18 -0
- package/dist/types/use.d.ts +45 -7
- package/package.json +1 -1
- package/dist/browser/emulation.d.ts +0 -61
- package/dist/browser/emulation.js +0 -180
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
const SAME_SITE = { strict: "Strict", lax: "Lax", none: "None" };
|
|
2
|
+
const INTEGER = /^-?\d+$/;
|
|
3
|
+
function isLoopback(host) {
|
|
4
|
+
return (host === "localhost" || host === "[::1]" || /^127(\.\d+){3}$/.test(host));
|
|
5
|
+
}
|
|
6
|
+
/** The default path of a cookie set without `Path`: the directory of the request path */
|
|
7
|
+
function defaultPath(pathname) {
|
|
8
|
+
const last = pathname.lastIndexOf("/");
|
|
9
|
+
return pathname.startsWith("/") && last > 0 ? pathname.slice(0, last) : "/";
|
|
10
|
+
}
|
|
11
|
+
function domainMatches(host, domain) {
|
|
12
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* One `Set-Cookie` header of a response from `url`. `undefined` for a header that is not a cookie (no name, or a
|
|
16
|
+
* `Domain` the host does not belong to, which a browser would refuse as well).
|
|
17
|
+
*/
|
|
18
|
+
export function parseSetCookie(header, url, now = Date.now()) {
|
|
19
|
+
const [pair = "", ...attributes] = header.split(";");
|
|
20
|
+
const eq = pair.indexOf("=");
|
|
21
|
+
if (eq < 0)
|
|
22
|
+
return undefined;
|
|
23
|
+
const name = pair.slice(0, eq).trim();
|
|
24
|
+
if (name === "")
|
|
25
|
+
return undefined;
|
|
26
|
+
const cookie = {
|
|
27
|
+
name,
|
|
28
|
+
value: pair.slice(eq + 1).trim(),
|
|
29
|
+
domain: url.hostname.toLowerCase(),
|
|
30
|
+
hostOnly: true,
|
|
31
|
+
path: defaultPath(url.pathname),
|
|
32
|
+
secure: false,
|
|
33
|
+
httpOnly: false,
|
|
34
|
+
};
|
|
35
|
+
let maxAge;
|
|
36
|
+
for (const attribute of attributes) {
|
|
37
|
+
const at = attribute.indexOf("=");
|
|
38
|
+
const key = (at < 0 ? attribute : attribute.slice(0, at))
|
|
39
|
+
.trim()
|
|
40
|
+
.toLowerCase();
|
|
41
|
+
const value = at < 0 ? "" : attribute.slice(at + 1).trim();
|
|
42
|
+
if (key === "domain" && value !== "") {
|
|
43
|
+
const domain = value.replace(/^\./, "").toLowerCase();
|
|
44
|
+
if (!domainMatches(cookie.domain, domain))
|
|
45
|
+
return undefined;
|
|
46
|
+
cookie.domain = domain;
|
|
47
|
+
cookie.hostOnly = false;
|
|
48
|
+
}
|
|
49
|
+
else if (key === "path" && value.startsWith("/"))
|
|
50
|
+
cookie.path = value;
|
|
51
|
+
else if (key === "secure")
|
|
52
|
+
cookie.secure = true;
|
|
53
|
+
else if (key === "httponly")
|
|
54
|
+
cookie.httpOnly = true;
|
|
55
|
+
else if (key === "samesite" && value.toLowerCase() in SAME_SITE)
|
|
56
|
+
cookie.sameSite =
|
|
57
|
+
SAME_SITE[value.toLowerCase()];
|
|
58
|
+
else if (key === "max-age" && INTEGER.test(value))
|
|
59
|
+
maxAge = Number(value);
|
|
60
|
+
else if (key === "expires") {
|
|
61
|
+
const time = Date.parse(value);
|
|
62
|
+
if (!Number.isNaN(time))
|
|
63
|
+
cookie.expires = Math.floor(time / 1000);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Max-Age wins over Expires
|
|
67
|
+
if (maxAge !== undefined)
|
|
68
|
+
cookie.expires = maxAge <= 0 ? 0 : Math.floor(now / 1000) + maxAge;
|
|
69
|
+
return cookie;
|
|
70
|
+
}
|
|
71
|
+
export function isExpired(cookie, now = Date.now()) {
|
|
72
|
+
return cookie.expires !== undefined && cookie.expires * 1000 <= now;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether a request to `url` carries `cookie`: host, path, `Secure` (https, or a loopback host the browsers treat
|
|
76
|
+
* as secure) and expiry. `SameSite` is not applied: a request made by the test is not made from another site.
|
|
77
|
+
*/
|
|
78
|
+
export function cookieMatches(cookie, url, now = Date.now()) {
|
|
79
|
+
if (isExpired(cookie, now))
|
|
80
|
+
return false;
|
|
81
|
+
const host = url.hostname.toLowerCase();
|
|
82
|
+
if (cookie.hostOnly
|
|
83
|
+
? host !== cookie.domain
|
|
84
|
+
: !domainMatches(host, cookie.domain))
|
|
85
|
+
return false;
|
|
86
|
+
const { path } = cookie;
|
|
87
|
+
const here = url.pathname || "/";
|
|
88
|
+
const underPath = here === path ||
|
|
89
|
+
(here.startsWith(path) &&
|
|
90
|
+
(path.endsWith("/") || here[path.length] === "/"));
|
|
91
|
+
if (!underPath)
|
|
92
|
+
return false;
|
|
93
|
+
return !cookie.secure || url.protocol === "https:" || isLoopback(host);
|
|
94
|
+
}
|
|
95
|
+
/** The `Cookie` header value for `cookies`: the longest path first, as a browser sends them */
|
|
96
|
+
export function cookieHeaderOf(cookies) {
|
|
97
|
+
if (cookies.length === 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
return [...cookies]
|
|
100
|
+
.sort((a, b) => b.path.length - a.path.length)
|
|
101
|
+
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
|
102
|
+
.join("; ");
|
|
103
|
+
}
|
|
104
|
+
/** The cookies of an in-memory request context: what its responses set, and what a storage state seeded */
|
|
105
|
+
export class CookieJar {
|
|
106
|
+
constructor(seed = []) {
|
|
107
|
+
this.cookies = [];
|
|
108
|
+
for (const cookie of seed)
|
|
109
|
+
this.put(fromStorageState(cookie));
|
|
110
|
+
}
|
|
111
|
+
cookieHeader(url) {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
this.cookies = this.cookies.filter((cookie) => !isExpired(cookie, now));
|
|
114
|
+
return Promise.resolve(cookieHeaderOf(this.cookies.filter((c) => cookieMatches(c, url, now))));
|
|
115
|
+
}
|
|
116
|
+
store(url, setCookies) {
|
|
117
|
+
for (const header of setCookies) {
|
|
118
|
+
const cookie = parseSetCookie(header, url);
|
|
119
|
+
if (cookie)
|
|
120
|
+
this.put(cookie);
|
|
121
|
+
}
|
|
122
|
+
return Promise.resolve();
|
|
123
|
+
}
|
|
124
|
+
list() {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
return Promise.resolve(this.cookies
|
|
127
|
+
.filter((cookie) => !isExpired(cookie, now))
|
|
128
|
+
.map(toStorageState));
|
|
129
|
+
}
|
|
130
|
+
/** Replaces the cookie with the same name, domain and path; an expired one only removes it */
|
|
131
|
+
put(cookie) {
|
|
132
|
+
this.cookies = this.cookies.filter((old) => old.name !== cookie.name ||
|
|
133
|
+
old.domain !== cookie.domain ||
|
|
134
|
+
old.path !== cookie.path);
|
|
135
|
+
if (!isExpired(cookie))
|
|
136
|
+
this.cookies.push(cookie);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function fromStorageState(cookie) {
|
|
140
|
+
return {
|
|
141
|
+
name: cookie.name,
|
|
142
|
+
value: cookie.value,
|
|
143
|
+
domain: cookie.domain.replace(/^\./, "").toLowerCase(),
|
|
144
|
+
hostOnly: !cookie.domain.startsWith("."),
|
|
145
|
+
path: cookie.path ?? "/",
|
|
146
|
+
secure: cookie.secure ?? false,
|
|
147
|
+
httpOnly: cookie.httpOnly ?? false,
|
|
148
|
+
...(cookie.sameSite !== undefined && { sameSite: cookie.sameSite }),
|
|
149
|
+
...(cookie.expires !== undefined &&
|
|
150
|
+
cookie.expires > 0 && { expires: cookie.expires }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function toStorageState(cookie) {
|
|
154
|
+
return {
|
|
155
|
+
name: cookie.name,
|
|
156
|
+
value: cookie.value,
|
|
157
|
+
domain: cookie.hostOnly ? cookie.domain : `.${cookie.domain}`,
|
|
158
|
+
path: cookie.path,
|
|
159
|
+
expires: cookie.expires ?? -1,
|
|
160
|
+
httpOnly: cookie.httpOnly,
|
|
161
|
+
secure: cookie.secure,
|
|
162
|
+
sameSite: cookie.sameSite ?? "Lax",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HeaderEntry } from "./api-response.js";
|
|
2
|
+
/** One request to send, a single hop: redirects are the caller's business */
|
|
3
|
+
export interface HopRequest {
|
|
4
|
+
url: URL;
|
|
5
|
+
method: string;
|
|
6
|
+
/** Names lower-cased */
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
body?: Buffer | undefined;
|
|
9
|
+
ignoreHTTPSErrors: boolean;
|
|
10
|
+
/** Milliseconds this hop may take; 0 is no limit */
|
|
11
|
+
timeout: number;
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
export interface HopResponse {
|
|
15
|
+
status: number;
|
|
16
|
+
statusText: string;
|
|
17
|
+
headers: HeaderEntry[];
|
|
18
|
+
/** The `Set-Cookie` headers, one entry each */
|
|
19
|
+
setCookies: string[];
|
|
20
|
+
/** Decoded: `Content-Encoding` is already undone */
|
|
21
|
+
body: Buffer;
|
|
22
|
+
}
|
|
23
|
+
/** A request that failed before a response was complete */
|
|
24
|
+
export declare class TransportError extends Error {
|
|
25
|
+
readonly code?: string | undefined;
|
|
26
|
+
constructor(message: string, code?: string | undefined);
|
|
27
|
+
}
|
|
28
|
+
/** The url without credentials or query: what an error may say about where the request went */
|
|
29
|
+
export declare function describeURL(url: URL): string;
|
|
30
|
+
/** Sends one request over `http` or `https` and reads the whole response. Errors never include header values. */
|
|
31
|
+
export declare function sendHop(request: HopRequest): Promise<HopResponse>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import zlib from "node:zlib";
|
|
4
|
+
/** A request that failed before a response was complete */
|
|
5
|
+
export class TransportError extends Error {
|
|
6
|
+
constructor(message, code) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "TransportError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function decode(body, encoding) {
|
|
13
|
+
if (body.length === 0)
|
|
14
|
+
return body;
|
|
15
|
+
switch (encoding?.trim().toLowerCase()) {
|
|
16
|
+
case "gzip":
|
|
17
|
+
case "x-gzip":
|
|
18
|
+
return zlib.gunzipSync(body);
|
|
19
|
+
case "br":
|
|
20
|
+
return zlib.brotliDecompressSync(body);
|
|
21
|
+
case "deflate":
|
|
22
|
+
try {
|
|
23
|
+
return zlib.inflateSync(body);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Some servers send deflate without the zlib header
|
|
27
|
+
return zlib.inflateRawSync(body);
|
|
28
|
+
}
|
|
29
|
+
default:
|
|
30
|
+
return body;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The url without credentials or query: what an error may say about where the request went */
|
|
34
|
+
export function describeURL(url) {
|
|
35
|
+
return `${url.origin}${url.pathname}`;
|
|
36
|
+
}
|
|
37
|
+
/** Sends one request over `http` or `https` and reads the whole response. Errors never include header values. */
|
|
38
|
+
export function sendHop(request) {
|
|
39
|
+
const { url, method, headers, body, signal } = request;
|
|
40
|
+
const where = `${method} ${describeURL(url)}`;
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
if (signal.aborted)
|
|
43
|
+
return reject(new TransportError(`request aborted (${where})`));
|
|
44
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
45
|
+
let settled = false;
|
|
46
|
+
let timer;
|
|
47
|
+
const finish = (error, value) => {
|
|
48
|
+
if (settled)
|
|
49
|
+
return;
|
|
50
|
+
settled = true;
|
|
51
|
+
signal.removeEventListener("abort", onAbort);
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
if (error)
|
|
54
|
+
reject(error);
|
|
55
|
+
else
|
|
56
|
+
resolve(value);
|
|
57
|
+
};
|
|
58
|
+
const req = transport.request(url, {
|
|
59
|
+
method,
|
|
60
|
+
headers: {
|
|
61
|
+
...headers,
|
|
62
|
+
...(body && { "content-length": String(body.length) }),
|
|
63
|
+
},
|
|
64
|
+
// No shared pool: nothing stays open after the last request
|
|
65
|
+
agent: false,
|
|
66
|
+
...(url.protocol === "https:" && {
|
|
67
|
+
rejectUnauthorized: !request.ignoreHTTPSErrors,
|
|
68
|
+
}),
|
|
69
|
+
}, (res) => {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
72
|
+
res.on("error", (err) => finish(new TransportError(`${err.message} (${where})`)));
|
|
73
|
+
res.on("end", () => {
|
|
74
|
+
try {
|
|
75
|
+
const raw = res.rawHeaders;
|
|
76
|
+
const entries = [];
|
|
77
|
+
for (let i = 0; i + 1 < raw.length; i += 2)
|
|
78
|
+
entries.push({
|
|
79
|
+
name: raw[i],
|
|
80
|
+
value: raw[i + 1],
|
|
81
|
+
});
|
|
82
|
+
const decoded = method === "HEAD"
|
|
83
|
+
? Buffer.alloc(0)
|
|
84
|
+
: decode(Buffer.concat(chunks), res.headers["content-encoding"]);
|
|
85
|
+
finish(undefined, {
|
|
86
|
+
status: res.statusCode ?? 0,
|
|
87
|
+
statusText: res.statusMessage ?? "",
|
|
88
|
+
headers: entries,
|
|
89
|
+
setCookies: res.headers["set-cookie"] ?? [],
|
|
90
|
+
body: decoded,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
finish(new TransportError(`the response body could not be decoded: ${err.message} (${where})`));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
const onAbort = () => {
|
|
99
|
+
req.destroy();
|
|
100
|
+
finish(new TransportError(`request aborted (${where})`));
|
|
101
|
+
};
|
|
102
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
timer =
|
|
104
|
+
request.timeout > 0
|
|
105
|
+
? setTimeout(() => {
|
|
106
|
+
req.destroy();
|
|
107
|
+
finish(new TransportError(`timed out after ${request.timeout}ms (${where})`, "ETIMEDOUT"));
|
|
108
|
+
}, request.timeout)
|
|
109
|
+
: undefined;
|
|
110
|
+
req.on("error", (err) => finish(new TransportError(`${err.code ?? err.message} (${where})`, err.code)));
|
|
111
|
+
req.end(body);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Where a response sends the client next: its `Location` against the URL it came from; `undefined` when it does not redirect */
|
|
2
|
+
export declare function redirectTarget(status: number, location: string | undefined, from: URL): URL | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* The method and body of the request that follows a redirect. 303 (any method but GET and HEAD) and 301/302 after
|
|
5
|
+
* a POST turn into a GET without a body; 307 and 308 keep both.
|
|
6
|
+
*/
|
|
7
|
+
export declare function followUp<B>(status: number, method: string, body: B | undefined): {
|
|
8
|
+
method: string;
|
|
9
|
+
body: B | undefined;
|
|
10
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
2
|
+
/** Where a response sends the client next: its `Location` against the URL it came from; `undefined` when it does not redirect */
|
|
3
|
+
export function redirectTarget(status, location, from) {
|
|
4
|
+
if (!REDIRECTS.has(status) || !location)
|
|
5
|
+
return undefined;
|
|
6
|
+
try {
|
|
7
|
+
return new URL(location, from);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The method and body of the request that follows a redirect. 303 (any method but GET and HEAD) and 301/302 after
|
|
15
|
+
* a POST turn into a GET without a body; 307 and 308 keep both.
|
|
16
|
+
*/
|
|
17
|
+
export function followUp(status, method, body) {
|
|
18
|
+
const upgrades = (status === 303 && method !== "GET" && method !== "HEAD") ||
|
|
19
|
+
((status === 301 || status === 302) && method === "POST");
|
|
20
|
+
return upgrades ? { method: "GET", body: undefined } : { method, body };
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A file in a `multipart` body */
|
|
2
|
+
export interface FilePayload {
|
|
3
|
+
name: string;
|
|
4
|
+
/** @default "application/octet-stream" */
|
|
5
|
+
mimeType?: string;
|
|
6
|
+
buffer: Buffer;
|
|
7
|
+
}
|
|
8
|
+
type Scalar = string | number | boolean;
|
|
9
|
+
/** The body options of a request: at most one is set */
|
|
10
|
+
export interface BodyOptions {
|
|
11
|
+
/** Sent as it is (a string, a `Buffer`), or as JSON (anything else) */
|
|
12
|
+
data?: string | Buffer | object | number | boolean | null;
|
|
13
|
+
/** Sent as `application/x-www-form-urlencoded` */
|
|
14
|
+
form?: Record<string, Scalar> | URLSearchParams;
|
|
15
|
+
/** Sent as `multipart/form-data`: fields and files */
|
|
16
|
+
multipart?: Record<string, Scalar | FilePayload>;
|
|
17
|
+
}
|
|
18
|
+
export interface EncodedBody {
|
|
19
|
+
body: Buffer;
|
|
20
|
+
contentType: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The bytes of a request body and the `Content-Type` that goes with them (a header the caller sets wins over it).
|
|
24
|
+
* `undefined` when there is no body. Throws `fetch(): ...` when more than one of the options is set.
|
|
25
|
+
*/
|
|
26
|
+
export declare function encodeBody(options: BodyOptions): EncodedBody | undefined;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
function isFile(value) {
|
|
3
|
+
return (typeof value === "object" &&
|
|
4
|
+
value !== null &&
|
|
5
|
+
Buffer.isBuffer(value.buffer));
|
|
6
|
+
}
|
|
7
|
+
function isJson(text) {
|
|
8
|
+
try {
|
|
9
|
+
JSON.parse(text);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const quoted = (text) => text.replace(/["\r\n]/g, "_");
|
|
17
|
+
function multipartOf(parts) {
|
|
18
|
+
const boundary = `----samurai${randomBytes(12).toString("hex")}`;
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for (const [name, value] of Object.entries(parts)) {
|
|
21
|
+
const file = isFile(value);
|
|
22
|
+
const head = file
|
|
23
|
+
? `Content-Disposition: form-data; name="${quoted(name)}"; filename="${quoted(value.name)}"\r\nContent-Type: ${value.mimeType ?? "application/octet-stream"}`
|
|
24
|
+
: `Content-Disposition: form-data; name="${quoted(name)}"`;
|
|
25
|
+
chunks.push(Buffer.from(`--${boundary}\r\n${head}\r\n\r\n`), file ? value.buffer : Buffer.from(String(value)), Buffer.from("\r\n"));
|
|
26
|
+
}
|
|
27
|
+
chunks.push(Buffer.from(`--${boundary}--\r\n`));
|
|
28
|
+
return {
|
|
29
|
+
body: Buffer.concat(chunks),
|
|
30
|
+
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The bytes of a request body and the `Content-Type` that goes with them (a header the caller sets wins over it).
|
|
35
|
+
* `undefined` when there is no body. Throws `fetch(): ...` when more than one of the options is set.
|
|
36
|
+
*/
|
|
37
|
+
export function encodeBody(options) {
|
|
38
|
+
const given = ["data", "form", "multipart"].filter((key) => options[key] !== undefined);
|
|
39
|
+
if (given.length > 1)
|
|
40
|
+
throw new Error(`fetch(): ${given.join(" and ")} cannot be used together, pick one`);
|
|
41
|
+
const { data, form, multipart } = options;
|
|
42
|
+
if (form !== undefined) {
|
|
43
|
+
const params = form instanceof URLSearchParams
|
|
44
|
+
? form
|
|
45
|
+
: new URLSearchParams(Object.entries(form).map(([key, value]) => [key, String(value)]));
|
|
46
|
+
return {
|
|
47
|
+
body: Buffer.from(params.toString()),
|
|
48
|
+
contentType: "application/x-www-form-urlencoded",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (multipart !== undefined)
|
|
52
|
+
return multipartOf(multipart);
|
|
53
|
+
if (data === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (typeof data === "string")
|
|
56
|
+
return {
|
|
57
|
+
body: Buffer.from(data),
|
|
58
|
+
contentType: isJson(data)
|
|
59
|
+
? "application/json"
|
|
60
|
+
: "application/octet-stream",
|
|
61
|
+
};
|
|
62
|
+
if (Buffer.isBuffer(data) || data instanceof Uint8Array)
|
|
63
|
+
return {
|
|
64
|
+
body: Buffer.from(data),
|
|
65
|
+
contentType: "application/octet-stream",
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
body: Buffer.from(JSON.stringify(data)),
|
|
69
|
+
contentType: "application/json",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { StorageState } from "../types/use.js";
|
|
2
|
+
import { APIRequestContext, type RequestContextOptions } from "./api-request-context.js";
|
|
3
|
+
/** Options of `request.newContext()` */
|
|
4
|
+
export interface NewRequestContextOptions extends RequestContextOptions {
|
|
5
|
+
/** Cookies to start with: a JSON file (relative to the project folder) or the object itself. Local storage is not used. */
|
|
6
|
+
storageState?: string | StorageState;
|
|
7
|
+
}
|
|
8
|
+
/** A request context with its own cookies: the same API as `page.request`, without a browser */
|
|
9
|
+
export declare function createRequestContext(options?: NewRequestContextOptions): APIRequestContext;
|
|
10
|
+
/**
|
|
11
|
+
* Creates `APIRequestContext`s that belong to no page: `request.newContext({ baseURL })`. Inside a test, the
|
|
12
|
+
* `request` fixture is one already.
|
|
13
|
+
*/
|
|
14
|
+
export declare const request: {
|
|
15
|
+
newContext: (options?: NewRequestContextOptions) => Promise<APIRequestContext>;
|
|
16
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { projectDir } from "../config/config.js";
|
|
3
|
+
import { loadStorageState } from "../config/storage-state-file.js";
|
|
4
|
+
import { APIRequestContext, } from "./api-request-context.js";
|
|
5
|
+
import { CookieJar } from "./cookie-jar.js";
|
|
6
|
+
/** A request context with its own cookies: the same API as `page.request`, without a browser */
|
|
7
|
+
export function createRequestContext(options = {}) {
|
|
8
|
+
const { storageState, ...rest } = options;
|
|
9
|
+
const state = storageState === undefined
|
|
10
|
+
? undefined
|
|
11
|
+
: loadStorageState(storageState, path.resolve(projectDir()), "newContext()");
|
|
12
|
+
return new APIRequestContext(rest, new CookieJar(state?.cookies));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Creates `APIRequestContext`s that belong to no page: `request.newContext({ baseURL })`. Inside a test, the
|
|
16
|
+
* `request` fixture is one already.
|
|
17
|
+
*/
|
|
18
|
+
export const request = {
|
|
19
|
+
newContext: (options) => Promise.resolve(createRequestContext(options)),
|
|
20
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SupportedBrowser } from "../types/browser.js";
|
|
2
|
-
import type { AllHookFixtures, RegisteredTestCase } from "../types/test.js";
|
|
2
|
+
import type { AllHookFixtures, RegisteredTestCase, Suite } from "../types/test.js";
|
|
3
3
|
import { type HookError } from "./hooks.js";
|
|
4
4
|
export interface AllHooksOptions {
|
|
5
5
|
/** The limit of each beforeAll / afterAll hook, in ms */
|
|
@@ -21,6 +21,12 @@ export declare class AllHooks {
|
|
|
21
21
|
ensure(test: RegisteredTestCase, browser: SupportedBrowser): Promise<void>;
|
|
22
22
|
/** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
|
|
23
23
|
closeAll(): Promise<HookError[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Runs the `afterAll` hooks of `root` and of the suites inside it that were entered, so the next test that arrives
|
|
26
|
+
* runs their `beforeAll` hooks again: a serial block that starts over. Returns the errors.
|
|
27
|
+
*/
|
|
28
|
+
closeWithin(root: Suite): Promise<HookError[]>;
|
|
29
|
+
private runAfter;
|
|
24
30
|
private enter;
|
|
25
31
|
private runBefore;
|
|
26
32
|
}
|
package/dist/runner/all-hooks.js
CHANGED
|
@@ -21,9 +21,25 @@ export class AllHooks {
|
|
|
21
21
|
}
|
|
22
22
|
/** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
|
|
23
23
|
async closeAll() {
|
|
24
|
-
const errors = [];
|
|
25
24
|
const entered = this.entered.splice(0).reverse();
|
|
26
25
|
this.scopes.clear();
|
|
26
|
+
return this.runAfter(entered);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Runs the `afterAll` hooks of `root` and of the suites inside it that were entered, so the next test that arrives
|
|
30
|
+
* runs their `beforeAll` hooks again: a serial block that starts over. Returns the errors.
|
|
31
|
+
*/
|
|
32
|
+
async closeWithin(root) {
|
|
33
|
+
const inside = (scope) => suiteChain(scope.suite).includes(root);
|
|
34
|
+
const closing = this.entered.filter(inside).reverse();
|
|
35
|
+
for (const scope of closing) {
|
|
36
|
+
this.entered.splice(this.entered.indexOf(scope), 1);
|
|
37
|
+
this.scopes.get(scope.suite)?.delete(scope.browser);
|
|
38
|
+
}
|
|
39
|
+
return this.runAfter(closing);
|
|
40
|
+
}
|
|
41
|
+
async runAfter(entered) {
|
|
42
|
+
const errors = [];
|
|
27
43
|
for (const { suite, browser, ready } of entered) {
|
|
28
44
|
await ready.catch(() => undefined);
|
|
29
45
|
for (const hook of suite.afterAll ?? []) {
|
|
@@ -2,6 +2,7 @@ import type { Browser } from "../browser/browser.js";
|
|
|
2
2
|
import type Page from "../browser/page.js";
|
|
3
3
|
import type { SupportedBrowser } from "../types/browser.js";
|
|
4
4
|
import type { AllHookFixtures, RegisteredTestCase, TestFixtures, TestInfo } from "../types/test.js";
|
|
5
|
+
import type { RequestFixture } from "./request-fixture.js";
|
|
5
6
|
import type { RunnerOptions } from "./options.js";
|
|
6
7
|
import type { PreparedRun } from "./prepare-run.js";
|
|
7
8
|
import type { OutputSink } from "../wait/output-sink.js";
|
|
@@ -20,6 +21,8 @@ export interface FixtureSources {
|
|
|
20
21
|
options: RunnerOptions;
|
|
21
22
|
sink: OutputSink;
|
|
22
23
|
info: TestInfo;
|
|
24
|
+
/** The test's `request` fixture, made on first use */
|
|
25
|
+
request: RequestFixture;
|
|
23
26
|
/** What the `use` options came to in this browser */
|
|
24
27
|
device: {
|
|
25
28
|
isMobile: boolean;
|
|
@@ -27,7 +30,7 @@ export interface FixtureSources {
|
|
|
27
30
|
};
|
|
28
31
|
}
|
|
29
32
|
/**
|
|
30
|
-
* The fixtures a test body and its each-hooks receive. `context`, `baseURL` and `testInfo` are getters: nothing
|
|
33
|
+
* The fixtures a test body and its each-hooks receive. `context`, `baseURL`, `request` and `testInfo` are getters: nothing
|
|
31
34
|
* is looked up until a test reads one (and `page.context()` throws for a page without one, which only a test
|
|
32
35
|
* that asks for it should see).
|
|
33
36
|
*/
|
package/dist/runner/fixtures.js
CHANGED
|
@@ -12,12 +12,12 @@ export function allHookFixtures(run, browserName, workerIndex = 0) {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
* The fixtures a test body and its each-hooks receive. `context`, `baseURL` and `testInfo` are getters: nothing
|
|
15
|
+
* The fixtures a test body and its each-hooks receive. `context`, `baseURL`, `request` and `testInfo` are getters: nothing
|
|
16
16
|
* is looked up until a test reads one (and `page.context()` throws for a page without one, which only a test
|
|
17
17
|
* that asks for it should see).
|
|
18
18
|
*/
|
|
19
19
|
export function buildTestFixtures(from) {
|
|
20
|
-
const { launched, browserName, workerIndex, run, test, options, sink, info, device, } = from;
|
|
20
|
+
const { launched, browserName, workerIndex, run, test, options, sink, info, device, request, } = from;
|
|
21
21
|
const { settings, secrets } = run;
|
|
22
22
|
const fixtures = {
|
|
23
23
|
page: launched.page,
|
|
@@ -34,6 +34,7 @@ export function buildTestFixtures(from) {
|
|
|
34
34
|
return Object.defineProperties(fixtures, {
|
|
35
35
|
context: { enumerable: true, get: () => launched.page.context() },
|
|
36
36
|
baseURL: { enumerable: true, get: () => settings.baseURL },
|
|
37
|
+
request: { enumerable: true, get: () => request.get() },
|
|
37
38
|
testInfo: { enumerable: true, get: () => info },
|
|
38
39
|
});
|
|
39
40
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { GlobalHookConfig, SamuraiTestConfig } from "../types/config.js";
|
|
2
|
+
/** The run-level hooks of a config: `setup` before the tests, `teardown` after them */
|
|
3
|
+
export interface GlobalHooks {
|
|
4
|
+
/** Runs `globalSetup`. A throw is `globalSetup(): message` */
|
|
5
|
+
setup(): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Runs what the setup returned, then `globalTeardown`; both even when the setup failed half way. Resolves with the
|
|
8
|
+
* first error as `globalTeardown(): message`, else undefined
|
|
9
|
+
*/
|
|
10
|
+
teardown(): Promise<Error | undefined>;
|
|
11
|
+
}
|
|
12
|
+
/** Binds the config's `globalSetup` / `globalTeardown` to the config they receive and the project folder they load from */
|
|
13
|
+
export declare function globalHooks(config: SamuraiTestConfig, hookConfig: GlobalHookConfig, projectDir: string): GlobalHooks;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
4
|
+
/** The function a hook names: given directly, or the default export of the module at `projectDir`/`hook` */
|
|
5
|
+
async function loadHook(name, hook, projectDir) {
|
|
6
|
+
if (typeof hook === "function")
|
|
7
|
+
return hook;
|
|
8
|
+
const file = path.resolve(projectDir, hook);
|
|
9
|
+
let module;
|
|
10
|
+
try {
|
|
11
|
+
module = (await import(pathToFileURL(file).href));
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw new Error(`${name}(): cannot load ${hook}: ${messageOf(error)}`);
|
|
15
|
+
}
|
|
16
|
+
if (typeof module.default !== "function")
|
|
17
|
+
throw new Error(`${name}(): ${hook} must export a function as default`);
|
|
18
|
+
return module.default;
|
|
19
|
+
}
|
|
20
|
+
/** Binds the config's `globalSetup` / `globalTeardown` to the config they receive and the project folder they load from */
|
|
21
|
+
export function globalHooks(config, hookConfig, projectDir) {
|
|
22
|
+
const returned = [];
|
|
23
|
+
return {
|
|
24
|
+
async setup() {
|
|
25
|
+
if (config.globalSetup === undefined)
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
const setup = (await loadHook("globalSetup", config.globalSetup, projectDir));
|
|
29
|
+
const teardown = await setup(hookConfig);
|
|
30
|
+
if (typeof teardown === "function")
|
|
31
|
+
returned.push(teardown);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const message = messageOf(error);
|
|
35
|
+
throw new Error(message.startsWith("globalSetup(): ")
|
|
36
|
+
? message
|
|
37
|
+
: `globalSetup(): ${message}`);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
async teardown() {
|
|
41
|
+
let first;
|
|
42
|
+
const record = (error) => {
|
|
43
|
+
const message = messageOf(error);
|
|
44
|
+
first ??= new Error(message.startsWith("globalTeardown(): ")
|
|
45
|
+
? message
|
|
46
|
+
: `globalTeardown(): ${message}`);
|
|
47
|
+
};
|
|
48
|
+
for (const teardown of returned.splice(0).reverse())
|
|
49
|
+
try {
|
|
50
|
+
await teardown();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
record(error);
|
|
54
|
+
}
|
|
55
|
+
if (config.globalTeardown !== undefined)
|
|
56
|
+
try {
|
|
57
|
+
const teardown = (await loadHook("globalTeardown", config.globalTeardown, projectDir));
|
|
58
|
+
await teardown(hookConfig);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
record(error);
|
|
62
|
+
}
|
|
63
|
+
return first;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|