@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.
- package/dist/src/executors/wait-for-webserver/proxy.d.ts +28 -0
- package/dist/src/executors/wait-for-webserver/proxy.js +158 -0
- package/dist/src/executors/wait-for-webserver/schema.d.ts +9 -0
- package/dist/src/executors/wait-for-webserver/schema.json +44 -0
- package/dist/src/executors/wait-for-webserver/wait-for-webserver.impl.d.ts +6 -0
- package/dist/src/executors/wait-for-webserver/wait-for-webserver.impl.js +388 -0
- package/dist/src/generators/configuration/schema.d.ts +1 -2
- package/dist/src/plugins/plugin.d.ts +15 -0
- package/dist/src/plugins/plugin.js +395 -43
- package/dist/src/plugins/webserver-config-worker.d.ts +13 -0
- package/dist/src/plugins/webserver-config-worker.js +56 -0
- package/dist/src/plugins/webserver-readiness.d.ts +117 -0
- package/dist/src/plugins/webserver-readiness.js +391 -0
- package/dist/src/utils/installed-playwright.d.ts +9 -0
- package/dist/src/utils/installed-playwright.js +23 -0
- package/dist/src/utils/npm-config-proxy.d.ts +11 -0
- package/dist/src/utils/npm-config-proxy.js +18 -0
- package/dist/src/utils/proxied-tls-verification.d.ts +12 -0
- package/dist/src/utils/proxied-tls-verification.js +19 -0
- package/dist/src/utils/versions.d.ts +2 -0
- package/dist/src/utils/versions.js +11 -1
- package/executors.json +6 -0
- package/package.json +7 -6
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createNodesV2 = exports.createNodes = void 0;
|
|
4
|
+
exports._clearWarnedUnparseableCommands = _clearWarnedUnparseableCommands;
|
|
4
5
|
const internal_1 = require("@nx/devkit/internal");
|
|
5
6
|
const devkit_1 = require("@nx/devkit");
|
|
6
7
|
const js_1 = require("@nx/js");
|
|
@@ -8,7 +9,11 @@ const internal_2 = require("@nx/js/internal");
|
|
|
8
9
|
const minimatch_1 = require("minimatch");
|
|
9
10
|
const node_fs_1 = require("node:fs");
|
|
10
11
|
const node_path_1 = require("node:path");
|
|
12
|
+
const installed_playwright_1 = require("../utils/installed-playwright");
|
|
13
|
+
const npm_config_proxy_1 = require("../utils/npm-config-proxy");
|
|
14
|
+
const proxied_tls_verification_1 = require("../utils/proxied-tls-verification");
|
|
11
15
|
const reporters_1 = require("../utils/reporters");
|
|
16
|
+
const webserver_readiness_1 = require("./webserver-readiness");
|
|
12
17
|
const playwrightConfigGlob = '**/playwright.config.{js,ts,cjs,cts,mjs,mts}';
|
|
13
18
|
exports.createNodes = [
|
|
14
19
|
playwrightConfigGlob,
|
|
@@ -20,13 +25,29 @@ exports.createNodes = [
|
|
|
20
25
|
const pmc = (0, devkit_1.getPackageManagerCommand)(packageManager);
|
|
21
26
|
const lockFileName = (0, js_1.getLockFileName)(packageManager);
|
|
22
27
|
const normalizedOptions = normalizeOptions(options);
|
|
28
|
+
// A config can read process.env, which no file hash covers (and which the
|
|
29
|
+
// daemon swaps per client). Key the cache on the daemon-allowed env set so
|
|
30
|
+
// an ambient change re-evaluates instead of serving stale targets.
|
|
31
|
+
const ambientEnvHash = (0, internal_1.hashDaemonClientEnv)();
|
|
32
|
+
const ambientEnvGeneration = (0, internal_1.getDaemonClientEnvGeneration)();
|
|
33
|
+
// The workspace-root dotenv candidates are the same for every config, so
|
|
34
|
+
// hash each file at most once per pass rather than once per config.
|
|
35
|
+
const dotEnvFileHashes = new Map();
|
|
23
36
|
try {
|
|
24
37
|
const { entries, preErrors } = await filterPlaywrightConfigs(configFilePaths, context);
|
|
25
|
-
const projectHashes = await (0, internal_1.calculateHashesForCreateNodes)(entries.map((e) => e.projectRoot), { ...normalizedOptions,
|
|
38
|
+
const projectHashes = await (0, internal_1.calculateHashesForCreateNodes)(entries.map((e) => e.projectRoot), { ...normalizedOptions, ambientEnvHash }, context, entries.map((e) => [lockFileName, ...e.externalTsconfigInputs]));
|
|
39
|
+
// A TypeScript config is `require`d without clearing the module cache, so
|
|
40
|
+
// a process that already evaluated it (the daemon, with plugin isolation
|
|
41
|
+
// off) gets that evaluation back: an edited config would keep its old
|
|
42
|
+
// targets and the env writes it makes while loading would not run again.
|
|
43
|
+
// Once per pass, so every config loads fresh.
|
|
44
|
+
if (entries.some((e) => require.cache[(0, node_path_1.join)(context.workspaceRoot, e.configFile)])) {
|
|
45
|
+
(0, internal_1.clearRequireCache)();
|
|
46
|
+
}
|
|
26
47
|
let results = [];
|
|
27
48
|
let nodeErrors = [];
|
|
28
49
|
try {
|
|
29
|
-
results = await (0, devkit_1.createNodesFromFiles)((configFile, _, ctx, idx) => createNodesInternal(configFile, normalizedOptions, ctx, pluginCache, pmc, entries[idx].externalTsconfigInputs, projectHashes[idx]), entries.map((e) => e.configFile), options, context);
|
|
50
|
+
results = await (0, devkit_1.createNodesFromFiles)((configFile, _, ctx, idx) => createNodesInternal(configFile, normalizedOptions, ctx, pluginCache, pmc, entries[idx].externalTsconfigInputs, projectHashes[idx], dotEnvFileHashes), entries.map((e) => e.configFile), options, context);
|
|
30
51
|
}
|
|
31
52
|
catch (e) {
|
|
32
53
|
if (e instanceof devkit_1.AggregateCreateNodesError) {
|
|
@@ -44,17 +65,52 @@ exports.createNodes = [
|
|
|
44
65
|
return results;
|
|
45
66
|
}
|
|
46
67
|
finally {
|
|
47
|
-
|
|
68
|
+
// The daemon can apply another client's env mid-pass (worker message
|
|
69
|
+
// dispatch is unserialized); entries built after that are keyed under
|
|
70
|
+
// the stale pass-start digest, so drop the write and let the next pass
|
|
71
|
+
// rebuild under a coherent key. The generation catches an env that
|
|
72
|
+
// changed and changed back mid-pass, which the digest misses.
|
|
73
|
+
if ((0, internal_1.getDaemonClientEnvGeneration)() === ambientEnvGeneration &&
|
|
74
|
+
(0, internal_1.hashDaemonClientEnv)() === ambientEnvHash) {
|
|
75
|
+
pluginCache.writeToDisk();
|
|
76
|
+
}
|
|
48
77
|
}
|
|
49
78
|
},
|
|
50
79
|
];
|
|
51
80
|
exports.createNodesV2 = exports.createNodes;
|
|
52
|
-
async function createNodesInternal(configFilePath, normalizedOptions, context, pluginCache, pmc, externalTsconfigInputs, hash) {
|
|
81
|
+
async function createNodesInternal(configFilePath, normalizedOptions, context, pluginCache, pmc, externalTsconfigInputs, hash, dotEnvFileHashes) {
|
|
53
82
|
const projectRoot = (0, node_path_1.dirname)(configFilePath);
|
|
54
|
-
|
|
55
|
-
|
|
83
|
+
// The createNodes hash covers projectRoot files, the lockfile, and tsconfig,
|
|
84
|
+
// but a workspace-root dotenv a nested project loads is outside it. Fold the
|
|
85
|
+
// consumer chains' dotenv fingerprints into the cache key so a dotenv change
|
|
86
|
+
// rebuilds the inferred targets instead of returning stale ones.
|
|
87
|
+
const chainDotEnvPairs = getChainDotEnvPairs(context.workspaceRoot, projectRoot, normalizedOptions, dotEnvFileHashes);
|
|
88
|
+
// The gate follows the installed Playwright's probe semantics. A linked
|
|
89
|
+
// install can cross the version floor without touching the lockfile, so the
|
|
90
|
+
// key carries the decision rather than trusting the hash to notice.
|
|
91
|
+
const playwright = (0, installed_playwright_1.installedPlaywrightVersion)([
|
|
92
|
+
(0, node_path_1.join)(context.workspaceRoot, projectRoot),
|
|
93
|
+
context.workspaceRoot,
|
|
94
|
+
]);
|
|
95
|
+
const legacyProxiedTls = (0, proxied_tls_verification_1.installedPlaywrightSkipsProxiedTls)(playwright);
|
|
96
|
+
const npmConfigProxy = (0, npm_config_proxy_1.installedPlaywrightReadsNpmConfigProxy)(playwright);
|
|
97
|
+
const cacheKey = `${hash}-${(0, internal_1.hashObject)({
|
|
98
|
+
chainDotEnvPairs,
|
|
99
|
+
legacyProxiedTls,
|
|
100
|
+
npmConfigProxy,
|
|
101
|
+
})}`;
|
|
102
|
+
let playwrightTargets = pluginCache.get(cacheKey);
|
|
103
|
+
if (!playwrightTargets) {
|
|
104
|
+
const { taskEnvEvalFailed, ...built } = await buildPlaywrightTargets(configFilePath, projectRoot, normalizedOptions, context, pmc, externalTsconfigInputs, chainDotEnvPairs, legacyProxiedTls, npmConfigProxy);
|
|
105
|
+
// The key encodes nothing about evaluation success, so caching a failed
|
|
106
|
+
// evaluation's gate-less fallback would make a transient failure (a
|
|
107
|
+
// timeout, a fork error) permanent; leave it out so the next pass retries.
|
|
108
|
+
if (!taskEnvEvalFailed) {
|
|
109
|
+
pluginCache.set(cacheKey, built);
|
|
110
|
+
}
|
|
111
|
+
playwrightTargets = built;
|
|
56
112
|
}
|
|
57
|
-
const { targets, metadata } =
|
|
113
|
+
const { targets, metadata } = playwrightTargets;
|
|
58
114
|
return {
|
|
59
115
|
projects: {
|
|
60
116
|
[projectRoot]: {
|
|
@@ -65,12 +121,25 @@ async function createNodesInternal(configFilePath, normalizedOptions, context, p
|
|
|
65
121
|
},
|
|
66
122
|
};
|
|
67
123
|
}
|
|
68
|
-
async function buildPlaywrightTargets(configFilePath, projectRoot, options, context, pmc, externalTsconfigInputs) {
|
|
124
|
+
async function buildPlaywrightTargets(configFilePath, projectRoot, options, context, pmc, externalTsconfigInputs, chainDotEnvPairs, legacyProxiedTls, npmConfigProxy) {
|
|
69
125
|
// Playwright forbids importing the `@playwright/test` module twice. This would affect running the tests,
|
|
70
126
|
// but we're just reading the config so let's delete the variable they are using to detect this.
|
|
71
127
|
// See: https://github.com/microsoft/playwright/pull/11218/files
|
|
72
128
|
delete process['__pw_initiator__'];
|
|
73
|
-
|
|
129
|
+
// The reads happen inside the consume callback, before the load's env
|
|
130
|
+
// writes are restored: the config object can expose getters that read env
|
|
131
|
+
// it set while loading, so it must not escape the protected scope.
|
|
132
|
+
const { consumed: { testOutput, reporterOutputs, ambientWebServers, testDir: configTestDir, testMatch, testIgnore, }, probeEnv: ambientProbeEnv, ambientEnv, } = await (0, webserver_readiness_1.loadConfigWithProbeEnv)((0, node_path_1.join)(context.workspaceRoot, configFilePath), (config) => ({
|
|
133
|
+
testOutput: getTestOutput(config),
|
|
134
|
+
reporterOutputs: (0, reporters_1.getReporterOutputs)(config),
|
|
135
|
+
ambientWebServers: (0, webserver_readiness_1.normalizeWebServers)(config.webServer),
|
|
136
|
+
testDir: config.testDir,
|
|
137
|
+
// Playwright defaults to the following pattern.
|
|
138
|
+
testMatch: materializeTestPattern(config.testMatch ?? '**/*.@(spec|test).?(c|m)[jt]s?(x)'),
|
|
139
|
+
testIgnore: config.testIgnore
|
|
140
|
+
? materializeTestPattern(config.testIgnore)
|
|
141
|
+
: undefined,
|
|
142
|
+
}));
|
|
74
143
|
const namedInputs = (0, internal_1.getNamedInputs)(projectRoot, context);
|
|
75
144
|
const tsconfigJsonInputs = externalTsconfigInputs.map((file) => ({
|
|
76
145
|
json: `{workspaceRoot}/${file}`,
|
|
@@ -78,9 +147,46 @@ async function buildPlaywrightTargets(configFilePath, projectRoot, options, cont
|
|
|
78
147
|
}));
|
|
79
148
|
const targets = {};
|
|
80
149
|
let metadata;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
150
|
+
// The readiness gate: Playwright's `reuseExistingServer` probe otherwise
|
|
151
|
+
// races the server boot, misses, and spawns its own nested server command
|
|
152
|
+
// whose I/O then leaks into the task.
|
|
153
|
+
//
|
|
154
|
+
// The command and address can read process.env, and createNodes runs without
|
|
155
|
+
// the task's dotenv loaded, so each consumer chain's servers resolve under
|
|
156
|
+
// that chain's task env. Chains whose tasks and gates load the same dotenv
|
|
157
|
+
// inputs share one resolution; distinct inputs resolve concurrently and can
|
|
158
|
+
// gate separately.
|
|
159
|
+
const gatesShareDotEnv = JSON.stringify(chainDotEnvPairs.gate) ===
|
|
160
|
+
JSON.stringify(chainDotEnvPairs.ciGate);
|
|
161
|
+
const chainsShareDotEnv = gatesShareDotEnv &&
|
|
162
|
+
JSON.stringify(chainDotEnvPairs.target) ===
|
|
163
|
+
JSON.stringify(chainDotEnvPairs.ciTarget);
|
|
164
|
+
const e2eGateName = `${options.targetName}--wait-for-webserver`;
|
|
165
|
+
const ciGateName = `${options.ciTargetName}--wait-for-webserver`;
|
|
166
|
+
let e2eResolved;
|
|
167
|
+
let ciResolved;
|
|
168
|
+
if (!options.ciTargetName || chainsShareDotEnv) {
|
|
169
|
+
e2eResolved = await resolveChainWebserver(configFilePath, projectRoot, context.workspaceRoot, ambientWebServers, ambientProbeEnv, ambientEnv, legacyProxiedTls, npmConfigProxy, options.targetName, undefined, e2eGateName, options.waitForWebServer);
|
|
170
|
+
ciResolved = e2eResolved;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
[e2eResolved, ciResolved] = await Promise.all([
|
|
174
|
+
resolveChainWebserver(configFilePath, projectRoot, context.workspaceRoot, ambientWebServers, ambientProbeEnv, ambientEnv, legacyProxiedTls, npmConfigProxy, options.targetName, undefined, e2eGateName, options.waitForWebServer),
|
|
175
|
+
resolveChainWebserver(configFilePath, projectRoot, context.workspaceRoot, ambientWebServers, ambientProbeEnv, ambientEnv, legacyProxiedTls, npmConfigProxy, options.ciTargetName, options.targetName, ciGateName, options.waitForWebServer),
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
const e2eChain = e2eResolved.chain;
|
|
179
|
+
const ciChain = ciResolved.chain;
|
|
180
|
+
const taskEnvEvalFailed = e2eResolved.taskEnvEvalFailed || ciResolved.taskEnvEvalFailed;
|
|
181
|
+
const e2eReadyTargetName = e2eChain.readinessServers.length > 0 ? e2eGateName : undefined;
|
|
182
|
+
// A shared gate runs under the e2e gate's dotenv, which the ci chain's probe
|
|
183
|
+
// env was only compared against when both gates load the same files.
|
|
184
|
+
const chainsShareGate = gatesShareDotEnv && sameChain(e2eChain, ciChain);
|
|
185
|
+
const ciReadyTargetName = ciChain.readinessServers.length > 0
|
|
186
|
+
? chainsShareGate
|
|
187
|
+
? e2eReadyTargetName
|
|
188
|
+
: ciGateName
|
|
189
|
+
: undefined;
|
|
84
190
|
const baseTargetConfig = {
|
|
85
191
|
command: 'playwright test',
|
|
86
192
|
options: {
|
|
@@ -99,12 +205,7 @@ async function buildPlaywrightTargets(configFilePath, projectRoot, options, cont
|
|
|
99
205
|
},
|
|
100
206
|
},
|
|
101
207
|
};
|
|
102
|
-
|
|
103
|
-
baseTargetConfig.dependsOn = getDependsOn(webserverCommandTasks);
|
|
104
|
-
}
|
|
105
|
-
else {
|
|
106
|
-
baseTargetConfig.parallelism = false;
|
|
107
|
-
}
|
|
208
|
+
applyChainDependsOn(baseTargetConfig, e2eChain, e2eReadyTargetName);
|
|
108
209
|
targets[options.targetName] = {
|
|
109
210
|
...baseTargetConfig,
|
|
110
211
|
cache: true,
|
|
@@ -117,6 +218,12 @@ async function buildPlaywrightTargets(configFilePath, projectRoot, options, cont
|
|
|
117
218
|
],
|
|
118
219
|
outputs: getTargetOutputs(testOutput, reporterOutputs, context.workspaceRoot, projectRoot),
|
|
119
220
|
};
|
|
221
|
+
if (e2eReadyTargetName) {
|
|
222
|
+
targets[e2eReadyTargetName] = buildWaitForWebserverTarget(e2eChain, options.webServerTimeout);
|
|
223
|
+
}
|
|
224
|
+
if (ciReadyTargetName && ciReadyTargetName !== e2eReadyTargetName) {
|
|
225
|
+
targets[ciReadyTargetName] = buildWaitForWebserverTarget(ciChain, options.webServerTimeout);
|
|
226
|
+
}
|
|
120
227
|
if (options.ciTargetName) {
|
|
121
228
|
// ensure the blob reporter output is the directory containing the blob
|
|
122
229
|
// report files
|
|
@@ -135,19 +242,22 @@ async function buildPlaywrightTargets(configFilePath, projectRoot, options, cont
|
|
|
135
242
|
],
|
|
136
243
|
outputs: getTargetOutputs(testOutput, ciReporterOutputs, context.workspaceRoot, projectRoot),
|
|
137
244
|
};
|
|
245
|
+
// Unconditionally: the chains can differ in ways the shared-gate check
|
|
246
|
+
// deliberately ignores (an extra uncovered server flips parallelism), and
|
|
247
|
+
// re-deriving is a no-op when they match.
|
|
248
|
+
applyChainDependsOn(ciBaseTargetConfig, ciChain, ciReadyTargetName);
|
|
138
249
|
const groupName = 'E2E (CI)';
|
|
139
250
|
metadata = { targetGroups: { [groupName]: [] } };
|
|
140
251
|
const ciTargetGroup = metadata.targetGroups[groupName];
|
|
141
|
-
const testDir =
|
|
142
|
-
? (0, devkit_1.joinPathFragments)(projectRoot,
|
|
252
|
+
const testDir = configTestDir
|
|
253
|
+
? (0, devkit_1.joinPathFragments)(projectRoot, configTestDir)
|
|
143
254
|
: projectRoot;
|
|
144
|
-
// Playwright defaults to the following pattern.
|
|
145
|
-
playwrightConfig.testMatch ??= '**/*.@(spec|test).?(c|m)[jt]s?(x)';
|
|
146
255
|
const dependsOn = [];
|
|
147
256
|
const testFiles = await getAllTestFiles({
|
|
148
257
|
context,
|
|
149
258
|
path: testDir,
|
|
150
|
-
|
|
259
|
+
testMatch,
|
|
260
|
+
testIgnore,
|
|
151
261
|
});
|
|
152
262
|
for (const testFile of testFiles) {
|
|
153
263
|
const outputSubfolder = (0, node_path_1.relative)(projectRoot, testFile)
|
|
@@ -161,7 +271,9 @@ async function buildPlaywrightTargets(configFilePath, projectRoot, options, cont
|
|
|
161
271
|
testFile,
|
|
162
272
|
testFiles,
|
|
163
273
|
context,
|
|
164
|
-
|
|
274
|
+
testDir,
|
|
275
|
+
testMatch,
|
|
276
|
+
testIgnore,
|
|
165
277
|
}, null, 2)}`);
|
|
166
278
|
}
|
|
167
279
|
const targetName = `${options.ciTargetName}--${relativeSpecFilePath}`;
|
|
@@ -214,9 +326,11 @@ async function buildPlaywrightTargets(configFilePath, projectRoot, options, cont
|
|
|
214
326
|
},
|
|
215
327
|
},
|
|
216
328
|
};
|
|
217
|
-
if (
|
|
329
|
+
if (chainRequiresSerialization(ciChain)) {
|
|
218
330
|
targets[options.ciTargetName].parallelism = false;
|
|
219
331
|
}
|
|
332
|
+
// The gate stays out of the group: a group member runs under the atomized
|
|
333
|
+
// target's dotenv files, and the gate's env was checked against its own.
|
|
220
334
|
ciTargetGroup.push(options.ciTargetName);
|
|
221
335
|
// infer the task to merge the reports from the atomized tasks
|
|
222
336
|
const mergeReportsTargetOutputs = new Set();
|
|
@@ -242,16 +356,24 @@ async function buildPlaywrightTargets(configFilePath, projectRoot, options, cont
|
|
|
242
356
|
};
|
|
243
357
|
ciTargetGroup.push(options.mergeReportsTargetName);
|
|
244
358
|
}
|
|
245
|
-
return { targets, metadata };
|
|
359
|
+
return { targets, metadata, taskEnvEvalFailed };
|
|
246
360
|
}
|
|
247
361
|
async function getAllTestFiles(opts) {
|
|
248
362
|
const files = await (0, internal_1.getFilesInDirectoryUsingContext)(opts.context.workspaceRoot, opts.path);
|
|
249
|
-
const matcher = createMatcher(opts.
|
|
250
|
-
const ignoredMatcher = opts.
|
|
251
|
-
? createMatcher(opts.
|
|
363
|
+
const matcher = createMatcher(opts.testMatch);
|
|
364
|
+
const ignoredMatcher = opts.testIgnore
|
|
365
|
+
? createMatcher(opts.testIgnore)
|
|
252
366
|
: () => false;
|
|
253
367
|
return files.filter((file) => matcher(file) && !ignoredMatcher(file));
|
|
254
368
|
}
|
|
369
|
+
// Copies a test file pattern off a just-loaded config: an array element or a
|
|
370
|
+
// property can be a getter reading env the load wrote, so every read has to
|
|
371
|
+
// happen before that env is restored, and a cloned RegExp leaves no
|
|
372
|
+
// config-owned object behind.
|
|
373
|
+
function materializeTestPattern(pattern) {
|
|
374
|
+
const clone = (p) => typeof p === 'string' ? p : new RegExp(p.source, p.flags);
|
|
375
|
+
return Array.isArray(pattern) ? Array.from(pattern, clone) : clone(pattern);
|
|
376
|
+
}
|
|
255
377
|
function createMatcher(pattern) {
|
|
256
378
|
if (Array.isArray(pattern)) {
|
|
257
379
|
const matchers = pattern.map((p) => createMatcher(p));
|
|
@@ -278,6 +400,7 @@ function normalizeOptions(options) {
|
|
|
278
400
|
targetName: options?.targetName ?? 'e2e',
|
|
279
401
|
ciTargetName,
|
|
280
402
|
mergeReportsTargetName: `${ciTargetName}--merge-reports`,
|
|
403
|
+
waitForWebServer: options?.waitForWebServer ?? true,
|
|
281
404
|
};
|
|
282
405
|
}
|
|
283
406
|
function getTestOutput(playwrightConfig) {
|
|
@@ -323,40 +446,269 @@ function addSubfolderToOutput(output, subfolder) {
|
|
|
323
446
|
}
|
|
324
447
|
return (0, devkit_1.joinPathFragments)(output, subfolder);
|
|
325
448
|
}
|
|
326
|
-
function
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
449
|
+
function getChainDotEnvPairs(workspaceRoot, projectRoot, options, fileHashes) {
|
|
450
|
+
const pairsFor = (target, nonAtomizedTarget) => getDotEnvPairsForTask(workspaceRoot, projectRoot, target, nonAtomizedTarget, fileHashes);
|
|
451
|
+
const target = pairsFor(options.targetName);
|
|
452
|
+
const gate = pairsFor(`${options.targetName}--wait-for-webserver`);
|
|
453
|
+
return {
|
|
454
|
+
target,
|
|
455
|
+
ciTarget: options.ciTargetName
|
|
456
|
+
? pairsFor(options.ciTargetName, options.targetName)
|
|
457
|
+
: target,
|
|
458
|
+
gate,
|
|
459
|
+
ciGate: options.ciTargetName
|
|
460
|
+
? pairsFor(`${options.ciTargetName}--wait-for-webserver`)
|
|
461
|
+
: gate,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function getDotEnvPairsForTask(workspaceRoot, projectRoot, target, nonAtomizedTarget, fileHashes) {
|
|
465
|
+
const pairs = [];
|
|
466
|
+
for (const file of (0, internal_1.getEnvPathsForTask)(projectRoot, target, undefined, nonAtomizedTarget)) {
|
|
467
|
+
let fileHash = fileHashes.get(file);
|
|
468
|
+
if (fileHash === undefined) {
|
|
469
|
+
fileHash = (0, internal_1.hashFile)((0, node_path_1.join)(workspaceRoot, file));
|
|
470
|
+
fileHashes.set(file, fileHash);
|
|
471
|
+
}
|
|
472
|
+
if (fileHash !== null) {
|
|
473
|
+
pairs.push([file, fileHash]);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
477
|
+
}
|
|
478
|
+
// Resolves the web server command tasks and readiness servers a consumer chain
|
|
479
|
+
// (`e2e` or the atomized `e2e-ci`) would see. A config with no ambient
|
|
480
|
+
// `webServer` yields nothing to depend on or gate, so the task-env resolution
|
|
481
|
+
// is skipped entirely: a `webServer` entry whose existence (not address)
|
|
482
|
+
// depends on task-scoped env is not detected. Otherwise, when the chain's
|
|
483
|
+
// task env differs from the graph-time ambient env, the config is re-evaluated
|
|
484
|
+
// in a child under that env so an env-derived command or address resolves the
|
|
485
|
+
// way the task will; a matching env reuses the ambient config. When
|
|
486
|
+
// `inferReadiness` is false the gate is opted out: the resolved command tasks
|
|
487
|
+
// still become dependencies, but no readiness servers are returned. A failed
|
|
488
|
+
// re-evaluation falls back to the ambient servers and skips the gate for the
|
|
489
|
+
// chain: an unverified address must not become a gate the daemon then caches,
|
|
490
|
+
// but a config bug or timeout should degrade to master's behavior, not kill
|
|
491
|
+
// graph construction for most commands. The returned `taskEnvEvalFailed` flag
|
|
492
|
+
// keeps that degraded result out of the plugin cache so a later pass retries
|
|
493
|
+
// the evaluation.
|
|
494
|
+
async function resolveChainWebserver(configFilePath, projectRoot, workspaceRoot, ambientWebServers, ambientProbeEnv, ambientEnv, legacyProxiedTls, npmConfigProxy, target, nonAtomizedTarget, gateTarget, inferReadiness) {
|
|
495
|
+
if (ambientWebServers.length === 0) {
|
|
496
|
+
return {
|
|
497
|
+
chain: { commandTasks: [], readinessServers: [], uncoveredServers: 0 },
|
|
498
|
+
taskEnvEvalFailed: false,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
let webServers = ambientWebServers;
|
|
502
|
+
let probeEnv = ambientProbeEnv;
|
|
503
|
+
let taskEnvEvalFailed = false;
|
|
504
|
+
// Base every env reconstruction on the locked ambient snapshot, never on the
|
|
505
|
+
// live process.env: another config's in-process load can be writing env
|
|
506
|
+
// concurrently, and a transient write read as ambient would mask the values
|
|
507
|
+
// this chain's dotenv files set.
|
|
508
|
+
const taskEnv = (0, internal_1.getGraphTimeDotEnvForTask)(projectRoot, target, undefined, nonAtomizedTarget, ambientEnv);
|
|
509
|
+
if ((0, webserver_readiness_1.taskEnvDivergesFromAmbient)(taskEnv, ambientEnv)) {
|
|
510
|
+
try {
|
|
511
|
+
({ webServers, probeEnv } = await (0, webserver_readiness_1.resolveWebServersUnderEnv)(configFilePath, workspaceRoot, taskEnv));
|
|
512
|
+
}
|
|
513
|
+
catch (e) {
|
|
514
|
+
taskEnvEvalFailed = true;
|
|
515
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
516
|
+
(0, internal_1.emitPluginWorkerLog)('warn', `@nx/playwright: could not evaluate ${configFilePath} under the ${target} task env to resolve the web server address. Targets are inferred from the ambient config evaluation and no web server readiness task is inferred for ${target}.\n${detail}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const { commandTasks, uncoveredServers } = getWebserverCommandTasks(webServers, configFilePath);
|
|
520
|
+
let readinessServers = [];
|
|
521
|
+
if (inferReadiness && !taskEnvEvalFailed) {
|
|
522
|
+
readinessServers = commandTasks
|
|
523
|
+
.map(toReadinessServer)
|
|
524
|
+
.filter(Boolean);
|
|
525
|
+
// The gate runs as its own target under its own dotenv and never loads the
|
|
526
|
+
// config, so a change to the probe transport (a proxy exclusion, a CA
|
|
527
|
+
// bundle) the task's dotenv or its config makes never reaches it and the
|
|
528
|
+
// gate could fail where Playwright's own probe passes. The dependencies
|
|
529
|
+
// stay; readiness falls back to Playwright's probe. Node reads
|
|
530
|
+
// NODE_EXTRA_CA_CERTS once at startup, so the value the task starts with
|
|
531
|
+
// is the one its probe trusts, whatever the config writes.
|
|
532
|
+
const divergingProbeVars = (0, webserver_readiness_1.getProbeEnvDivergence)(readinessServers, { ...probeEnv, NODE_EXTRA_CA_CERTS: taskEnv.NODE_EXTRA_CA_CERTS }, (0, internal_1.getGraphTimeDotEnvForTask)(projectRoot, gateTarget, undefined, undefined, ambientEnv), legacyProxiedTls, npmConfigProxy);
|
|
533
|
+
if (divergingProbeVars.length > 0) {
|
|
534
|
+
readinessServers = [];
|
|
535
|
+
(0, internal_1.emitPluginWorkerLog)('warn', `@nx/playwright: under the ${target} task, ${divergingProbeVars.join(', ')} changes how its web server would be probed, which the readiness task cannot reproduce. No web server readiness task is inferred for ${target}.`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
chain: { commandTasks, readinessServers, uncoveredServers },
|
|
540
|
+
taskEnvEvalFailed,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
// Two chains share a gate only when both the readiness servers and the serve
|
|
544
|
+
// command tasks (which become the gate's and the tasks' dependsOn) match.
|
|
545
|
+
function sameChain(a, b) {
|
|
546
|
+
return (JSON.stringify(a.commandTasks) === JSON.stringify(b.commandTasks) &&
|
|
547
|
+
JSON.stringify(a.readinessServers) === JSON.stringify(b.readinessServers));
|
|
548
|
+
}
|
|
549
|
+
function buildWaitForWebserverTarget(chain, webServerTimeout) {
|
|
550
|
+
return {
|
|
551
|
+
executor: '@nx/playwright:wait-for-webserver',
|
|
552
|
+
cache: false,
|
|
553
|
+
options: {
|
|
554
|
+
servers: chain.readinessServers,
|
|
555
|
+
...(webServerTimeout != null ? { timeout: webServerTimeout } : {}),
|
|
556
|
+
},
|
|
557
|
+
dependsOn: getDependsOn(chain.commandTasks),
|
|
558
|
+
metadata: {
|
|
559
|
+
technologies: ['playwright'],
|
|
560
|
+
description: 'Waits for the E2E web server(s) to be ready before the Playwright test tasks run.',
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
// One entry per (config, command) for the lifetime of the process: the two
|
|
565
|
+
// chains and every graph recomputation re-derive the same tasks, and the
|
|
566
|
+
// warning is about the config's content, not about any single run.
|
|
567
|
+
const warnedUnparseableCommands = new Set();
|
|
568
|
+
// Test seam: the warn-once set outlives a spec file's cases.
|
|
569
|
+
function _clearWarnedUnparseableCommands() {
|
|
570
|
+
warnedUnparseableCommands.clear();
|
|
571
|
+
}
|
|
572
|
+
function getWebserverCommandTasks(webServers, configFilePath) {
|
|
573
|
+
const commandTasks = [];
|
|
574
|
+
let uncoveredServers = 0;
|
|
575
|
+
for (const server of webServers) {
|
|
335
576
|
if (!server.reuseExistingServer) {
|
|
336
577
|
continue;
|
|
337
578
|
}
|
|
579
|
+
// An unchecked config can omit `command` (Playwright's own type requires
|
|
580
|
+
// it); nothing runs, so there is nothing to depend on or gate.
|
|
581
|
+
if (typeof server.command !== 'string') {
|
|
582
|
+
uncoveredServers++;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
// Playwright races a `wait.stdout`/`wait.stderr` regex against the address
|
|
586
|
+
// probe and stores the match's named capture groups in the env, both tied
|
|
587
|
+
// to the process Playwright starts itself. A task-started server would be
|
|
588
|
+
// reused without them, or raced by a duplicate launch while it boots, so
|
|
589
|
+
// such a server gets no inferred dependency and no gate.
|
|
590
|
+
if (server.waitsForOutput) {
|
|
591
|
+
uncoveredServers++;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
// A `webServer.env` only reaches the process Playwright starts itself. A
|
|
595
|
+
// task-started server would run without it, listening at a different
|
|
596
|
+
// address or silently reused missing the env the tests rely on, so only
|
|
597
|
+
// Playwright can launch such a server: no inferred dependency and no gate.
|
|
598
|
+
if (server.hasEnv) {
|
|
599
|
+
uncoveredServers++;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
338
602
|
const task = parseTaskFromCommand(server.command);
|
|
339
603
|
if (task) {
|
|
340
|
-
|
|
604
|
+
commandTasks.push({
|
|
605
|
+
...task,
|
|
606
|
+
port: server.port,
|
|
607
|
+
url: server.url,
|
|
608
|
+
ignoreHTTPSErrors: server.ignoreHTTPSErrors,
|
|
609
|
+
timeout: server.timeout,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
uncoveredServers++;
|
|
614
|
+
// A command that never invokes nx (`npm run start`, `vite`) was never
|
|
615
|
+
// meant to map to a task, so its skip warrants no warning.
|
|
616
|
+
if (/(^|\s)nx(@\S+)?\s/.test(server.command)) {
|
|
617
|
+
const warnedKey = `${configFilePath}|${server.command}`;
|
|
618
|
+
if (!warnedUnparseableCommands.has(warnedKey)) {
|
|
619
|
+
warnedUnparseableCommands.add(warnedKey);
|
|
620
|
+
(0, internal_1.emitPluginWorkerLog)('warn', `@nx/playwright: could not infer an Nx task from the webServer command "${server.command}" in ${configFilePath}, so no serve dependency or readiness wait is inferred for it.`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
341
623
|
}
|
|
342
624
|
}
|
|
343
|
-
return
|
|
625
|
+
return { commandTasks, uncoveredServers };
|
|
626
|
+
}
|
|
627
|
+
// Playwright throws when `port` and `url` are both truthy, but a present
|
|
628
|
+
// `port` (even 0) still selects its TCP-only probe, whose target comes from
|
|
629
|
+
// the derived url rather than the option: the url's port for `port: 0` plus
|
|
630
|
+
// `url`, and 0, which can never connect, when that url carries no explicit
|
|
631
|
+
// port. An unchecked `playwright.config.js` can also carry a `port` Playwright
|
|
632
|
+
// coerces but the readiness task can't probe. Gate only on the shapes the task
|
|
633
|
+
// probes the same way; the rest is left to Playwright.
|
|
634
|
+
function toReadinessServer(task) {
|
|
635
|
+
// The inferred dependency runs the target without the command's trailing
|
|
636
|
+
// `:configuration` (a task dependency cannot carry one), so the server it
|
|
637
|
+
// starts can listen at a different address than the configured one. Gating
|
|
638
|
+
// on that address would wait out the whole budget; leave readiness to
|
|
639
|
+
// Playwright's own probe instead.
|
|
640
|
+
if (task.hasConfiguration) {
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
let server;
|
|
644
|
+
if (typeof task.port === 'number' && task.port) {
|
|
645
|
+
server = { port: task.port };
|
|
646
|
+
}
|
|
647
|
+
else if (typeof task.url === 'string' &&
|
|
648
|
+
task.url &&
|
|
649
|
+
task.port === undefined) {
|
|
650
|
+
server = { url: task.url };
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
if (task.ignoreHTTPSErrors) {
|
|
656
|
+
server.ignoreHTTPSErrors = true;
|
|
657
|
+
}
|
|
658
|
+
// Carry each server's own `webServer.timeout` (the budget Playwright waits
|
|
659
|
+
// for a server it starts) so a slow server is not cut short and a fast one
|
|
660
|
+
// is not given another server's budget. Guard the type like `port`/`url`
|
|
661
|
+
// above: an unchecked `.js` config can carry a non-number the gate task's
|
|
662
|
+
// schema would then reject at run time.
|
|
663
|
+
if (typeof task.timeout === 'number' && Number.isFinite(task.timeout)) {
|
|
664
|
+
server.timeout = task.timeout;
|
|
665
|
+
}
|
|
666
|
+
return server;
|
|
344
667
|
}
|
|
345
668
|
function parseTaskFromCommand(command) {
|
|
346
669
|
const nxRunRegex = /^(?:(?:npx|yarn|bun|pnpm|pnpm exec|pnpx) )?nx run (\S+:\S+)$/;
|
|
347
670
|
const infixRegex = /^(?:(?:npx|yarn|bun|pnpm|pnpm exec|pnpx) )?nx (\S+ \S+)$/;
|
|
348
671
|
const nxRunMatch = command.match(nxRunRegex);
|
|
349
672
|
if (nxRunMatch) {
|
|
350
|
-
|
|
351
|
-
|
|
673
|
+
// Truthiness rather than `!== undefined`: a trailing colon (`app:serve:`)
|
|
674
|
+
// splits to an empty configuration, which behaves as none.
|
|
675
|
+
const [project, target, configuration] = nxRunMatch[1].split(':');
|
|
676
|
+
return { project, target, hasConfiguration: !!configuration };
|
|
352
677
|
}
|
|
353
678
|
const infixMatch = command.match(infixRegex);
|
|
354
679
|
if (infixMatch) {
|
|
355
680
|
const [target, project] = infixMatch[1].split(' ');
|
|
356
|
-
return { project, target };
|
|
681
|
+
return { project, target, hasConfiguration: false };
|
|
357
682
|
}
|
|
358
683
|
return null;
|
|
359
684
|
}
|
|
685
|
+
// Whether tasks consuming the chain's servers must not run in parallel: with
|
|
686
|
+
// no inferred serve task, or with any reused server no task covers, something
|
|
687
|
+
// the graph cannot see starts a server, and concurrent consumers would race
|
|
688
|
+
// it (each launching its own copy, or tearing it down under the others).
|
|
689
|
+
function chainRequiresSerialization(chain) {
|
|
690
|
+
return !chain.commandTasks.length || chain.uncoveredServers > 0;
|
|
691
|
+
}
|
|
692
|
+
// A chain with inferred serve tasks depends on them (and its gate, when one
|
|
693
|
+
// was inferred). Set together with parallelism so a config that inherits one
|
|
694
|
+
// state (the atomized CI base copies the e2e config) cannot end up carrying
|
|
695
|
+
// both.
|
|
696
|
+
function applyChainDependsOn(targetConfig, chain, readyTargetName) {
|
|
697
|
+
if (chain.commandTasks.length) {
|
|
698
|
+
targetConfig.dependsOn = readyTargetName
|
|
699
|
+
? [...getDependsOn(chain.commandTasks), { target: readyTargetName }]
|
|
700
|
+
: getDependsOn(chain.commandTasks);
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
delete targetConfig.dependsOn;
|
|
704
|
+
}
|
|
705
|
+
if (chainRequiresSerialization(chain)) {
|
|
706
|
+
targetConfig.parallelism = false;
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
delete targetConfig.parallelism;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
360
712
|
function getDependsOn(tasks) {
|
|
361
713
|
const projectsPerTask = new Map();
|
|
362
714
|
for (const { project, target } of tasks) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forked child that evaluates a Playwright config under the env it was spawned
|
|
3
|
+
* with (see resolveWebServersUnderEnv) and returns the resolved `webServer`
|
|
4
|
+
* addresses along with the probe env the config left behind. A child is used
|
|
5
|
+
* rather than an in-process re-evaluation because createNodes evaluates configs
|
|
6
|
+
* concurrently: a shared `process.env` swap would race across them, and the
|
|
7
|
+
* module cache would hand back the first evaluation.
|
|
8
|
+
*
|
|
9
|
+
* argv: [configFilePath (workspace-relative), workspaceRoot]. The result is
|
|
10
|
+
* sent over the IPC channel as a tagged `webserver-config-result` message, or
|
|
11
|
+
* `webserver-config-error` on failure.
|
|
12
|
+
*/
|
|
13
|
+
export declare function evaluateAndSendWebserverConfig(): Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.evaluateAndSendWebserverConfig = evaluateAndSendWebserverConfig;
|
|
4
|
+
const internal_1 = require("@nx/devkit/internal");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const webserver_readiness_1 = require("./webserver-readiness");
|
|
7
|
+
/**
|
|
8
|
+
* Forked child that evaluates a Playwright config under the env it was spawned
|
|
9
|
+
* with (see resolveWebServersUnderEnv) and returns the resolved `webServer`
|
|
10
|
+
* addresses along with the probe env the config left behind. A child is used
|
|
11
|
+
* rather than an in-process re-evaluation because createNodes evaluates configs
|
|
12
|
+
* concurrently: a shared `process.env` swap would race across them, and the
|
|
13
|
+
* module cache would hand back the first evaluation.
|
|
14
|
+
*
|
|
15
|
+
* argv: [configFilePath (workspace-relative), workspaceRoot]. The result is
|
|
16
|
+
* sent over the IPC channel as a tagged `webserver-config-result` message, or
|
|
17
|
+
* `webserver-config-error` on failure.
|
|
18
|
+
*/
|
|
19
|
+
async function evaluateAndSendWebserverConfig() {
|
|
20
|
+
const [configFilePath, workspaceRoot] = process.argv.slice(2);
|
|
21
|
+
const config = await (0, internal_1.loadConfigFile)((0, node_path_1.join)(workspaceRoot, configFilePath));
|
|
22
|
+
await send({
|
|
23
|
+
type: 'webserver-config-result',
|
|
24
|
+
webServers: (0, webserver_readiness_1.normalizeWebServers)(config.webServer),
|
|
25
|
+
probeEnv: (0, webserver_readiness_1.pickProbeEnv)(process.env),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// process.exit can truncate an IPC message that has not been flushed to the
|
|
29
|
+
// parent, so wait for the send to be acknowledged before exiting. A delivery
|
|
30
|
+
// failure rejects so the caller exits nonzero rather than reporting success.
|
|
31
|
+
function send(message) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
if (!process.send) {
|
|
34
|
+
resolve();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
process.send(message, (error) => (error ? reject(error) : resolve()));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
// Only the forked entrypoint runs the evaluation; importing the module (as the
|
|
41
|
+
// tests do) must not.
|
|
42
|
+
if (require.main === module) {
|
|
43
|
+
evaluateAndSendWebserverConfig().then(() => process.exit(0), async (error) => {
|
|
44
|
+
// A non-empty message so the failure the parent surfaces stays diagnosable.
|
|
45
|
+
const detail = (error && (error.stack || error.message)) ||
|
|
46
|
+
String(error) ||
|
|
47
|
+
'Unknown error while evaluating the Playwright config.';
|
|
48
|
+
try {
|
|
49
|
+
await send({ type: 'webserver-config-error', error: detail });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// The channel is gone; the nonzero exit below still signals the failure.
|
|
53
|
+
}
|
|
54
|
+
process.exit(1);
|
|
55
|
+
});
|
|
56
|
+
}
|