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