@gate-forge/pack-playwright 0.7.1 → 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 (202) 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/__pycache__/gateforge_persistence_intents.cpython-312.pyc +0 -0
  201. package/python/__pycache__/gateforge_persistence_intents.cpython-314.pyc +0 -0
  202. package/python/gateforge_pytest_plugin.py +462 -0
@@ -1,665 +1,26 @@
1
1
  /**
2
- * The engine-owned browser (plan Phase 1 item 4): Chromium contexts the
3
- * WITNESS process creates and drives itself — one isolated context per
4
- * supervisor-opened test session, closed when the session seals.
2
+ * Compatibility re-export: `witness/browser` now lives in the runner-neutral
3
+ * `@gate-forge/witness` package (plan 2026-09-25 phase 1).
5
4
  *
6
- * Test code NEVER touches these pages: the worker holds no handle, no
7
- * URL, and no capability over them. The engine executes the constrained
8
- * surface operations (create/read/update/archive) with the
9
- * consumer-declared locators, and observes each step itself:
10
- * - the rendered control it clicked and the values it typed;
11
- * - the actual application request the action caused (captured from the
12
- * engine page's own network, app-origin only);
13
- * - the resulting navigation and the rendered row/form it read back.
14
- *
15
- * Forgery resistance (why this channel is proof):
16
- * - fabricated DOM (`page.evaluate` in the worker page) is irrelevant:
17
- * the engine reads its OWN page;
18
- * - mocked responses (`page.route` in the worker) are irrelevant: the
19
- * worker cannot route the engine's traffic;
20
- * - direct API/Node mutations bypass the engine page entirely: no
21
- * engine action runs, so no engine-observed action/visible records
22
- * exist for them — invented suite-submitted records never satisfy;
23
- * - HTTP-200-while-error: the engine asserts the rendered status
24
- * itself (created/archived values) AND captures the app response
25
- * status — a displayed error fails the action fail-closed.
26
- *
27
- * Surface selectors are LOCATORS, never proof: a lying descriptor only
28
- * makes the engine fail to find elements (fail closed).
5
+ * The move is physical, not behavioural: this module forwards every
6
+ * export unchanged so `@gate-forge/pack-playwright`'s published import
7
+ * paths — which consumers pin — keep resolving to the same values.
29
8
  */
9
+ export * from '@gate-forge/witness/witness/browser';
30
10
  import { chromium } from 'playwright';
