@gate-forge/pack-playwright 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +31 -0
  2. package/dist/constants.d.ts +13 -140
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +13 -148
  5. package/dist/constants.js.map +1 -1
  6. package/dist/cypress/plugin.d.ts +53 -0
  7. package/dist/cypress/plugin.d.ts.map +1 -0
  8. package/dist/cypress/plugin.js +369 -0
  9. package/dist/cypress/plugin.js.map +1 -0
  10. package/dist/cypress/spec-scan.d.ts +39 -0
  11. package/dist/cypress/spec-scan.d.ts.map +1 -0
  12. package/dist/cypress/spec-scan.js +301 -0
  13. package/dist/cypress/spec-scan.js.map +1 -0
  14. package/dist/diagnosis.d.ts +39 -0
  15. package/dist/diagnosis.d.ts.map +1 -0
  16. package/dist/diagnosis.js +63 -0
  17. package/dist/diagnosis.js.map +1 -0
  18. package/dist/discovery/adapter-projection.d.ts +13 -0
  19. package/dist/discovery/adapter-projection.d.ts.map +1 -0
  20. package/dist/discovery/adapter-projection.js +84 -0
  21. package/dist/discovery/adapter-projection.js.map +1 -0
  22. package/dist/discovery/config-locations.d.ts +5 -0
  23. package/dist/discovery/config-locations.d.ts.map +1 -0
  24. package/dist/discovery/config-locations.js +19 -0
  25. package/dist/discovery/config-locations.js.map +1 -0
  26. package/dist/discovery/cypress-runner-adapter.d.ts +112 -0
  27. package/dist/discovery/cypress-runner-adapter.d.ts.map +1 -0
  28. package/dist/discovery/cypress-runner-adapter.js +501 -0
  29. package/dist/discovery/cypress-runner-adapter.js.map +1 -0
  30. package/dist/discovery/discover.d.ts +53 -1
  31. package/dist/discovery/discover.d.ts.map +1 -1
  32. package/dist/discovery/discover.js +100 -3
  33. package/dist/discovery/discover.js.map +1 -1
  34. package/dist/discovery/git-ignore.d.ts +13 -0
  35. package/dist/discovery/git-ignore.d.ts.map +1 -0
  36. package/dist/discovery/git-ignore.js +62 -0
  37. package/dist/discovery/git-ignore.js.map +1 -0
  38. package/dist/discovery/index.d.ts +9 -0
  39. package/dist/discovery/index.d.ts.map +1 -1
  40. package/dist/discovery/index.js +9 -0
  41. package/dist/discovery/index.js.map +1 -1
  42. package/dist/discovery/playwright-runner-adapter.d.ts +108 -0
  43. package/dist/discovery/playwright-runner-adapter.d.ts.map +1 -0
  44. package/dist/discovery/playwright-runner-adapter.js +185 -0
  45. package/dist/discovery/playwright-runner-adapter.js.map +1 -0
  46. package/dist/discovery/prepare-barrier.d.ts +522 -0
  47. package/dist/discovery/prepare-barrier.d.ts.map +1 -0
  48. package/dist/discovery/prepare-barrier.js +739 -0
  49. package/dist/discovery/prepare-barrier.js.map +1 -0
  50. package/dist/discovery/pytest-adapter.d.ts.map +1 -1
  51. package/dist/discovery/pytest-adapter.js +6 -2
  52. package/dist/discovery/pytest-adapter.js.map +1 -1
  53. package/dist/discovery/pytest-runner-adapter.d.ts +124 -0
  54. package/dist/discovery/pytest-runner-adapter.d.ts.map +1 -0
  55. package/dist/discovery/pytest-runner-adapter.js +454 -0
  56. package/dist/discovery/pytest-runner-adapter.js.map +1 -0
  57. package/dist/discovery/reconcile.d.ts +80 -11
  58. package/dist/discovery/reconcile.d.ts.map +1 -1
  59. package/dist/discovery/reconcile.js +395 -78
  60. package/dist/discovery/reconcile.js.map +1 -1
  61. package/dist/discovery/runner-env.d.ts +88 -2
  62. package/dist/discovery/runner-env.d.ts.map +1 -1
  63. package/dist/discovery/runner-env.js +142 -4
  64. package/dist/discovery/runner-env.js.map +1 -1
  65. package/dist/discovery/static-discovery.d.ts +38 -3
  66. package/dist/discovery/static-discovery.d.ts.map +1 -1
  67. package/dist/discovery/static-discovery.js +454 -15
  68. package/dist/discovery/static-discovery.js.map +1 -1
  69. package/dist/discovery/supervised-run.d.ts +199 -10
  70. package/dist/discovery/supervised-run.d.ts.map +1 -1
  71. package/dist/discovery/supervised-run.js +350 -39
  72. package/dist/discovery/supervised-run.js.map +1 -1
  73. package/dist/discovery/trusted-config.d.ts +180 -6
  74. package/dist/discovery/trusted-config.d.ts.map +1 -1
  75. package/dist/discovery/trusted-config.js +279 -10
  76. package/dist/discovery/trusted-config.js.map +1 -1
  77. package/dist/discovery/vitest-runner-adapter.d.ts +172 -0
  78. package/dist/discovery/vitest-runner-adapter.d.ts.map +1 -0
  79. package/dist/discovery/vitest-runner-adapter.js +607 -0
  80. package/dist/discovery/vitest-runner-adapter.js.map +1 -0
  81. package/dist/fixture/consumer-runner.d.ts +4 -0
  82. package/dist/fixture/consumer-runner.d.ts.map +1 -0
  83. package/dist/fixture/consumer-runner.js +38 -0
  84. package/dist/fixture/consumer-runner.js.map +1 -0
  85. package/dist/fixture/evidence.d.ts +27 -0
  86. package/dist/fixture/evidence.d.ts.map +1 -1
  87. package/dist/fixture/evidence.js +12 -0
  88. package/dist/fixture/evidence.js.map +1 -1
  89. package/dist/fixture/fixture.d.ts +2 -1
  90. package/dist/fixture/fixture.d.ts.map +1 -1
  91. package/dist/fixture/fixture.js +1 -17
  92. package/dist/fixture/fixture.js.map +1 -1
  93. package/dist/fixture/witness-client.d.ts +6 -174
  94. package/dist/fixture/witness-client.d.ts.map +1 -1
  95. package/dist/fixture/witness-client.js +6 -267
  96. package/dist/fixture/witness-client.js.map +1 -1
  97. package/dist/index.d.ts +9 -6
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +5 -4
  100. package/dist/index.js.map +1 -1
  101. package/dist/json.d.ts +6 -12
  102. package/dist/json.d.ts.map +1 -1
  103. package/dist/json.js +6 -17
  104. package/dist/json.js.map +1 -1
  105. package/dist/reporter/project-graph-reporter.d.ts +92 -0
  106. package/dist/reporter/project-graph-reporter.d.ts.map +1 -0
  107. package/dist/reporter/project-graph-reporter.js +92 -0
  108. package/dist/reporter/project-graph-reporter.js.map +1 -0
  109. package/dist/reporter/reporter.cjs.map +1 -1
  110. package/dist/reporter/reporter.d.ts +93 -4
  111. package/dist/reporter/reporter.d.ts.map +1 -1
  112. package/dist/reporter/reporter.js +174 -18
  113. package/dist/reporter/reporter.js.map +1 -1
  114. package/dist/runner-claims.d.ts +13 -0
  115. package/dist/runner-claims.d.ts.map +1 -0
  116. package/dist/runner-claims.js +46 -0
  117. package/dist/runner-claims.js.map +1 -0
  118. package/dist/runner-resolution.d.ts +3 -0
  119. package/dist/runner-resolution.d.ts.map +1 -0
  120. package/dist/runner-resolution.js +14 -0
  121. package/dist/runner-resolution.js.map +1 -0
  122. package/dist/supervisor/client.d.ts +23 -1
  123. package/dist/supervisor/client.d.ts.map +1 -1
  124. package/dist/supervisor/client.js +49 -0
  125. package/dist/supervisor/client.js.map +1 -1
  126. package/dist/supervisor/drain.d.ts +65 -0
  127. package/dist/supervisor/drain.d.ts.map +1 -1
  128. package/dist/supervisor/drain.js +353 -9
  129. package/dist/supervisor/drain.js.map +1 -1
  130. package/dist/supervisor/index.d.ts +7 -3
  131. package/dist/supervisor/index.d.ts.map +1 -1
  132. package/dist/supervisor/index.js +7 -3
  133. package/dist/supervisor/index.js.map +1 -1
  134. package/dist/supervisor/spool.d.ts +65 -2
  135. package/dist/supervisor/spool.d.ts.map +1 -1
  136. package/dist/supervisor/spool.js +47 -0
  137. package/dist/supervisor/spool.js.map +1 -1
  138. package/dist/surface.d.ts +6 -207
  139. package/dist/surface.d.ts.map +1 -1
  140. package/dist/surface.js +6 -264
  141. package/dist/surface.js.map +1 -1
  142. package/dist/vitest/index.d.ts +23 -0
  143. package/dist/vitest/index.d.ts.map +1 -0
  144. package/dist/vitest/index.js +356 -0
  145. package/dist/vitest/index.js.map +1 -0
  146. package/dist/vitest/reporter.d.ts +83 -0
  147. package/dist/vitest/reporter.d.ts.map +1 -0
  148. package/dist/vitest/reporter.js +211 -0
  149. package/dist/vitest/reporter.js.map +1 -0
  150. package/dist/vitest/worker-slot.d.ts +36 -0
  151. package/dist/vitest/worker-slot.d.ts.map +1 -0
  152. package/dist/vitest/worker-slot.js +51 -0
  153. package/dist/vitest/worker-slot.js.map +1 -0
  154. package/dist/witness/adapter-registry.d.ts +6 -50
  155. package/dist/witness/adapter-registry.d.ts.map +1 -1
  156. package/dist/witness/adapter-registry.js +6 -257
  157. package/dist/witness/adapter-registry.js.map +1 -1
  158. package/dist/witness/behavior-request.d.ts +6 -76
  159. package/dist/witness/behavior-request.d.ts.map +1 -1
  160. package/dist/witness/behavior-request.js +6 -273
  161. package/dist/witness/behavior-request.js.map +1 -1
  162. package/dist/witness/behavior.d.ts +6 -48
  163. package/dist/witness/behavior.d.ts.map +1 -1
  164. package/dist/witness/behavior.js +6 -114
  165. package/dist/witness/behavior.js.map +1 -1
  166. package/dist/witness/bin.d.ts +6 -17
  167. package/dist/witness/bin.d.ts.map +1 -1
  168. package/dist/witness/bin.js +6 -168
  169. package/dist/witness/bin.js.map +1 -1
  170. package/dist/witness/browser.d.ts +14 -206
  171. package/dist/witness/browser.d.ts.map +1 -1
  172. package/dist/witness/browser.js +15 -654
  173. package/dist/witness/browser.js.map +1 -1
  174. package/dist/witness/classifications.d.ts +6 -38
  175. package/dist/witness/classifications.d.ts.map +1 -1
  176. package/dist/witness/classifications.js +6 -83
  177. package/dist/witness/classifications.js.map +1 -1
  178. package/dist/witness/env-attestation.d.ts +6 -94
  179. package/dist/witness/env-attestation.d.ts.map +1 -1
  180. package/dist/witness/env-attestation.js +6 -198
  181. package/dist/witness/env-attestation.js.map +1 -1
  182. package/dist/witness/fixture-provider.d.ts +6 -82
  183. package/dist/witness/fixture-provider.d.ts.map +1 -1
  184. package/dist/witness/fixture-provider.js +6 -105
  185. package/dist/witness/fixture-provider.js.map +1 -1
  186. package/dist/witness/loopback-pins.d.ts +6 -75
  187. package/dist/witness/loopback-pins.d.ts.map +1 -1
  188. package/dist/witness/loopback-pins.js +6 -240
  189. package/dist/witness/loopback-pins.js.map +1 -1
  190. package/dist/witness/server.d.ts +8 -26
  191. package/dist/witness/server.d.ts.map +1 -1
  192. package/dist/witness/server.js +24 -3761
  193. package/dist/witness/server.js.map +1 -1
  194. package/dist/witness/types.d.ts +6 -882
  195. package/dist/witness/types.d.ts.map +1 -1
  196. package/dist/witness/types.js +9 -1
  197. package/dist/witness/types.js.map +1 -1
  198. package/examples/overlay-proof.spec.js +75 -0
  199. package/package.json +8 -2
  200. package/python/gateforge_pytest_plugin.py +462 -0
