@nx/playwright 23.2.0-beta.10 → 23.2.0-beta.12

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.
@@ -0,0 +1,28 @@
1
+ export type ProxyResolution = {
2
+ kind: 'direct';
3
+ } | {
4
+ kind: 'proxy';
5
+ proxy: URL;
6
+ } | {
7
+ kind: 'unusable';
8
+ variable: string;
9
+ value: string;
10
+ reason: string;
11
+ };
12
+ export declare function resolveProxyForUrl(url: URL, env?: NodeJS.ProcessEnv, npmConfigProxy?: boolean): ProxyResolution;
13
+ /**
14
+ * The proxy `env` configures for `protocol`, before any `no_proxy` exclusion:
15
+ * `<protocol>_proxy`, falling back to `all_proxy`, normalized as the probe
16
+ * would use it (a value with no scheme takes the target's protocol). With
17
+ * `npmConfigProxy`, `npm_config_<protocol>_proxy` comes first and
18
+ * `npm_config_proxy` sits between the two.
19
+ */
20
+ export declare function resolveProxyForProtocol(protocol: string, env?: NodeJS.ProcessEnv, npmConfigProxy?: boolean): ProxyResolution;
21
+ /**
22
+ * The `no_proxy` entries `env` configures (`npm_config_no_proxy` first with
23
+ * `npmConfigProxy`), lowercased and with the empty entries a separator run
24
+ * leaves dropped. Each entry excludes hosts on its own, so their order carries
25
+ * no meaning.
26
+ */
27
+ export declare function noProxyEntries(env: NodeJS.ProcessEnv, npmConfigProxy?: boolean): string[];
28
+ export declare function withoutCredentials(value: string): string;
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ // Playwright routes its readiness poll through a proxy configured in the
3
+ // environment, so a gate that always connected directly would be answering a
4
+ // different question than the check it exists to satisfy. Selection mirrors the
5
+ // `proxy-from-env` copy playwright-core 1.60.0 bundles (`getProxyForUrl`)
6
+ // together with its own `normalizeProxyURL`; with `npmConfigProxy` it mirrors
7
+ // the copy bundled before 1.59.0, which read npm's `npm_config_*` proxy
8
+ // variables ahead of the standard ones. The transport is left to
9
+ // `https-proxy-agent`, the agent Playwright itself hands to `https.request`
10
+ // for a proxied https target.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.resolveProxyForUrl = resolveProxyForUrl;
13
+ exports.resolveProxyForProtocol = resolveProxyForProtocol;
14
+ exports.noProxyEntries = noProxyEntries;
15
+ exports.withoutCredentials = withoutCredentials;
16
+ const DEFAULT_PORTS = {
17
+ ftp: 21,
18
+ gopher: 70,
19
+ http: 80,
20
+ https: 443,
21
+ ws: 80,
22
+ wss: 443,
23
+ };
24
+ function resolveProxyForUrl(url, env = process.env, npmConfigProxy = false) {
25
+ const protocol = url.protocol.split(':')[0];
26
+ // Matched on the host with any port stripped, which keeps the brackets on an
27
+ // IPv6 literal.
28
+ const hostname = url.host.replace(/:\d*$/, '');
29
+ if (!hostname || !protocol) {
30
+ return { kind: 'direct' };
31
+ }
32
+ const port = parseInt(url.port) || DEFAULT_PORTS[protocol] || 0;
33
+ if (!shouldProxy(hostname, port, env, npmConfigProxy)) {
34
+ return { kind: 'direct' };
35
+ }
36
+ return resolveProxyForProtocol(protocol, env, npmConfigProxy);
37
+ }
38
+ /**
39
+ * The proxy `env` configures for `protocol`, before any `no_proxy` exclusion:
40
+ * `<protocol>_proxy`, falling back to `all_proxy`, normalized as the probe
41
+ * would use it (a value with no scheme takes the target's protocol). With
42
+ * `npmConfigProxy`, `npm_config_<protocol>_proxy` comes first and
43
+ * `npm_config_proxy` sits between the two.
44
+ */
45
+ function resolveProxyForProtocol(protocol, env = process.env, npmConfigProxy = false) {
46
+ const source = readEnv(npmConfigProxy
47
+ ? [
48
+ `npm_config_${protocol}_proxy`,
49
+ `${protocol}_proxy`,
50
+ 'npm_config_proxy',
51
+ 'all_proxy',
52
+ ]
53
+ : [`${protocol}_proxy`, 'all_proxy'], env);
54
+ if (!source.value) {
55
+ return { kind: 'direct' };
56
+ }
57
+ // A value with no scheme takes the target's protocol, which is where this
58
+ // differs from `normalizeProxyURL` alone: that only defaults to http.
59
+ const scheme = source.value.includes('://') ? '' : `${protocol}://`;
60
+ const normalized = `${scheme}${source.value}`.trim();
61
+ // Playwright's own probe errors out on a proxy URL it cannot use instead of
62
+ // falling back to a direct request, so the gate must not clear here. Each
63
+ // cause is named, because the value alone often does not show it: redacting
64
+ // the credentials takes the offending character with them.
65
+ const unusable = (reason) => ({
66
+ kind: 'unusable',
67
+ variable: source.variable,
68
+ value: withoutCredentials(source.value),
69
+ reason,
70
+ });
71
+ let proxy;
72
+ try {
73
+ proxy = new URL(/^\w+:\/\//.test(normalized) ? normalized : `http://${normalized}`);
74
+ }
75
+ catch {
76
+ return unusable('is not a valid url');
77
+ }
78
+ // Anything else parses but cannot be dialled: an http target throws out of
79
+ // `http.request`, and an https one gets `CONNECT` spoken at a port that is
80
+ // not answering HTTP, which surfaces much later as a refused server.
81
+ if (proxy.protocol !== 'http:' && proxy.protocol !== 'https:') {
82
+ return unusable('is not an http or https url');
83
+ }
84
+ // Credentials are percent-decoded when the request is built, far from the
85
+ // configuration that produced them, so a value that cannot be decoded is
86
+ // rejected here where the failure can still name where it came from.
87
+ try {
88
+ decodeURIComponent(proxy.username);
89
+ decodeURIComponent(proxy.password);
90
+ }
91
+ catch {
92
+ return unusable('has credentials that are not valid percent-encoding');
93
+ }
94
+ return { kind: 'proxy', proxy };
95
+ }
96
+ // `no_proxy` governs where traffic may go. Sending the target URL to an
97
+ // excluded proxy would disclose it even when the direct probe is the one that
98
+ // succeeds.
99
+ function shouldProxy(hostname, port, env, npmConfigProxy) {
100
+ const noProxy = readNoProxy(env, npmConfigProxy).toLowerCase();
101
+ if (!noProxy) {
102
+ return true;
103
+ }
104
+ if (noProxy === '*') {
105
+ return false;
106
+ }
107
+ return noProxyEntries(env, npmConfigProxy).every((entry) => {
108
+ const withPort = entry.match(/^(.+):(\d+)$/);
109
+ let host = withPort ? withPort[1] : entry;
110
+ const entryPort = withPort ? parseInt(withPort[2]) : 0;
111
+ if (entryPort && entryPort !== port) {
112
+ return true;
113
+ }
114
+ if (!/^[.*]/.test(host)) {
115
+ return hostname !== host;
116
+ }
117
+ if (host.charAt(0) === '*') {
118
+ host = host.slice(1);
119
+ }
120
+ return !hostname.endsWith(host);
121
+ });
122
+ }
123
+ /**
124
+ * The `no_proxy` entries `env` configures (`npm_config_no_proxy` first with
125
+ * `npmConfigProxy`), lowercased and with the empty entries a separator run
126
+ * leaves dropped. Each entry excludes hosts on its own, so their order carries
127
+ * no meaning.
128
+ */
129
+ function noProxyEntries(env, npmConfigProxy = false) {
130
+ return readNoProxy(env, npmConfigProxy)
131
+ .toLowerCase()
132
+ .split(/[,\s]/)
133
+ .filter(Boolean);
134
+ }
135
+ function readNoProxy(env, npmConfigProxy) {
136
+ return readEnv(npmConfigProxy ? ['npm_config_no_proxy', 'no_proxy'] : ['no_proxy'], env).value;
137
+ }
138
+ // The executor runs in its own process, so a proxy variable the Playwright
139
+ // config sets while loading (through `dotenv`, say) reaches Playwright's probe
140
+ // and not this one. The name that was read is reported alongside the value so a
141
+ // bad one can be found among the variables this looks at. The first name with
142
+ // a value wins, in the lowercase spelling before the uppercase one.
143
+ function readEnv(names, env) {
144
+ for (const name of names) {
145
+ for (const variable of [name.toLowerCase(), name.toUpperCase()]) {
146
+ if (env[variable]) {
147
+ return { variable, value: env[variable] };
148
+ }
149
+ }
150
+ }
151
+ return { variable: names[names.length - 1].toUpperCase(), value: '' };
152
+ }
153
+ // A rejected value is echoed back so it can be recognized in the environment,
154
+ // and it reaches the task log, so the credentials come off first.
155
+ function withoutCredentials(value) {
156
+ const at = value.lastIndexOf('@');
157
+ return at === -1 ? value : `***@${value.slice(at + 1)}`;
158
+ }
@@ -0,0 +1,9 @@
1
+ export type Schema = {
2
+ servers: Array<{
3
+ port?: number;
4
+ url?: string;
5
+ ignoreHTTPSErrors?: boolean;
6
+ timeout?: number;
7
+ }>;
8
+ timeout?: number;
9
+ };
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "https://json-schema.org/schema",
3
+ "version": 2,
4
+ "title": "Schema for Playwright Wait For Web Server Executor",
5
+ "description": "Waits for the E2E web server(s) to be ready.",
6
+ "type": "object",
7
+ "properties": {
8
+ "servers": {
9
+ "type": "array",
10
+ "description": "The web servers to wait for.",
11
+ "items": {
12
+ "type": "object",
13
+ "properties": {
14
+ "port": {
15
+ "type": "number",
16
+ "description": "The port to wait for TCP connections on.",
17
+ "minimum": 1,
18
+ "maximum": 65535,
19
+ "multipleOf": 1
20
+ },
21
+ "url": {
22
+ "type": "string",
23
+ "description": "The URL to poll. Redirects are followed and the server is ready once the final response has a status from 200 to 403."
24
+ },
25
+ "ignoreHTTPSErrors": {
26
+ "type": "boolean",
27
+ "description": "Whether to ignore HTTPS errors (e.g. a self-signed certificate) when polling the URL.",
28
+ "default": false
29
+ },
30
+ "timeout": {
31
+ "type": "number",
32
+ "description": "The maximum time to wait for this server in milliseconds. A value of 0 means unset, as it does for Playwright's webServer.timeout. Defaults to 60000.",
33
+ "default": 60000
34
+ }
35
+ }
36
+ }
37
+ },
38
+ "timeout": {
39
+ "type": "number",
40
+ "description": "The maximum time to wait in milliseconds before failing. Overrides the timeout configured on every server. A value of 0 means unset, as it does for Playwright's webServer.timeout."
41
+ }
42
+ },
43
+ "required": ["servers"]
44
+ }
@@ -0,0 +1,6 @@
1
+ import { type ExecutorContext } from '@nx/devkit';
2
+ import type { Schema } from './schema';
3
+ export declare function waitForWebserverExecutor(options: Schema, context: ExecutorContext): Promise<{
4
+ success: boolean;
5
+ }>;
6
+ export default waitForWebserverExecutor;
@@ -0,0 +1,388 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.waitForWebserverExecutor = waitForWebserverExecutor;
4
+ const tslib_1 = require("tslib");
5
+ const devkit_1 = require("@nx/devkit");
6
+ const https_proxy_agent_1 = require("https-proxy-agent");
7
+ const http = tslib_1.__importStar(require("node:http"));
8
+ const https = tslib_1.__importStar(require("node:https"));
9
+ const node_net_1 = require("node:net");
10
+ const node_path_1 = require("node:path");
11
+ const installed_playwright_1 = require("../../utils/installed-playwright");
12
+ const npm_config_proxy_1 = require("../../utils/npm-config-proxy");
13
+ const proxied_tls_verification_1 = require("../../utils/proxied-tls-verification");
14
+ const proxy_1 = require("./proxy");
15
+ const DEFAULT_TIMEOUT = 60_000;
16
+ // Retry cadence mirrors Playwright's web server readiness loop: a short ramp
17
+ // up front, then a steady interval.
18
+ const RETRY_SCHEDULE = [100, 250, 500];
19
+ const FALLBACK_RETRY_DELAY = 1_000;
20
+ // A probe the deadline cut short before it could learn anything observed
21
+ // nothing, so it must not replace what the previous probe saw.
22
+ const NO_OBSERVATION = 'the deadline passed before the server responded';
23
+ async function waitForWebserverExecutor(options, context) {
24
+ const servers = options.servers ?? [];
25
+ if (servers.length === 0) {
26
+ devkit_1.logger.error('@nx/playwright:wait-for-webserver requires at least one server to wait for.');
27
+ return { success: false };
28
+ }
29
+ const playwright = (0, installed_playwright_1.installedPlaywrightVersion)(playwrightRequirePaths(context));
30
+ const skipProxiedTls = (0, proxied_tls_verification_1.installedPlaywrightSkipsProxiedTls)(playwright);
31
+ const npmConfigProxy = (0, npm_config_proxy_1.installedPlaywrightReadsNpmConfigProxy)(playwright);
32
+ const problem = servers
33
+ .map((server) => findServerProblem(server, npmConfigProxy))
34
+ .find(Boolean);
35
+ if (problem) {
36
+ devkit_1.logger.error(`@nx/playwright:wait-for-webserver ${problem}`);
37
+ return { success: false };
38
+ }
39
+ try {
40
+ await Promise.all(servers.map((server) =>
41
+ // A zero timeout means "unset" rather than "give up immediately", which
42
+ // is how Playwright reads it (`this._options.timeout || 60 * 1e3`).
43
+ waitForServer(server, options.timeout || server.timeout || DEFAULT_TIMEOUT, skipProxiedTls, npmConfigProxy)));
44
+ return { success: true };
45
+ }
46
+ catch (e) {
47
+ devkit_1.logger.error(`@nx/playwright:wait-for-webserver ${e instanceof Error ? e.message : String(e)}`);
48
+ return { success: false };
49
+ }
50
+ }
51
+ exports.default = waitForWebserverExecutor;
52
+ // A malformed URL, an out-of-range port and a proxy that cannot be used are all
53
+ // inputs that can never become valid, so they are rejected up front instead of
54
+ // being retried until the timeout and reported as a slow server. An unusable
55
+ // port is also the only input that makes `connect` throw synchronously, which
56
+ // no probe could turn into a failure.
57
+ function findServerProblem(server, npmConfigProxy) {
58
+ if (server.port == null && !server.url) {
59
+ return 'requires each server to define a "port" or a "url".';
60
+ }
61
+ if (server.port != null && !isValidPort(server.port)) {
62
+ return `received an invalid "port": ${server.port}.`;
63
+ }
64
+ if (server.url) {
65
+ let url;
66
+ try {
67
+ url = new URL(server.url);
68
+ }
69
+ catch {
70
+ return `received an invalid "url": ${(0, proxy_1.withoutCredentials)(server.url)}.`;
71
+ }
72
+ // Only the address the wait starts from is checked here. A redirect can
73
+ // cross to a protocol with its own proxy variable, which the probe reports
74
+ // when it gets there.
75
+ const resolution = (0, proxy_1.resolveProxyForUrl)(url, process.env, npmConfigProxy);
76
+ if (resolution.kind === 'unusable') {
77
+ return `${unusableProxyProblem(resolution)}.`;
78
+ }
79
+ }
80
+ }
81
+ // Left unpunctuated because a probe failure is also quoted mid-sentence when
82
+ // the wait times out.
83
+ function unusableProxyProblem(resolution) {
84
+ return `cannot use the proxy configured in ${resolution.variable}: ${resolution.value} ${resolution.reason}`;
85
+ }
86
+ // 0 is not a connectable port, so it is rejected rather than probed.
87
+ function isValidPort(port) {
88
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
89
+ }
90
+ // The directories the task's Playwright install resolves from: the project
91
+ // root, where `playwright test` runs, then the workspace root.
92
+ function playwrightRequirePaths(context) {
93
+ if (context.root == null) {
94
+ return [];
95
+ }
96
+ const projectRoot = context.projectName != null
97
+ ? context.projectsConfigurations?.projects?.[context.projectName]?.root
98
+ : undefined;
99
+ return projectRoot != null
100
+ ? [(0, node_path_1.join)(context.root, projectRoot), context.root]
101
+ : [context.root];
102
+ }
103
+ async function waitForServer(server, timeout, skipProxiedTls, npmConfigProxy) {
104
+ // Named the way it is probed: a port wins over a url when both are set.
105
+ const label = server.port != null ? `port ${server.port}` : redactedHref(server.url);
106
+ const deadline = Date.now() + timeout;
107
+ const schedule = [...RETRY_SCHEDULE];
108
+ let lastObserved;
109
+ while (true) {
110
+ const failure = await probeServer(server, deadline, skipProxiedTls, npmConfigProxy);
111
+ if (failure === null) {
112
+ return;
113
+ }
114
+ if (failure !== NO_OBSERVATION) {
115
+ lastObserved = failure;
116
+ }
117
+ if (Date.now() >= deadline) {
118
+ throw new Error(`Timed out after ${timeout}ms waiting for the E2E web server at ${label} to be ready. Last observation: ${lastObserved ?? failure}. If the server now listens at a different address, run "nx reset" so the gate is re-inferred from the Playwright config. If a configuration-scoped env file (".env.<target>.<configuration>") sets the address, the gate is inferred without it; set "waitForWebServer" to false on @nx/playwright/plugin instead.`);
119
+ }
120
+ const delay = schedule.shift() ?? FALLBACK_RETRY_DELAY;
121
+ await sleep(Math.min(delay, Math.max(0, deadline - Date.now())));
122
+ }
123
+ }
124
+ // Probe the way Playwright does: connect to the port on both loopback
125
+ // addresses, or poll the URL for a status from 200 to 403, through whatever
126
+ // proxy the environment configures for it. A port is always probed directly,
127
+ // because Playwright checks one with a raw socket too.
128
+ function probeServer(server, deadline, skipProxiedTls, npmConfigProxy) {
129
+ return server.port != null
130
+ ? probePort(server.port, deadline)
131
+ : probeUrl(server.url, server.ignoreHTTPSErrors ?? false, deadline, skipProxiedTls, npmConfigProxy);
132
+ }
133
+ function probePort(port, deadline) {
134
+ // Silence for the retry-delay window is the probe's observation; clamp it so
135
+ // a connect that hangs (e.g. a firewalled port) cannot run past the
136
+ // readiness deadline. Floored at 1ms because setTimeout(0) disables the
137
+ // socket's idle timeout instead.
138
+ const timeout = Math.max(1, Math.min(FALLBACK_RETRY_DELAY, deadline - Date.now()));
139
+ const canConnect = (host) => new Promise((resolve) => {
140
+ const address = host.includes(':')
141
+ ? `[${host}]:${port}`
142
+ : `${host}:${port}`;
143
+ const socket = (0, node_net_1.connect)({ port, host });
144
+ const settle = (failure) => {
145
+ socket.removeAllListeners();
146
+ socket.destroy();
147
+ resolve(failure);
148
+ };
149
+ socket.setTimeout(timeout);
150
+ socket.once('connect', () => settle(null));
151
+ socket.once('timeout', () => settle(`${address} timed out`));
152
+ socket.once('error', (error) => settle(`${address} ${error.code ?? error.message}`));
153
+ });
154
+ return new Promise((resolve) => {
155
+ const failures = [];
156
+ let pending = 2;
157
+ const onResult = (failure) => {
158
+ if (failure === null) {
159
+ resolve(null);
160
+ }
161
+ else {
162
+ failures.push(failure);
163
+ if (--pending === 0) {
164
+ resolve(failures.join('; '));
165
+ }
166
+ }
167
+ };
168
+ void canConnect('127.0.0.1').then(onResult);
169
+ void canConnect('::1').then(onResult);
170
+ });
171
+ }
172
+ async function probeUrl(url, ignoreHTTPSErrors, deadline, skipProxiedTls, npmConfigProxy) {
173
+ // Validated before any server is probed, so this cannot throw.
174
+ const parsedUrl = new URL(url);
175
+ let response = await requestStatus(parsedUrl, ignoreHTTPSErrors, deadline, skipProxiedTls, npmConfigProxy);
176
+ if (response.status === 404 && parsedUrl.pathname === '/') {
177
+ const indexUrl = new URL(parsedUrl);
178
+ indexUrl.pathname = '/index.html';
179
+ response = await requestStatus(indexUrl, ignoreHTTPSErrors, deadline, skipProxiedTls, npmConfigProxy);
180
+ }
181
+ if (response.status >= 200 && response.status < 404) {
182
+ return null;
183
+ }
184
+ return (response.failure ??
185
+ `${redactedHref(response.href)} responded with status ${response.status}${viaProxy(response.via)}`);
186
+ }
187
+ // The one place the phrase is written, so a failure the probe words and one the
188
+ // request words cannot drift apart.
189
+ function viaProxy(via) {
190
+ return via ? ` via the proxy at ${via}` : '';
191
+ }
192
+ // Probe failures reach the task log, so a URL's credentials come off before it
193
+ // enters one. Callers only pass URLs that already round-tripped through `URL`.
194
+ function redactedHref(href) {
195
+ const url = new URL(href);
196
+ if (!url.username && !url.password) {
197
+ return href;
198
+ }
199
+ url.username = '***';
200
+ url.password = '';
201
+ return url.href;
202
+ }
203
+ function requestStatus(url, ignoreHTTPSErrors, deadline, skipProxiedTls, npmConfigProxy, redirects = 0) {
204
+ // A chain that ran out of budget still told us the server is answering, so it
205
+ // is a real observation rather than silence, whether the budget ran out
206
+ // between hops or inside the last one.
207
+ const redirectFailure = redirects > 0
208
+ ? `${redactedHref(url.href)} redirected ${redirects} times without resolving`
209
+ : undefined;
210
+ const remaining = deadline - Date.now();
211
+ if (remaining <= 0) {
212
+ return Promise.resolve({
213
+ href: url.href,
214
+ status: 0,
215
+ failure: redirectFailure ?? NO_OBSERVATION,
216
+ });
217
+ }
218
+ // Resolved per URL rather than once per probe so a redirect that changes
219
+ // protocol re-selects the way Playwright's own recursion does.
220
+ const resolution = (0, proxy_1.resolveProxyForUrl)(url, process.env, npmConfigProxy);
221
+ if (resolution.kind === 'unusable') {
222
+ return Promise.resolve({
223
+ href: url.href,
224
+ status: 0,
225
+ failure: `${redactedHref(url.href)} ${unusableProxyProblem(resolution)}`,
226
+ });
227
+ }
228
+ const via = resolution.kind === 'proxy' ? resolution.proxy.host : undefined;
229
+ const describe = (detail) => `${redactedHref(url.href)} ${detail}${viaProxy(via)}`;
230
+ return new Promise((resolve) => {
231
+ const requestOptions = {
232
+ method: 'GET',
233
+ headers: { Accept: '*/*' },
234
+ rejectUnauthorized: !ignoreHTTPSErrors,
235
+ };
236
+ let request;
237
+ let settled = false;
238
+ // Until the agent finishes `CONNECT` the request holds no socket, so
239
+ // destroying it cannot reach the connection to the proxy. Aborting is what
240
+ // closes that leg; without it a proxy that never answers leaves a live
241
+ // socket nothing else can reach.
242
+ const connecting = new AbortController();
243
+ // Declared ahead of the settle it is cleared by, which the timer's own
244
+ // callback in turn calls.
245
+ let budget;
246
+ const settle = (response) => {
247
+ if (settled) {
248
+ return;
249
+ }
250
+ settled = true;
251
+ clearTimeout(budget);
252
+ request?.destroy();
253
+ connecting.abort();
254
+ resolve(response);
255
+ };
256
+ // The rest of the readiness budget bounds the request in wall-clock time. A
257
+ // request arms its own idle timeout only once it holds a socket, and one
258
+ // waiting on a proxy that accepted the connection but never answered
259
+ // `CONNECT` never gets that far. Unreferenced because it is a bound rather
260
+ // than work of its own: it still fires while a request is in flight, and
261
+ // cannot hold the process open once nothing else does.
262
+ budget = setTimeout(() => {
263
+ // Silence for the window the port probe allows is an observation of the
264
+ // server. A request cut off with less budget than that only means the
265
+ // deadline arrived mid-flight, and must not displace what an earlier
266
+ // probe saw.
267
+ settle({
268
+ href: url.href,
269
+ status: 0,
270
+ failure: redirectFailure ??
271
+ (remaining >= FALLBACK_RETRY_DELAY
272
+ ? describe('did not respond')
273
+ : NO_OBSERVATION),
274
+ });
275
+ }, remaining).unref();
276
+ const onResponse = (response) => {
277
+ const status = response.statusCode ?? 0;
278
+ const location = response.headers.location;
279
+ response.resume();
280
+ // Follow redirects like Playwright and evaluate the final
281
+ // destination, so a redirect to an unavailable page is not read as
282
+ // ready. The readiness deadline bounds the chain instead of a hop
283
+ // count, which would fail a long chain Playwright follows to a
284
+ // ready server. Treat a malformed chain as not ready.
285
+ if (status >= 300 && status < 400 && location) {
286
+ let redirectUrl;
287
+ try {
288
+ redirectUrl = new URL(location, url);
289
+ }
290
+ catch {
291
+ settle({
292
+ href: url.href,
293
+ status: 0,
294
+ // Node's header parser passes tab and the C1 controls through, and
295
+ // the value reaches a terminal from here. Unparseable for URL, so
296
+ // credentials come off with the string-based treatment.
297
+ failure: describe(`redirected to an invalid location "${(0, proxy_1.withoutCredentials)(location.replace(/[\x00-\x1f\x7f-\x9f]/g, ''))}"`),
298
+ });
299
+ return;
300
+ }
301
+ if (redirectUrl.protocol !== 'http:' &&
302
+ redirectUrl.protocol !== 'https:') {
303
+ // Classified like an unparseable location rather than followed into
304
+ // `http.request`, which would throw synchronously and fail the wait
305
+ // outright. A boot-time redirect can still settle into a ready
306
+ // server within the budget.
307
+ settle({
308
+ href: url.href,
309
+ status: 0,
310
+ failure: describe(`redirected to "${redactedHref(redirectUrl.href)}", which is not an http or https url`),
311
+ });
312
+ return;
313
+ }
314
+ settle(requestStatus(redirectUrl, ignoreHTTPSErrors, deadline, skipProxiedTls, npmConfigProxy, redirects + 1));
315
+ return;
316
+ }
317
+ settle({ href: url.href, via, status });
318
+ };
319
+ switch (resolution.kind) {
320
+ case 'direct':
321
+ // `localhost` can resolve to ::1 first while the server listens on
322
+ // 127.0.0.1 only; Happy Eyeballs tries both families the way
323
+ // Playwright's probe agent does, on every Node line regardless of the
324
+ // process-wide default.
325
+ // The cast is needed because @types/node's RequestOptions omits the
326
+ // option; Node forwards request options to socket.connect, which
327
+ // honors it.
328
+ request = (url.protocol === 'https:' ? https : http).request(url, {
329
+ ...requestOptions,
330
+ autoSelectFamily: true,
331
+ }, onResponse);
332
+ break;
333
+ case 'proxy':
334
+ request = proxiedRequest(url, resolution.proxy, requestOptions, skipProxiedTls, connecting.signal, onResponse);
335
+ break;
336
+ default: {
337
+ // Both kinds that reach here route the request somewhere; a new one has
338
+ // to say where.
339
+ const unhandled = resolution;
340
+ throw new Error(`Unhandled proxy resolution ${JSON.stringify(unhandled)}`);
341
+ }
342
+ }
343
+ // Not `once`: aborting the connection raises a second error on a request
344
+ // that has already been settled, and an emitter left without a listener
345
+ // would throw it at the process.
346
+ request.on('error', (error) => {
347
+ const detail = error.code ?? error.message;
348
+ settle({
349
+ href: url.href,
350
+ status: 0,
351
+ failure: describe(redirects > 0 ? `${detail} after ${redirects} redirects` : detail),
352
+ });
353
+ });
354
+ request.end();
355
+ });
356
+ }
357
+ // An http target is named in the request line and the request itself goes to
358
+ // the proxy, which is what Playwright does. An https target cannot be named
359
+ // that way, so the agent opens a tunnel with `CONNECT` and TLS runs over it.
360
+ // Proxy credentials travel differently on the two routes, on the URL for the
361
+ // first and as `Proxy-Authorization` for the second, and both match Playwright.
362
+ function proxiedRequest(url, proxy, options, skipProxiedTls, signal, onResponse) {
363
+ if (url.protocol === 'https:') {
364
+ const agent = new https_proxy_agent_1.HttpsProxyAgent(proxy);
365
+ // The agent dials the proxy itself, so this is the only handle on that
366
+ // connection. A signal in the request options does not reach it.
367
+ agent.connectOpts.signal = signal;
368
+ return https.request(url, {
369
+ ...options,
370
+ // Playwright before 1.59.0 never verifies the origin certificate on
371
+ // this route, whatever the caller asked for.
372
+ // TODO(v25): drop once minSupportedPlaywrightVersion reaches 1.59.0.
373
+ ...(skipProxiedTls ? { rejectUnauthorized: false } : {}),
374
+ agent,
375
+ }, onResponse);
376
+ }
377
+ // Playwright's Happy Eyeballs agent also dials the proxy on this route (its
378
+ // CONNECT route above drops it, so that one stays default). Same cast as the
379
+ // direct probe: @types/node's RequestOptions omits the option.
380
+ return (proxy.protocol === 'https:' ? https : http).request(proxy, {
381
+ ...options,
382
+ path: url.href,
383
+ autoSelectFamily: true,
384
+ }, onResponse);
385
+ }
386
+ function sleep(ms) {
387
+ return new Promise((resolve) => setTimeout(resolve, ms));
388
+ }
@@ -30,8 +30,7 @@ export interface ConfigurationGeneratorSchema {
30
30
  addPlugin?: boolean;
31
31
  }