31
- import { declaredSurfaceFields, renderSurfaceTemplate, } from '../surface.js';
32
- /** Fail-closed engine browser error (surfaced as 409/503 downstream). */
33
- export class EngineBrowserError extends Error {
34
- constructor(message) {
35
- super(message);
36
- this.name = 'EngineBrowserError';
37
- }
38
- }
39
- /** Bounded wait for a rendered selector (fail closed, never indefinite). */
40
- const ENGINE_STEP_TIMEOUT_MS = 15_000;
41
- /**
42
- * Walks one validated wizard step list on the engine-owned page
43
- * (surface v2 create). Every interaction runs here, engine-side: the
44
- * test supplies intent (field values) only. A step referencing an
45
- * input field the journey did not provide fails closed — the engine
46
- * never invents values. Unknown shapes are unreachable post-
47
- * validation, but still refused defensively (never silently skipped).
48
- *
49
- * Args:
50
- * page: the engine-owned page (real or structural stub).
51
- * appBase: the trusted attested subject origin (never suite input).
52
- * steps: the validated wizard walk, in order.
53
- * fields: the journey's declared input values, keyed by field name.
54
- */
55
- export async function driveCreateSteps(page, appBase, steps, fields) {
56
- for (const step of steps) {
57
- if ('goto' in step) {
58
- await page.goto(`${appBase}${step.goto}`, { timeout: ENGINE_STEP_TIMEOUT_MS });
59
- }
60
- else if ('click' in step) {
61
- await page.locator(step.click).click({ timeout: ENGINE_STEP_TIMEOUT_MS });
62
- }
63
- else if ('fill' in step) {
64
- const value = step.fill.field !== undefined ? fields[step.fill.field] : step.fill.value;
65
- if (typeof value !== 'string') {
66
- throw new EngineBrowserError(`browser.create steps: fill of '${step.fill.selector}' references field ` +
67
- `'${String(step.fill.field)}' the journey did not provide — the engine never invents input values`);
68
- }
69
- await page.locator(step.fill.selector).fill(value, { timeout: ENGINE_STEP_TIMEOUT_MS });
70
- }
71
- else if ('check' in step) {
72
- await page.locator(step.check).check({ timeout: ENGINE_STEP_TIMEOUT_MS });
73
- }
74
- else if ('press' in step) {
75
- await page.keyboard.press(step.press);
76
- }
77
- else if ('waitFor' in step) {
78
- await page.waitForSelector(step.waitFor, { timeout: ENGINE_STEP_TIMEOUT_MS });
79
- }
80
- else {
81
- throw new EngineBrowserError(`browser.create steps: unknown wizard step ${JSON.stringify(step)} — refusing to drive`);
82
- }
83
- }
84
- }
11
+ import { EngineBrowserManager as RunnerNeutralBrowserManager, } from '@gate-forge/witness/witness/browser';
85
12
  /**
86
- * Owns the engine Chromium instance and its per-session contexts.
13
+ * The Playwright-defaulting engine browser manager.
87
14
  *
88
- * Args:
89
- * launcher: browser launcher (default: the pinned Chromium). Tests
90
- * inject a failing launcher to prove the fail-closed path; the
91
- * driving and observation below stay engine code regardless of
92
- * which executable launches.
15
+ * The runner-neutral `EngineBrowserManager` REQUIRES a launcher: this
16
+ * package is the one that knows about Playwright, so the default lives
17
+ * here. Every construction site already passed a launcher explicitly,
18
+ * so nothing changes — but a consumer that relied on the default keeps
19
+ * getting Chromium from this path.
93
20
  */