@@ -7,11 +7,12 @@
7
7
  * TRUST BOUNDARY (plan §3.3, phase 2 item 4): `--list` loads the
8
8
  * consumer's playwright config and test modules as UNTRUSTED code —
9
9
  * they execute in a child process. This module therefore:
10
- * - strips every `GATEFORGE_*` variable (witness keys, run tokens, run
11
- * state) from the child environment, then sets only an isolated,
12
- * secret-free temporary `GATEFORGE_STATE_DIR` so supervised-only test
13
- * declarations are registered;
14
- * - deletes that temporary directory after enumeration;
10
+ * - strips every `GATEFORGE_*` variable from the scrubbed child
11
+ * environment, then sets only an isolated, secret-free temporary
12
+ * `GATEFORGE_STATE_DIR`;
13
+ * - gives wired comparison listings only the safe run-variable allowlist
14
+ * actually exposed to runner children;
15
+ * - removes temporary state after scrubbed enumeration;
15
16
  * - enforces a finite timeout (the child is killed; a timeout is a
16
17
  * typed failure, never a hang or an empty inventory);
17
18
  * - treats a failed invocation as a typed error (CLI exit 2), while
@@ -37,27 +38,12 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync }
37
38
  import { createRequire } from 'node:module';
38
39
  import { tmpdir } from 'node:os';