32
32
 
33
- export interface NormalizedGeneratorOptions
34
- extends ConfigurationGeneratorSchema {
33
+ export interface NormalizedGeneratorOptions extends ConfigurationGeneratorSchema {
35
34
  addPlugin: boolean;
36
35
  directory: string;
37
36
  linter: LinterType;
@@ -2,6 +2,21 @@ import { type CreateNodes } from '@nx/devkit';
2
2
  export interface PlaywrightPluginOptions {
3
3
  targetName?: string;
4
4
  ciTargetName?: string;
5
+ /**
6
+ * Maximum time in milliseconds the inferred web server readiness task waits
7
+ * for a server to be ready before failing. Overrides the `timeout` each
8
+ * Playwright `webServer` configures. Defaults to that configured timeout,
9
+ * or 60000 when neither is set.
10
+ */
11
+ webServerTimeout?: number;
12
+ /**
13
+ * Whether to infer a task that waits for the web server to be ready before the
14
+ * Playwright test tasks run. When false, no readiness task is inferred and the
15
+ * tests fall back to Playwright's own `reuseExistingServer` probe. Defaults to
16
+ * true.
17
+ */
18
+ waitForWebServer?: boolean;
5
19
  }
6
20
  export declare const createNodes: CreateNodes<PlaywrightPluginOptions>;
7
21
  export declare const createNodesV2: CreateNodes<PlaywrightPluginOptions>;
22
+ export declare function _clearWarnedUnparseableCommands(): void;