@nx/playwright 23.2.0-beta.11 → 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.
@@ -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, CI: process.env.CI }, context, entries.map((e) => [lockFileName, ...e.externalTsconfigInputs]));
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
- pluginCache.writeToDisk();
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
- if (!pluginCache.has(hash)) {
55
- pluginCache.set(hash, await buildPlaywrightTargets(configFilePath, projectRoot, normalizedOptions, context, pmc, externalTsconfigInputs));
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 } = pluginCache.get(hash);
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
- const playwrightConfig = await (0, internal_1.loadConfigFile)((0, node_path_1.join)(context.workspaceRoot, configFilePath));
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
- const testOutput = getTestOutput(playwrightConfig);
82
- const reporterOutputs = (0, reporters_1.getReporterOutputs)(playwrightConfig);
83
- const webserverCommandTasks = getWebserverCommandTasks(playwrightConfig);
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
- if (webserverCommandTasks.length) {
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 = playwrightConfig.testDir
142
- ? (0, devkit_1.joinPathFragments)(projectRoot, playwrightConfig.testDir)
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
- config: playwrightConfig,
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
- config: playwrightConfig,
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 (!webserverCommandTasks.length) {
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.config.testMatch);
250
- const ignoredMatcher = opts.config.testIgnore
251
- ? createMatcher(opts.config.testIgnore)
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 getWebserverCommandTasks(playwrightConfig) {
327
- if (!playwrightConfig.webServer) {
328
- return [];
329
- }
330
- const tasks = [];
331
- const webServer = Array.isArray(playwrightConfig.webServer)
332
- ? playwrightConfig.webServer
333
- : [playwrightConfig.webServer];
334
- for (const server of webServer) {
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
- tasks.push(task);
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 tasks;
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
- const [project, target] = nxRunMatch[1].split(':');
351
- return { project, target };
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
+ }