39
40
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
40
- import { CLAIM_ANNOTATION_TYPE } from '../constants.js';
41
- /** Config file names checked at the repo root and one level deep
42
- * (first match wins within each directory). */
43
- const PLAYWRIGHT_CONFIG_NAMES = [
44
- 'playwright.config.ts',
45
- 'playwright.config.mts',
46
- 'playwright.config.cts',
47
- 'playwright.config.js',
48
- 'playwright.config.mjs',
49
- 'playwright.config.cjs',
50
- ];
51
- /** Directory names never searched for a nested playwright config
52
- * (dependency trees, build output, VCS state, runner artifacts). */
53
- const CONFIG_SEARCH_PRUNED_DIRS = new Set([
54
- 'node_modules',
55
- 'dist',
56
- '.git',
57
- 'test-results',
58
- 'coverage',
59
- 'build',
60
- ]);
41
+ import { fileURLToPath } from 'node:url';
42
+ import { buildRunnerChildEnv } from './runner-env.js';
43
+ import { CLAIM_ANNOTATION_TYPE, ENV_PLAYWRIGHT_CONFIG_DIR } from '../constants.js';
44
+ import { localPlaywrightCliCandidates } from '../runner-resolution.js';
45
+ import { CONFIG_SEARCH_PRUNED_DIRS, PLAYWRIGHT_CONFIG_NAMES } from './config-locations.js';
46
+ import { PROJECT_GRAPH_PATH_ENV } from '../reporter/project-graph-reporter.js';
61
47
  /** Default wall-clock bound for one `--list` invocation. */
62
48
  export const DEFAULT_LIST_TIMEOUT_MS = 60_000;