94
- export class EngineBrowserManager {
95
- launcher;
96
- browser = null;
97
- launching = null;
98
- sessions = new Map();
99
- /**
100
- * Pinned `--host-resolver-rules` value binding every attested hostname
101
- * to its startup-approved loopback IPs. Set once at witness startup
102
- * (before any launch); the browser is then incapable of resolving
103
- * those names anywhere but loopback, regardless of later DNS changes.
104
- */
105
- dnsPinRules = null;
21
+ export class EngineBrowserManager extends RunnerNeutralBrowserManager {
106
22
  constructor(launcher = chromium) {
107
- this.launcher = launcher;
108
- }
109
- /**
110
- * Installs the DNS pin rules for the next (first) launch.
111
- *
112
- * Args:
113
- * rules: the `--host-resolver-rules` value, or null when nothing is
114
- * pinned (plain launch, previous behavior).
115
- *
116
- * Throws:
117
- * EngineBrowserError: when the browser already launched — pins must
118
- * precede every navigation (fail closed, never silently unbound).
119
- */
120
- setDnsPinRules(rules) {
121
- if (this.browser !== null || this.launching !== null) {
122
- throw new EngineBrowserError('DNS pin rules arrive after browser launch: pins must precede every navigation');
123
- }
124
- this.dnsPinRules = rules;
125
- }
126
- /**
127
- * Returns the engine page for one session, creating the isolated
128
- * context on first use.
129
- *
130
- * Args:
131
- * sessionId: the supervisor-opened session the context belongs to.
132
- *
133
- * Returns:
134
- * Promise<Page>: the session's engine-owned page.
135
- *
136
- * Throws:
137
- * EngineBrowserError: when Chromium cannot launch (fail closed —
138
- * the capability is honestly unavailable at runtime).
139
- */
140
- async pageFor(sessionId) {
141
- const existing = this.sessions.get(sessionId);
142
- if (existing !== undefined)
143
- return existing.page;
144
- const browser = await this.ensureBrowser();
145
- const context = await browser.newContext();
146
- const page = await context.newPage();
147
- this.sessions.set(sessionId, { context, page });
148
- return page;
149
- }
150
- /**
151
- * Closes and forgets one session's engine context (session seal).
152
- * Late engine calls for the session then recreate nothing — callers
153
- * must refuse sealed sessions before driving (they do: every browser
154
- * endpoint requires an OPEN session first).
155
- *
156
- * Args:
157
- * sessionId: the sealed session.
158
- */
159
- async closeSession(sessionId) {
160
- const entry = this.sessions.get(sessionId);
161
- if (entry === undefined)
162
- return;
163
- this.sessions.delete(sessionId);
164
- await entry.context.close().catch(() => undefined);
165
- }
166
- /** Closes every engine context and the browser (witness stop). */
167
- async closeAll() {
168
- const entries = [...this.sessions.values()];
169
- this.sessions.clear();
170
- await Promise.all(entries.map((entry) => entry.context.close().catch(() => undefined)));
171
- const browser = this.browser;
172
- this.browser = null;
173
- this.launching = null;
174
- if (browser !== null)
175
- await browser.close().catch(() => undefined);
176
- }
177
- /** Lazy Chromium launch (single flight; fail closed with the cause). */
178
- async ensureBrowser() {
179
- if (this.browser !== null)
180
- return this.browser;
181
- this.launching ??= this.launcher
182
- .launch({
183
- headless: true,
184
- // DNS binding (loopback-pins): attested hostnames resolve ONLY
185
- // to their startup-approved loopback IPs inside this browser —
186
- // a mid-run DNS change cannot move its traffic off loopback.
187
- ...(this.dnsPinRules !== null ? { args: [`--host-resolver-rules=${this.dnsPinRules}`] } : {}),
188
- })
189
- .then((browser) => {
190
- this.browser = browser;
191
- return browser;
192
- })
193
- .catch((error) => {
194
- this.launching = null;
195
- throw new EngineBrowserError(`the engine-owned browser cannot launch: ${error.message} — browser ` +
196
- 'contracts stay blocking until a working Chromium is available to the witness process');
197
- });
198
- return this.launching;
199
- }
200
- }
201
- /** Captures app-origin responses on one page for the action window. */
202
- async function captureExchanges(page, appOrigin, action) {
203
- const captured = [];
204
- // Replacement-origin enforcement (fake-frontend fix 2026-09-14): every
205
- // URL the engine page touches during the window — main-frame
206
- // navigations AND every hop of every redirect chain — must stay on the
207
- // trusted origin. A form submit, link, or redirect landing elsewhere
208
- // fails the action closed instead of observing a foreign frontend.
209
- const violations = [];
210
- const checkUrl = (url, where) => {
211
- let origin;
212
- try {
213
- origin = new URL(url).origin;
214
- }
215
- catch {
216
- return; // non-URL (about:blank, data:) — the final page.url check covers landing
217
- }
218
- if (origin !== appOrigin)
219
- violations.push(`${where}: ${url}`);
220
- };
221
- // Main-frame navigation guard: page.url() is the main frame's URL,
222
- // so asserting it on every frame event covers navigations while
223
- // subresource loads (same URL, no-op) cannot false-positive.
224
- const mainFrameListener = () => {
225
- const current = page.url();
226
- if (current.startsWith('about:') || current.startsWith('data:'))
227
- return;
228
- checkUrl(current, 'navigation to foreign origin');
229
- };
230
- page.on('framenavigated', mainFrameListener);
231
- const listener = (response) => {
232
- // Walk the full redirect chain: the final URL AND every hop must be
233
- // same-origin — a 303 to a foreign frontend is rejected even though
234
- // the 303 itself came from the app. Scoped to redirects and document
235
- // navigations: plain cross-origin SUBRESOURCES (fonts, icons) are
236
- // not navigations, never become evidence, and must not false-positive.
237
- const request = response.request();
238
- const chain = [request.url()];
239
- let previous = request.redirectedFrom();
240
- while (previous !== null && typeof previous === 'object' && 'url' in previous) {
241
- const hop = previous;
242
- chain.push(hop.url());
243
- previous = hop.redirectedFrom();
244
- }
245
- const involvedRedirect = chain.length > 1;
246
- let resourceType = '';
247
- try {
248
- resourceType = request.resourceType();
249
- }
250
- catch {
251
- resourceType = '';
252
- }
253
- if (involvedRedirect || resourceType === 'document') {
254
- for (const url of chain)
255
- checkUrl(url, 'redirect chain left the trusted origin');
256
- }
257
- let url;
258
- try {
259
- url = new URL(response.url());
260
- }
261
- catch {
262
- return;
263
- }
264
- if (url.origin !== appOrigin)
265
- return;
266
- const chunks = [];
267
- void response
268
- .body()
269
- .then((body) => chunks.push(body))
270
- .catch(() => undefined)
271
- .finally(() => {
272
- captured.push({
273
- method: response.request().method().toUpperCase(),
274
- path: canonicalExchangePath(url.pathname),
275
- status: response.status(),
276
- body: Buffer.concat(chunks).slice(0, 16384),
277
- });
278
- });
279
- };
280
- page.on('response', listener);
281
- // The drive's own error (status mismatch, missing row) is recorded
282
- // but yields to origin violations: a redirect escape is the stronger
283
- // security signal and must name itself even when the foreign page
284
- // ALSO fails a rendered check.
285
- let driveError = null;
286
- try {
287
- await action();
288
- // Let in-flight response bodies settle (bounded): the mutation's own
289
- // exchange must be captured before the window closes.
290
- await page.waitForTimeout(250);
291
- }
292
- catch (error) {
293
- driveError = error;
294
- }
295
- finally {
296
- page.off('response', listener);
297
- page.off('framenavigated', mainFrameListener);
298
- }
299
- // Await the pending body reads (bounded by the step timeout overall).
300
- await page.waitForTimeout(250);
301
- // The landing itself must be the trusted origin (backstop for
302
- // navigations that produced no response event).
303
- checkUrl(page.url(), 'engine page landed on a foreign origin');
304
- if (violations.length > 0) {
305
- throw new EngineBrowserError(`browser action left the trusted application origin '${appOrigin}': ` +
306
- `${violations.slice(0, 3).join('; ')} — replacement origins and redirects are rejected; ` +
307
- 'no action record is issued for a foreign frontend');
308
- }
309
- if (driveError !== null)
310
- throw driveError;
311
- return captured;
312
- }
313
- /** Canonicalizes an engine-observed path (lockstep with the witness). */
314
- function canonicalExchangePath(pathname) {
315
- let path = pathname.split('?')[0]?.split('#')[0] ?? '/';
316
- if (!path.startsWith('/'))
317
- path = `/${path}`;
318
- if (path.length > 1)
319
- path = path.replace(/\/+$/, '');
320
- return path;
321
- }
322
- /** Reads the cells of a legacy table row in DOM order. */
323
- async function rowCells(row) {
324
- const cells = row.locator('td');
325
- const count = await cells.count();
326
- const out = [];
327
- for (let i = 0; i < count; i++) {
328
- out.push(((await cells.nth(i).textContent()) ?? '').trim());
329
- }
330
- return out;
331
- }
332
- /**
333
- * Checks whether one descriptor uses v3 row-relative field locators.
334
- *
335
- * Args:
336
- * list: the validated list descriptor.
337
- *
338
- * Returns:
339
- * boolean: true when v3 relative locators are present.
340
- */
341
- function usesRelativeListLocators(list) {
342
- return typeof list.idLocator === 'string';
343
- }
344
- /**
345
- * Reads an entity id from a row with its versioned list strategy.
346
- *
347
- * Args:
348
- * row: the matched entity row or card.
349
- * surface: the validated consumer surface descriptor.
350
- *
351
- * Returns:
352
- * Promise<string>: the trimmed rendered entity id.
353
- */
354
- async function readRowEntityId(row, surface) {
355
- if (usesRelativeListLocators(surface.list)) {
356
- return ((await row.locator(surface.list.idLocator).textContent()) ?? '').trim();
357
- }
358
- const cells = await rowCells(row);
359
- return (cells[surface.list.idCellIndex] ?? '').trim();
360
- }
361
- /** Every entity id rendered in the list right now. */
362
- async function collectIds(page, surface) {
363
- const rows = page.locator(surface.list.rowSelector);
364
- const count = await rows.count();
365
- const ids = new Set();
366
- for (let i = 0; i < count; i++) {
367
- const id = await readRowEntityId(rows.nth(i), surface);
368
- if (id !== '')
369
- ids.add(id);
370
- }
371
- return ids;
372
- }
373
- /** The rendered row for one entity (null when the UI exposes none). */
374
- async function findRow(page, surface, entityId) {
375
- const rows = page.locator(surface.list.rowSelector);
376
- const count = await rows.count();
377
- for (let i = 0; i < count; i++) {
378
- const row = rows.nth(i);
379
- if ((await readRowEntityId(row, surface)) === entityId)
380
- return row;
381
- }
382
- return null;
383
- }
384
- /** The rendered field values of one row. */
385
- async function readRowFields(page, surface, row) {
386
- void page;
387
- const fields = {};
388
- if (usesRelativeListLocators(surface.list)) {
389
- for (const [name, selector] of Object.entries(surface.list.fieldLocators)) {
390
- fields[name] = ((await row.locator(selector).textContent()) ?? '').trim();
391
- }
392
- }
393
- else {
394
- const cells = await rowCells(row);
395
- for (const [name, index] of Object.entries(surface.list.fieldCellIndexes)) {
396
- fields[name] = (cells[index] ?? '').trim();
397
- }
398
- }
399
- return fields;
400
- }
401
- /** Fills exactly the declared inputs on the current form. */
402
- async function fillFormFields(page, selectors, fields) {
403
- for (const [name, selector] of Object.entries(selectors)) {
404
- const value = fields[name];
405
- if (value === undefined)
406
- continue;
407
- await page.locator(selector).fill(value, { timeout: ENGINE_STEP_TIMEOUT_MS });
408
- }
409
- }
410
- /** Reads the current form's input values back. */
411
- async function readFormFields(page, selectors) {
412
- const fields = {};
413
- for (const [name, selector] of Object.entries(selectors)) {
414
- fields[name] = await page.locator(selector).inputValue({ timeout: ENGINE_STEP_TIMEOUT_MS });
415
- }
416
- return fields;
417
- }
418
- /** Navigates to the list and proves it rendered. */
419
- async function gotoList(page, appBase, surface) {
420
- await page.goto(`${appBase}${surface.list.path}`, { timeout: ENGINE_STEP_TIMEOUT_MS });
421
- await page.waitForSelector(surface.list.readySelector, { timeout: ENGINE_STEP_TIMEOUT_MS });
422
- }
423
- /** Waits for the post-action landing and proves it rendered. */
424
- async function waitAfterAction(page, surface) {
425
- await page.waitForURL((url) => url.pathname === surface.afterAction.path, {
426
- timeout: ENGINE_STEP_TIMEOUT_MS,
427
- });
428
- await page.waitForSelector(surface.list.readySelector, { timeout: ENGINE_STEP_TIMEOUT_MS });
429
- }
430
- /**
431
- * Drives one constrained surface operation on the engine page and
432
- * observes every step itself.
433
- *
434
- * Args:
435
- * page: the session's engine-owned page.
436
- * appBase: loopback app base the engine navigates (validated upstream).
437
- * surface: the validated consumer descriptor (locators only).
438
- * operation: the constrained operation to perform.
439
- * input: entered fields (create/update) and/or the target entity id.
440
- *
441
- * Returns:
442
- * Promise<EngineActionObservation>: the engine's own observation —
443
- * observed entity id, entered + rendered fields, captured exchanges.
444
- *
445
- * Throws:
446
- * EngineBrowserError: fail-closed on every untrusted outcome —
447
- * missing rows, ambiguous creation, wrong rendered status, no
448
- * application request, or an application error status.
449
- */
450
- export async function driveEngineAction(page, appBase, surface, operation, input) {
451
- const appOrigin = new URL(appBase).origin;
452
- try {
453
- return await driveEngineActionInner(page, appBase, appOrigin, surface, operation, input);
454
- }
455
- catch (error) {
456
- // Every Playwright failure (timeout, navigation, detached frame) is
457
- // an engine-observed unsuccessful action — never a 500: the test
458
- // fails and the gate blocks with the precise cause.
459
- if (error instanceof EngineBrowserError)
460
- throw error;
461
- throw new EngineBrowserError(`browser.${operation} failed: ${error.message}`);
462
- }
463
- }
464
- /** The operation dispatch (wrapped by {@link driveEngineAction}). */
465
- async function driveEngineActionInner(page, appBase, appOrigin, surface, operation, input) {
466
- switch (operation) {
467
- case 'create': {
468
- const fields = declaredSurfaceFields('browser.create', input.fields ?? {}, surface.create.fields);
469
- let entityId = '';
470
- let rendered = {};
471
- const exchanges = await captureExchanges(page, appOrigin, async () => {
472
- await gotoList(page, appBase, surface);
473
- const before = await collectIds(page, surface);
474
- if ('steps' in surface.create) {
475
- // Wizard walk (surface v2): the engine performs the declared
476
- // steps itself — every click/type is engine-observed.
477
- await driveCreateSteps(page, appBase, surface.create.steps, fields);
478
- }
479
- else {
480
- await page.goto(`${appBase}${surface.create.formPath}`, { timeout: ENGINE_STEP_TIMEOUT_MS });
481
- await page.waitForSelector(surface.create.formReadySelector, { timeout: ENGINE_STEP_TIMEOUT_MS });
482
- await fillFormFields(page, surface.create.fields, fields);
483
- await page.locator(surface.create.submitSelector).click({ timeout: ENGINE_STEP_TIMEOUT_MS });
484
- }
485
- await waitAfterAction(page, surface);
486
- const created = [...(await collectIds(page, surface))].filter((id) => !before.has(id));
487
- if (created.length !== 1) {
488
- throw new EngineBrowserError(`browser.create observed ${String(created.length)} new entities, expected exactly one — ` +
489
- 'the rendered outcome is ambiguous, so no action record is issued');
490
- }
491
- entityId = created[0];
492
- const row = await findRow(page, surface, entityId);
493
- if (row === null) {
494
- throw new EngineBrowserError(`browser.create: the rendered list exposes no row for the created entity ${entityId}`);
495
- }
496
- rendered = await readRowFields(page, surface, row);
497
- if (surface.status.createdValue !== undefined) {
498
- const shown = rendered[surface.status.field] ?? '';
499
- if (shown !== surface.status.createdValue) {
500
- throw new EngineBrowserError(`browser.create: entity ${entityId} rendered ${surface.status.field} '${shown}', ` +
501
- `expected '${surface.status.createdValue}' — the application displays an error state, ` +
502
- 'so the action fails instead of earning proof');
503
- }
504
- }
505
- });
506
- requireAppRequest(exchanges, 'create');
507
- return { entityId, enteredFields: fields, renderedFields: rendered, exchanges };
508
- }
509
- case 'read': {
510
- const entityId = input.entityId ?? '';
511
- if (entityId === '')
512
- throw new EngineBrowserError('browser.read requires { entityId }');
513
- let rendered = {};
514
- const exchanges = await captureExchanges(page, appOrigin, async () => {
515
- await gotoList(page, appBase, surface);
516
- const row = await findRow(page, surface, entityId);
517
- if (row === null) {
518
- throw new EngineBrowserError(`browser.read: the rendered UI exposes no row for ${entityId}`);
519
- }
520
- const edit = row.locator(surface.edit.linkSelector);
521
- if ((await edit.count()) === 0) {
522
- throw new EngineBrowserError(`browser.read: the rendered UI exposes no navigation control for ${entityId}`);
523
- }
524
- await edit.click({ timeout: ENGINE_STEP_TIMEOUT_MS });
525
- await page.waitForSelector(renderSurfaceTemplate(surface.edit.formReadySelectorTemplate, entityId), {
526
- timeout: ENGINE_STEP_TIMEOUT_MS,
527
- });
528
- rendered = await readFormFields(page, surface.edit.fields);
529
- });
530
- return { entityId, enteredFields: {}, renderedFields: rendered, exchanges };
531
- }
532
- case 'update': {
533
- const entityId = input.entityId ?? '';
534
- if (entityId === '')
535
- throw new EngineBrowserError('browser.update requires { entityId }');
536
- const fields = declaredSurfaceFields('browser.update', input.fields ?? {}, surface.edit.fields);
537
- let rendered = {};
538
- const exchanges = await captureExchanges(page, appOrigin, async () => {
539
- await gotoList(page, appBase, surface);
540
- const row = await findRow(page, surface, entityId);
541
- if (row === null) {
542
- throw new EngineBrowserError(`browser.update: the rendered UI exposes no row for ${entityId}`);
543
- }
544
- await row.locator(surface.edit.linkSelector).click({ timeout: ENGINE_STEP_TIMEOUT_MS });
545
- await page.waitForSelector(renderSurfaceTemplate(surface.edit.formReadySelectorTemplate, entityId), {
546
- timeout: ENGINE_STEP_TIMEOUT_MS,
547
- });
548
- await fillFormFields(page, surface.edit.fields, fields);
549
- await page
550
- .locator(renderSurfaceTemplate(surface.edit.saveSelectorTemplate, entityId))
551
- .first()
552
- .click({ timeout: ENGINE_STEP_TIMEOUT_MS });
553
- await waitAfterAction(page, surface);
554
- const updatedRow = await findRow(page, surface, entityId);
555
- if (updatedRow === null) {
556
- throw new EngineBrowserError(`browser.update: entity ${entityId} vanished after update`);
557
- }
558
- rendered = await readRowFields(page, surface, updatedRow);
559
- });
560
- requireAppRequest(exchanges, 'update');
561
- return { entityId, enteredFields: fields, renderedFields: rendered, exchanges };
562
- }
563
- case 'delete': {
564
- const entityId = input.entityId ?? '';
565
- if (entityId === '')
566
- throw new EngineBrowserError('browser.archive requires { entityId }');
567
- const deleteFields = { ...surface.deleteFields };
568
- let rendered = {};
569
- const exchanges = await captureExchanges(page, appOrigin, async () => {
570
- await gotoList(page, appBase, surface);
571
- const row = await findRow(page, surface, entityId);
572
- if (row === null) {
573
- throw new EngineBrowserError(`browser.archive: the rendered UI exposes no row for ${entityId}`);
574
- }
575
- const control = row.locator(renderSurfaceTemplate(surface.archive.controlSelectorTemplate, entityId));
576
- if ((await control.count()) === 0) {
577
- throw new EngineBrowserError(`browser.archive: entity ${entityId} exposes no archive control (already archived?)`);
578
- }
579
- await control.click({ timeout: ENGINE_STEP_TIMEOUT_MS });
580
- await waitAfterAction(page, surface);
581
- const archivedRow = await findRow(page, surface, entityId);
582
- if (archivedRow === null) {
583
- throw new EngineBrowserError(`browser.archive: entity ${entityId} vanished after archive`);
584
- }
585
- rendered = await readRowFields(page, surface, archivedRow);
586
- if (surface.status.archivedValue !== undefined) {
587
- const shown = rendered[surface.status.field] ?? '';
588
- if (shown !== surface.status.archivedValue) {
589
- throw new EngineBrowserError(`browser.archive: entity ${entityId} rendered ${surface.status.field} '${shown}', ` +
590
- `expected '${surface.status.archivedValue}' — the application displays an error ` +
591
- 'state, so the action fails instead of earning proof');
592
- }
593
- }
594
- });
595
- requireAppRequest(exchanges, 'delete');
596
- return { entityId, enteredFields: deleteFields, renderedFields: rendered, exchanges };
597
- }
598
- }
599
- }
600
- /**
601
- * Requires that the action window captured at least one application
602
- * request that the app did not reject: a rendered click that caused no
603
- * request (or only error statuses) proves no application behavior.
604
- *
605
- * Args:
606
- * exchanges: the engine-captured app-origin exchanges.
607
- * operation: the performed operation (diagnostic only).
608
- *
609
- * Throws:
610
- * EngineBrowserError: when no acceptable application request exists.
611
- */
612
- function requireAppRequest(exchanges, operation) {
613
- const mutation = exchanges.filter((entry) => entry.method !== 'GET' && entry.method !== 'HEAD');
614
- if (mutation.length === 0) {
615
- throw new EngineBrowserError(`browser.${operation}: the engine captured no application request for the action — ` +
616
- 'a rendered click that caused no request proves no application behavior');
617
- }
618
- const accepted = mutation.find((entry) => entry.status < 400);
619
- if (accepted === undefined) {
620
- throw new EngineBrowserError(`browser.${operation}: every captured application request was rejected ` +
621
- `(${mutation.map((entry) => `${entry.method} ${entry.path} → ${String(entry.status)}`).join(', ')}) — ` +
622
- 'the application refused the action, so it fails instead of earning proof');
623
- }
624
- }
625
- /**
626
- * Re-reads the rendered result for one entity on the engine page
627
- * (the `visible.confirm` counterpart): row read for mutations, form
628
- * read for reads.
629
- *
630
- * Args:
631
- * page: the session's engine-owned page.
632
- * appBase: loopback app base.
633
- * surface: the validated consumer descriptor.
634
- * operation: the original action (decides row vs form readback).
635
- * entityId: the engine-observed entity id.
636
- *
637
- * Returns:
638
- * Promise<Record<string, string>>: the rendered fields.
639
- *
640
- * Throws:
641
- * EngineBrowserError: when the UI no longer exposes the entity.
642
- */
643
- export async function readEngineVisible(page, appBase, surface, operation, entityId) {
644
- try {
645
- await gotoList(page, appBase, surface);
646
- const row = await findRow(page, surface, entityId);
647
- if (row === null) {
648
- throw new EngineBrowserError(`browser.visible: the rendered UI exposes no row for ${entityId}`);
649
- }
650
- if (operation === 'read') {
651
- await row.locator(surface.edit.linkSelector).click({ timeout: ENGINE_STEP_TIMEOUT_MS });
652
- await page.waitForSelector(renderSurfaceTemplate(surface.edit.formReadySelectorTemplate, entityId), {
653
- timeout: ENGINE_STEP_TIMEOUT_MS,
654
- });
655
- return readFormFields(page, surface.edit.fields);
656
- }
657
- return readRowFields(page, surface, row);
658
- }
659
- catch (error) {
660
- if (error instanceof EngineBrowserError)
661
- throw error;
662
- throw new EngineBrowserError(`browser.visible failed: ${error.message}`);
23
+ super(launcher);
663
24
  }
664
25
  }
665
26
  //# sourceMappingURL=browser.js.map