63
49
  /** Typed discovery failure: a playwright invocation that could not run
@@ -68,17 +54,43 @@ export class TestDiscoveryError extends Error {
68
54
  this.name = 'TestDiscoveryError';
69
55
  }
70
56
  }
57
+ /**
58
+ * Resolves the engine-owned project-graph reporter entry.
59
+ *
60
+ * Absolute and pack-relative for the same reason the trusted reporter
61
+ * entry is: the enumeration child must load an ENGINE file by absolute
62
+ * path, never a candidate-relative one. Resolution works from both the
63
+ * `src/` and `dist/` layouts.
64
+ *
65
+ * @param fromModule: module URL to resolve the pack from (default: this file).
66
+ *
67
+ * @returns
68
+ * string: absolute `<pack>/dist/reporter/project-graph-reporter.js`.
69
+ */
70
+ export function projectGraphReporterEntry(fromModule = import.meta.url) {
71
+ const pkgPath = fileURLToPath(new URL('../../package.json', fromModule));
72
+ return join(pkgPath.slice(0, -'package.json'.length), 'dist', 'reporter', 'project-graph-reporter.js');
73
+ }
71
74
  /**
72
75
  * The playwright CLI of the repo being scanned, else the pack's own.
73
- * Resolution order (consumer-first, subdirectory-first): the CONFIG
74
- * DIRECTORY's own install (a subdirectory project pins the playwright
75
- * version its config and specs load through — running any other version
76
- * against it dies with the two-versions-of-@playwright/test conflict),
77
- * then the repo root's, then the pack's own.
76
+ * Resolution order (consumer-first, nearest-first): the CONFIG
77
+ * DIRECTORY's own install and then every directory above it (a
78
+ * subdirectory project pins the playwright version its config and
79
+ * specs load through — running any other version against it dies with
80
+ * the two-versions-of-@playwright/test conflict), and within each
81
+ * directory the `@playwright/test` CLI before the bare `playwright`
82
+ * one (see {@link localPlaywrightCliCandidates}).
78
83
  *
79
84
  * Args:
80
85
  * cwd: absolute repo root.
81
86
  * configDir: config directory repo-relative (`'.'` for root-level).
87
+ *
88
+ * Returns:
89
+ * string: absolute path of the CLI to invoke.
90
+ *
91
+ * Throws:
92
+ * TestDiscoveryError: when neither the repo's own nor the pack's CLI
93
+ * exists (a broken pack dependency, never a silent fallback).
82
94
  */
83
95
  function playwrightCliPath(cwd, configDir) {
84
96
  // CONSUMER-FIRST resolution: a consumer repo pins its own
@@ -86,15 +98,12 @@ function playwrightCliPath(cwd, configDir) {
86
98
  // through it). Running the pack's CLI against a consumer whose local
87
99
  // version differs dies with the two-versions-of-@playwright/test
88
100
  // conflict — so the scanned repo's own CLI wins when present
89
- // (consumer migration, E22). The pack's CLI remains the fallback
90
- // (fixture repos symlink the monorepo node_modules, so they resolve
91
- // to the same bytes either way).
92
- const searchRoots = configDir !== '.' ? [join(cwd, configDir), cwd] : [cwd];
93
- for (const searchRoot of searchRoots) {
94
- for (const candidate of localPlaywrightCliCandidates(searchRoot)) {
95
- if (existsSync(candidate))
96
- return candidate;
97
- }
101
+ // (consumer migration, E22; install rehearsal F7). The pack's CLI
102
+ // remains the fallback (fixture repos symlink the monorepo
103
+ // node_modules, so they resolve to the same bytes either way).
104
+ for (const candidate of localPlaywrightCliCandidates(join(cwd, configDir))) {
105
+ if (existsSync(candidate))
106
+ return candidate;
98
107
  }
99
108
  const require = createRequire(import.meta.url);
100
109
  const pkgJson = require.resolve('playwright/package.json');
@@ -104,13 +113,6 @@ function playwrightCliPath(cwd, configDir) {
104
113
  }
105
114
  return cli;
106
115
  }
107
- /** The scanned repo's local playwright CLI locations, in preference order. */
108
- export function localPlaywrightCliCandidates(cwd) {
109
- return [
110
- join(cwd, 'node_modules', 'playwright', 'cli.js'),
111
- join(cwd, 'node_modules', '@playwright', 'test', 'cli.js'),
112
- ];
113
- }
114
116
  /**
115
117
  * Strips every `GATEFORGE_*` variable from the environment for UNTRUSTED
116
118
  * child runs, then adds only a caller-owned temporary state directory.
@@ -135,39 +137,38 @@ export function untrustedEnv(env, discoveryStateDir) {
135
137
  return child;
136
138
  }
137
139
  /**
138
- * Finds the consumer's playwright config: at the repo root first, then —
139
- * only when no root-level config exists — ONE directory level deep
140
- * (immediate subdirectories, dependency/build/VCS/runner directories
141
- * pruned), alphabetically first match. Returns the repo-relative posix
142
- * path (`'playwright.config.ts'`, or `'e2e/playwright.config.ts'` for a
143
- * subdirectory project), or null when none exists.
140
+ * Enumerates EVERY playwright config the search space contains, in the
141
+ * order the choice is made: repo-root configs first (in
142
+ * {@link PLAYWRIGHT_CONFIG_NAMES} order), then ONE directory level
143
+ * deep (immediate subdirectories, dependency/build/VCS/runner
144
+ * directories pruned, alphabetically).
144
145
  *
145
- * Root-level configs always win: an existing root project must keep its
146
- * exact historical invocation. The nested search only extends discovery
147
- * to the self-contained subdirectory layout (the config's OWN directory
148
- * pins its playwright install — see {@link playwrightCliPath}).
146
+ * Enumeration runs exactly one config, so a repo with several configs
147
+ * is inventoried as a subset. Listing them all is what lets that
148
+ * narrowing be reported instead of silent — see
149
+ * {@link findPlaywrightConfig}.
149
150
  *
150
151
  * Args:
151
152
  * cwd: absolute repo root.
152
153
  *
153
154
  * Returns:
154
- * string | null: repo-relative posix config path, or null.
155
+ * string[]: repo-relative posix config paths, in choice order.
155
156
  */
156
- export function findPlaywrightConfig(cwd) {
157
+ export function findPlaywrightConfigs(cwd) {
158
+ const found = [];
157
159
  for (const name of PLAYWRIGHT_CONFIG_NAMES) {
158
- const path = join(cwd, name);
159
- if (existsSync(path))
160
- return name;
160
+ if (existsSync(join(cwd, name)))
161
+ found.push(name);
161
162
  }
162
163
  let names;
163
164
  try {
164
165
  names = readdirSync(cwd);
165
166
  }
166
167
  catch {
167
- return null; // unreadable root: the root-level search already came up empty
168
+ return found; // unreadable root: the root-level search already came up empty
168
169
  }
169
170
  const subdirs = names
170
- .filter((name) => !CONFIG_SEARCH_PRUNED_DIRS.has(name))
171
+ .filter((name) => CONFIG_SEARCH_PRUNED_DIRS[name] !== true)
171
172
  .filter((name) => {
172
173
  try {
173
174
  return statSync(join(cwd, name)).isDirectory();
@@ -180,16 +181,134 @@ export function findPlaywrightConfig(cwd) {
180
181
  for (const dir of subdirs) {
181
182
  for (const name of PLAYWRIGHT_CONFIG_NAMES) {
182
183
  if (existsSync(join(cwd, dir, name)))
183
- return `${dir}/${name}`;
184
+ found.push(`${dir}/${name}`);
184
185
  }
185
186
  }
186
- return null;
187
+ return found;
188
+ }
189
+ /**
190
+ * Finds the ONE consumer playwright config enumeration runs: at the
191
+ * repo root first, then — only when no root-level config exists — ONE
192
+ * directory level deep (see {@link findPlaywrightConfigs}). Returns
193
+ * the repo-relative posix path (`'playwright.config.ts'`, or
194
+ * `'e2e/playwright.config.ts'` for a subdirectory project), or null
195
+ * when none exists.
196
+ *
197
+ * Root-level configs always win: an existing root project must keep its
198
+ * exact historical invocation. The nested search only extends discovery
199
+ * to the self-contained subdirectory layout (the config's OWN directory
200
+ * pins its playwright install — see {@link playwrightCliPath}).
201
+ *
202
+ * The choice is never silent: when more than one config exists,
203
+ * {@link listNativePlaywrightTests} names every discovered config, the
204
+ * one used and why, and the ones NOT inventoried. There is deliberately
205
+ * no configuration key for the choice: any such key would change WHICH
206
+ * consumer code runs, not just what is reported, and it would have to
207
+ * be plumbed through the discovery callers' option sets.
208
+ *
209
+ * Args:
210
+ * cwd: absolute repo root.
211
+ *
212
+ * Returns:
213
+ * string | null: repo-relative posix config path, or null.
214
+ */
215
+ export function findPlaywrightConfig(cwd) {
216
+ return findPlaywrightConfigs(cwd)[0] ?? null;
217
+ }
218
+ /**
219
+ * Builds the one-line disclosure appended to enumeration's detail when
220
+ * the repo holds more than one playwright config. Without it a repo
221
+ * with several suites is silently graded as a subset.
222
+ *
223
+ * Args:
224
+ * configs: every discovered config, in choice order (first = used).
225
+ * used: the config enumeration ran.
226
+ *
227
+ * Returns:
228
+ * string: the disclosure, or '' when there is nothing to disclose.
229
+ */
230
+ function configChoiceNote(configs, used) {
231
+ if (configs.length < 2)
232
+ return '';
233
+ const reason = used.includes('/')
234
+ ? 'no repo-root config exists, so the alphabetically first subdirectory config is inventoried'
235
+ : 'a repo-root config always wins, so a root project keeps its exact invocation';
236
+ const listed = configs
237
+ .map((path) => path === used
238
+ ? `${path} (inventoried: ${reason})`
239
+ : `${path} (not inventoried: its test cases are missing from this catalog)`)
240
+ .join(', ');
241
+ return (` — note: ${String(configs.length)} playwright configs are present, and only 1 is inventoried: ${listed}`);
242
+ }
243
+ /**
244
+ * True when a parsed graph field is a plain object (never an array, never
245
+ * null): the container both project-graph maps are.
246
+ */
247
+ function isPlainRecord(value) {
248
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
249
+ }
250
+ /** True when a parsed field is an object whose values are all strings. */
251
+ function isStringRecord(value) {
252
+ return isPlainRecord(value) && Object.values(value).every((entry) => typeof entry === 'string');
253
+ }
254
+ /** True when a parsed dependency list is an array of strings. */
255
+ function isStringArray(value) {
256
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
187
257
  }
188
258
  /** Converts an absolute path to repo-root-relative posix form. */
189
259
  function toRepoRelative(cwd, path) {
190
260
  const rel = relative(cwd, resolve(cwd, path));
191
261
  return rel.split('\\').join('/');
192
262
  }
263
+ /**
264
+ * Reads the `version` of an installed package by its directory (the
265
+ * `<pkg>/package.json` beside a CLI), or null when it is unreadable.
266
+ *
267
+ * Args:
268
+ * packageDir: absolute directory of the installed package.
269
+ *
270
+ * Returns:
271
+ * string | null: the declared version, or null when unreadable.
272
+ */
273
+ function installedVersion(packageDir) {
274
+ try {
275
+ const parsed = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
276
+ return typeof parsed.version === 'string' ? parsed.version : null;
277
+ }
278
+ catch {
279
+ return null;
280
+ }
281
+ }
282
+ /**
283
+ * Expands a runner load error that is really a playwright VERSION
284
+ * conflict into one sentence naming both versions and the real cause.
285
+ * A missing dependency is the wrong diagnosis there: nothing is
286
+ * missing, the two-versions-of-@playwright/test conflict is (install
287
+ * rehearsal F7), and the pack's pinned copy must not be what runs the
288
+ * consumer's project. Every other message is passed through
289
+ * unchanged.
290
+ *
291
+ * Args:
292
+ * message: one reporter load error.
293
+ * cli: absolute path of the CLI that produced it.
294
+ *
295
+ * Returns:
296
+ * string: the message, with the conflict named when it is one.
297
+ */
298
+ function diagnoseRunnerLoadError(message, cli) {
299
+ if (!/did not expect test\(\) to be called here/.test(message))
300
+ return message;
301
+ const require = createRequire(import.meta.url);
302
+ const packDir = dirname(require.resolve('playwright/package.json'));
303
+ const runnerDir = dirname(cli);
304
+ const runnerName = basename(runnerDir) === 'test' ? `@playwright/${basename(runnerDir)}` : basename(runnerDir);
305
+ const runner = `${runnerName}@${installedVersion(runnerDir) ?? 'unknown version'}`;
306
+ const pack = `playwright@${installedVersion(packDir) ?? 'unknown version'} (the pack's pin)`;
307
+ return (`${message} — this is the two-versions-of-@playwright/test conflict, not a missing ` +
308
+ `dependency: this project was enumerated with ${runner} while the pack pins ${pack}. ` +
309
+ "Make the project's own @playwright/test the one Gateforge runs (remove the other copy from " +
310
+ 'node_modules), or align both to one version.');
311
+ }
193
312
  /**
194
313
  * Enumerates the consumer's playwright tests via official list mode.
195
314
  * See the module doc for the trust boundary. The CLI is the consumer's
@@ -205,8 +324,8 @@ function toRepoRelative(cwd, path) {
205
324
  * invocation (repo-root cwd, auto-discovered config, no `--config`).
206
325
  *
207
326
  * Args:
208
- * options: `cwd` (absolute repo root) and optional `timeoutMs`
209
- * (default {@link DEFAULT_LIST_TIMEOUT_MS}).
327
+ * options: `cwd` (absolute repo root), optional `timeoutMs`, and
328
+ * optional allowlisted wired-runner variables for registration comparison.
210
329
  *
211
330
  * Returns:
212
331
  * Promise<NativeListResult>: enumerated instances, reporter errors,
@@ -214,10 +333,12 @@ function toRepoRelative(cwd, path) {
214
333
  *
215
334
  * Throws:
216
335
  * TestDiscoveryError: when the child cannot spawn, exceeds the
217
- * timeout, or stdout is not parseable reporter JSON.
336
+ * timeout, or produced no readable reporter JSON (the report is read
337
+ * from the reporter's own output file, never from stdout).
218
338
  */
219
339
  export async function listNativePlaywrightTests(options) {
220
- const configPath = findPlaywrightConfig(options.cwd);
340
+ const configs = findPlaywrightConfigs(options.cwd);
341
+ const configPath = configs[0] ?? null;
221
342
  if (configPath === null) {
222
343
  return {
223
344
  status: 'unavailable',
@@ -231,15 +352,53 @@ export async function listNativePlaywrightTests(options) {
231
352
  const childCwd = join(options.cwd, configDir);
232
353
  const timeoutMs = options.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS;
233
354
  const cli = playwrightCliPath(options.cwd, configDir);
234
- const args = [cli, 'test', '--list', '--reporter=json'];
355
+ // The JSON report is read from a FILE the runner writes, never from
356
+ // stdout: a consumer's playwright config routinely prints at load
357
+ // time (a dotenv/dotenvx banner, a stray `console.log`) and stdout is
358
+ // the runner's own channel, not a document channel (install
359
+ // rehearsal F6). The path is absolute, so the reporter's
360
+ // cwd-relative resolution cannot move it, and the JSON reporter's
361
+ // `printsToStdio()` turns false — no part of the report can
362
+ // interleave with the config's logging. The same directory holds the
363
+ // project-graph document (below) and is removed with the child.
364
+ const reportDir = mkdtempSync(join(tmpdir(), 'gateforge-playwright-report-'));
365
+ const reportPath = join(reportDir, 'reporter.json');
366
+ // The project graph rides along with the json report through an
367
+ // ENGINE-OWNED reporter (see {@link projectGraphReporterEntry}): the
368
+ // json reporter's `config.projects[]` does not carry `dependencies` in
369
+ // any released playwright (verified against 1.58.2 and 1.62.1), and
370
+ // reading them out of the consumer config would mean trusting candidate
371
+ // code. Without the built entry the enumeration runs exactly as before
372
+ // and reports no graph — the field is optional precisely so a missing
373
+ // graph is honest absence, never a guessed empty one.
374
+ const graphReporterEntry = projectGraphReporterEntry();
375
+ const args = [
376
+ cli,
377
+ 'test',
378
+ '--list',
379
+ `--reporter=json${existsSync(graphReporterEntry) ? `,${graphReporterEntry}` : ''}`,
380
+ ];
235
381
  if (configDir !== '.')
236
382
  args.push('--config', basename(configPath));
237
- const discoveryStateDir = mkdtempSync(join(tmpdir(), 'gateforge-discovery-state-'));
383
+ const discoveryStateDir = options.wiredEnv === undefined ? mkdtempSync(join(tmpdir(), 'gateforge-discovery-state-')) : undefined;
238
384
  let outcome;
385
+ let reportText = null;
386
+ let graphText = null;
239
387
  try {
388
+ const childEnv = options.wiredEnv === undefined
389
+ ? untrustedEnv(process.env, discoveryStateDir)
390
+ : buildRunnerChildEnv(options.wiredEnv, process.env);
391
+ // The evidence fixture binds to the CONSUMER's runner, resolved from
392
+ // the config directory this enumeration just discovered: that is what
393
+ // makes a non-root config with its own `node_modules` work, not only a
394
+ // hoisted repository-root install. Set on BOTH the scrubbed and the
395
+ // wired child so the two registrations can never differ.
396
+ childEnv[ENV_PLAYWRIGHT_CONFIG_DIR] = childCwd;
397
+ childEnv['PLAYWRIGHT_JSON_OUTPUT_FILE'] = reportPath;
398
+ childEnv[PROJECT_GRAPH_PATH_ENV] = join(reportDir, 'project-graph.json');
240
399
  const child = spawn(process.execPath, args, {
241
400
  cwd: childCwd,
242
- env: untrustedEnv(process.env, discoveryStateDir),
401
+ env: childEnv,
243
402
  stdio: ['ignore', 'pipe', 'pipe'],
244
403
  });
245
404
  outcome = await new Promise((settle) => {
@@ -273,9 +432,27 @@ export async function listNativePlaywrightTests(options) {
273
432
  finish({ code, stdout, stderr, timedOut, error: null });
274
433
  });
275
434
  });
435
+ try {
436
+ reportText = readFileSync(reportPath, 'utf8');
437
+ }
438
+ catch {
439
+ // A runner that never reached the reporter (or one whose version
440
+ // predates its output-file support) leaves no file. The whole
441
+ // captured stream is then the only candidate, and it is read as
442
+ // one document — never scanned for a plausible-looking substring.
443
+ reportText = outcome.stdout;
444
+ }
445
+ try {
446
+ graphText = readFileSync(join(reportDir, 'project-graph.json'), 'utf8');
447
+ }
448
+ catch {
449
+ graphText = null;
450
+ }
276
451
  }
277
452
  finally {
278
- rmSync(discoveryStateDir, { recursive: true, force: true });
453
+ if (discoveryStateDir !== undefined)
454
+ rmSync(discoveryStateDir, { recursive: true, force: true });
455
+ rmSync(reportDir, { recursive: true, force: true });
279
456
  }
280
457
  if (outcome.error !== null) {
281
458
  throw new TestDiscoveryError(`playwright --list failed to run: ${outcome.error.message}`);
@@ -285,10 +462,12 @@ export async function listNativePlaywrightTests(options) {
285
462
  }
286
463
  let document;
287
464
  try {
288
- document = JSON.parse(outcome.stdout);
465
+ document = JSON.parse(reportText ?? '');
289
466
  }
290
467
  catch {
291
- throw new TestDiscoveryError(`playwright --list produced unparseable output (exit ${String(outcome.code)}): ` +
468
+ throw new TestDiscoveryError(`playwright --list produced no readable reporter JSON (exit ${String(outcome.code)}). The report ` +
469
+ "is read from the reporter's own output file, so a config that logs to stdout no longer corrupts " +
470
+ 'it — this means the run never reached the reporter. Runner output: ' +
292
471
  `${(outcome.stderr || outcome.stdout).slice(0, 400)}`);
293
472
  }
294
473
  // The runner reports files relative to ITS rootDir — the config
@@ -340,13 +519,151 @@ export async function listNativePlaywrightTests(options) {
340
519
  }
341
520
  };
342
521
  walkSuites(document.suites ?? [], [], null);
343
- const errors = (document.errors ?? []).map((error) => error.message ?? String(error));
522
+ const errors = (document.errors ?? []).map((error) => diagnoseRunnerLoadError(error.message ?? String(error), cli));
523
+ // The project graph the runner resolved, plus the storage states its
524
+ // projects declare. A document that does not parse — or that carries a
525
+ // field this reader does not understand — is absence, never a partial
526
+ // graph: a downstream run that emitted a `dependencies` edge from half
527
+ // a graph would order projects wrongly, and one that honored half the
528
+ // declared states would authenticate the wrong projects.
529
+ let projectDependencies;
530
+ let projectStorageStates;
531
+ if (graphText !== null) {
532
+ try {
533
+ const parsed = JSON.parse(graphText);
534
+ // Untrusted input: everything below is checked at runtime, and one
535
+ // bad field discards the WHOLE document rather than half of it.
536
+ const graph = parsed.projectDependencies;
537
+ const states = parsed.projectStorageStates;
538
+ if (parsed.schemaVersion === 2 && isPlainRecord(graph)) {
539
+ // Null-prototype: a project NAME is candidate data and `__proto__`
540
+ // is a legal one, so the maps keyed by it must not inherit.
541
+ const dependencies = Object.create(null);
542
+ const storageStates = Object.create(null);
543
+ let wellFormed = true;
544
+ for (const [name, edges] of Object.entries(graph)) {
545
+ if (!isStringArray(edges)) {
546
+ wellFormed = false;
547
+ break;
548
+ }
549
+ dependencies[name] = [...new Set(edges.filter((edge) => edge.length > 0))].sort();
550
+ }
551
+ if (wellFormed && states !== undefined) {
552
+ if (!isStringRecord(states)) {
553
+ wellFormed = false;
554
+ }
555
+ else {
556
+ for (const [name, value] of Object.entries(states)) {
557
+ // An empty string is carried, not filtered: whether it may
558
+ // be read is the planner's refusal, never this reader's
559
+ // silent omission.
560
+ storageStates[name] = value;
561
+ }
562
+ }
563
+ }
564
+ if (wellFormed) {
565
+ projectDependencies = dependencies;
566
+ // Omitted when nothing declared a state — an empty map would
567
+ // read as "every project runs with an empty state".
568
+ if (Object.keys(storageStates).length > 0)
569
+ projectStorageStates = storageStates;
570
+ }
571
+ }
572
+ }
573
+ catch {
574
+ projectDependencies = undefined;
575
+ projectStorageStates = undefined;
576
+ }
577
+ }
344
578
  const configDetail = configDir !== '.' ? ` (cwd '${configDir}')` : '';
579
+ const envDetail = options.wiredEnv === undefined
580
+ ? 'isolated temporary GATEFORGE_STATE_DIR'
581
+ : 'allowlisted wired runner variables';
345
582
  return {
346
583
  status: 'discovered',
347
- detail: `native playwright --list over '${configPath}'${configDetail} enumerated ${String(instances.length)} instance(s) as untrusted code (isolated temporary GATEFORGE_STATE_DIR only)`,
584
+ detail: `native playwright --list over '${configPath}'${configDetail} enumerated ${String(instances.length)} ` +
585
+ `instance(s) as untrusted code (${envDetail})` + configChoiceNote(configs, configPath),
348
586
  instances,
349
587
  errors,
588
+ ...(projectDependencies !== undefined ? { projectDependencies } : {}),
589
+ ...(projectStorageStates !== undefined ? { projectStorageStates } : {}),
590
+ };
591
+ }
592
+ /**
593
+ * Finds registration instances present in only one environment's native
594
+ * Playwright listing, treating project and duplicate instances as identity.
595
+ *
596
+ * Args:
597
+ * scrubbed: native instances enumerated without run wiring.
598
+ * wired: native instances enumerated with the runner's safe run variables.
599
+ *
600
+ * Returns:
601
+ * An object containing project-qualified instances unique to each listing.
602
+ */
603
+ export function diffNativePlaywrightTests(scrubbed, wired) {
604
+ /**
605
+ * Builds the project-qualified matching identity for one instance.
606
+ *
607
+ * Args:
608
+ * instance: native test instance.
609
+ *
610
+ * Returns:
611
+ * string: serialized file, title path, and project tuple.
612
+ */
613
+ const keyOf = (instance) => JSON.stringify([instance.file, instance.titlePath, instance.project]);
614
+ const wiredCounts = new Map();
615
+ for (const instance of wired) {
616
+ const key = keyOf(instance);
617
+ wiredCounts.set(key, (wiredCounts.get(key) ?? 0) + 1);
618
+ }
619
+ const matchedCounts = new Map();
620
+ const scrubbedOnly = [];
621
+ for (const instance of scrubbed) {
622
+ const key = keyOf(instance);
623
+ const matched = matchedCounts.get(key) ?? 0;
624
+ if (matched < (wiredCounts.get(key) ?? 0)) {
625
+ matchedCounts.set(key, matched + 1);
626
+ }
627
+ else {
628
+ scrubbedOnly.push(instance);
629
+ }
630
+ }
631
+ const wiredOnly = [];
632
+ const emittedCounts = new Map();
633
+ for (const instance of wired) {
634
+ const key = keyOf(instance);
635
+ const emitted = emittedCounts.get(key) ?? 0;
636
+ if (emitted < (matchedCounts.get(key) ?? 0)) {
637
+ emittedCounts.set(key, emitted + 1);
638
+ }
639
+ else {
640
+ wiredOnly.push(instance);
641
+ }
642
+ }
643
+ /**
644
+ * Sorts instances deterministically by file, project, and title path.
645
+ *
646
+ * Args:
647
+ * left: first native test instance.
648
+ * right: second native test instance.
649
+ *
650
+ * Returns:
651
+ * number: standard array comparator result.
652
+ */
653
+ const compare = (left, right) => {
654
+ const byFile = left.file < right.file ? -1 : left.file > right.file ? 1 : 0;
655
+ if (byFile !== 0)
656
+ return byFile;
657
+ const byProject = left.project < right.project ? -1 : left.project > right.project ? 1 : 0;
658
+ if (byProject !== 0)
659
+ return byProject;
660
+ const leftTitlePath = left.titlePath.join('>');
661
+ const rightTitlePath = right.titlePath.join('>');
662
+ return leftTitlePath < rightTitlePath ? -1 : leftTitlePath > rightTitlePath ? 1 : 0;
663
+ };
664
+ return {
665
+ scrubbedOnly: scrubbedOnly.sort(compare),
666
+ wiredOnly: wiredOnly.sort(compare),
350
667
  };
351
668
  }
352
669
  /** Matching key for reconciliation: file + full title path (no project). */