@vgai/live 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -0
- package/dist/editor.d.ts +78 -55
- package/dist/editor.js +129 -76
- package/dist/game-client/bridge-heartbeat.d.ts +49 -0
- package/dist/game-client/bridge-heartbeat.js +46 -0
- package/dist/game-client/bridge-transport.d.ts +75 -0
- package/dist/game-client/bridge-transport.js +19 -0
- package/dist/game-client/client.d.ts +293 -0
- package/dist/game-client/client.js +706 -0
- package/dist/game-client/errors.d.ts +57 -0
- package/dist/game-client/errors.js +76 -0
- package/dist/game-client/events-matcher.d.ts +41 -0
- package/dist/game-client/events-matcher.js +68 -0
- package/dist/game-client/failure-block.d.ts +93 -0
- package/dist/game-client/failure-block.js +97 -0
- package/dist/game-client/fast-forward.d.ts +125 -0
- package/dist/game-client/fast-forward.js +122 -0
- package/dist/game-client/hidden-recovery.d.ts +85 -0
- package/dist/game-client/hidden-recovery.js +105 -0
- package/dist/game-client/index.d.ts +40 -0
- package/dist/game-client/index.js +26 -0
- package/dist/game-client/perf-sampling.d.ts +56 -0
- package/dist/game-client/perf-sampling.js +85 -0
- package/dist/game-client/relay-transport.d.ts +100 -0
- package/dist/game-client/relay-transport.js +237 -0
- package/dist/game-client/screenshot-target.d.ts +60 -0
- package/dist/game-client/screenshot-target.js +68 -0
- package/dist/game-client/state-cap.d.ts +7 -0
- package/dist/game-client/state-cap.js +21 -0
- package/dist/game-client/types.d.ts +128 -0
- package/dist/game-client/types.js +15 -0
- package/dist/game-client/wait-for.d.ts +155 -0
- package/dist/game-client/wait-for.js +229 -0
- package/dist/game.d.ts +47 -18
- package/dist/game.js +59 -16
- package/dist/index.d.ts +46 -21
- package/dist/index.js +51 -20
- package/dist/session.d.ts +4 -4
- package/dist/session.js +7 -7
- package/dist/tools.d.ts +12 -3
- package/dist/tools.js +15 -6
- package/package.json +10 -5
- package/src/editor.ts +142 -96
- package/src/game-client/bridge-heartbeat.ts +61 -0
- package/src/game-client/bridge-transport.ts +73 -0
- package/src/game-client/client.ts +836 -0
- package/src/game-client/errors.ts +96 -0
- package/src/game-client/events-matcher.ts +106 -0
- package/src/game-client/failure-block.ts +199 -0
- package/src/game-client/fast-forward.ts +175 -0
- package/src/game-client/hidden-recovery.ts +149 -0
- package/src/game-client/index.ts +98 -0
- package/src/game-client/perf-sampling.ts +94 -0
- package/src/game-client/relay-transport.ts +311 -0
- package/src/game-client/screenshot-target.ts +91 -0
- package/src/game-client/state-cap.ts +29 -0
- package/src/game-client/types.ts +137 -0
- package/src/game-client/wait-for.ts +327 -0
- package/src/game.ts +96 -16
- package/src/index.ts +68 -31
- package/src/session.ts +8 -10
- package/src/tools.ts +19 -6
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Playwright-page-bound wiring for `game`'s client methods (D17: specs
|
|
3
|
+
* drive `window.__vgai` via `page.evaluate` — the standalone page has no HTTP
|
|
4
|
+
* channel). This module is deliberately the ONLY place that touches `Page`;
|
|
5
|
+
* the budget math (`wait-for.ts`), the events matcher (`events-matcher.ts`),
|
|
6
|
+
* and the failure-block assembler (`failure-block.ts`) are plain modules
|
|
7
|
+
* that never import Playwright, per Task 3.2's architecture requirement.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir } from 'node:fs/promises';
|
|
10
|
+
import { dirname, resolve } from 'node:path';
|
|
11
|
+
import { maybeBridgeHeartbeat } from './bridge-heartbeat.js';
|
|
12
|
+
import { appendWarmSessionHint, inputGatedError, SessionError, SessionFailure } from './errors.js';
|
|
13
|
+
import { describeEventsExpectation, matchEventsSubsequence } from './events-matcher.js';
|
|
14
|
+
import { assembleFailureBlock } from './failure-block.js';
|
|
15
|
+
import { runFastForward, } from './fast-forward.js';
|
|
16
|
+
import { HiddenRecoveryDriver } from './hidden-recovery.js';
|
|
17
|
+
import { TpsAccumulator } from './perf-sampling.js';
|
|
18
|
+
import { resolveScreenshotTarget } from './screenshot-target.js';
|
|
19
|
+
import { assertValidWaitForBudget, maybeHeartbeat, runWaitFor, WAIT_FOR_STALL_POLL_LIMIT, WaitForTimeoutError, } from './wait-for.js';
|
|
20
|
+
/** Runs inside the page. Kept as a single exported plain function (not a
|
|
21
|
+
* closure over Node state) because `page.evaluate(fn, arg)` only ships
|
|
22
|
+
* `fn`'s own source across the boundary. */
|
|
23
|
+
function bridgeCallInPage(args) {
|
|
24
|
+
const bridge = window.__vgai;
|
|
25
|
+
if (!bridge) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
error: { code: undefined, message: 'window.__vgai is not installed on this page' },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const parts = args.method.split('.');
|
|
32
|
+
let parent = bridge;
|
|
33
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
34
|
+
parent = parent[parts[i]];
|
|
35
|
+
}
|
|
36
|
+
const key = parts[parts.length - 1];
|
|
37
|
+
const fn = parent[key];
|
|
38
|
+
try {
|
|
39
|
+
const result = fn.apply(parent, args.callArgs);
|
|
40
|
+
return { ok: true, result };
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const e = err;
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
error: { code: e?.code, message: e?.message ?? String(err), data: e?.data },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Same shape, but awaits the call — used for `invoke` (async debug
|
|
51
|
+
* commands). */
|
|
52
|
+
async function bridgeCallInPageAsync(args) {
|
|
53
|
+
const bridge = window.__vgai;
|
|
54
|
+
if (!bridge) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: { code: undefined, message: 'window.__vgai is not installed on this page' },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const parts = args.method.split('.');
|
|
61
|
+
let parent = bridge;
|
|
62
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
63
|
+
parent = parent[parts[i]];
|
|
64
|
+
}
|
|
65
|
+
const key = parts[parts.length - 1];
|
|
66
|
+
const fn = parent[key];
|
|
67
|
+
try {
|
|
68
|
+
const result = await fn.apply(parent, args.callArgs);
|
|
69
|
+
return { ok: true, result };
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const e = err;
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: { code: e?.code, message: e?.message ?? String(err), data: e?.data },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* #140 — the `BridgeTransport` `page.evaluate` implementation. This is the
|
|
81
|
+
* ONE place a `Page` is ever touched to drive `window.__vgai` (module doc
|
|
82
|
+
* above); `RelayTransport` (`relay-transport.ts`) is the sibling
|
|
83
|
+
* implementation for the live editor session, and imports no Playwright at
|
|
84
|
+
* all. Exported so `fixture.ts` (which already imports `@playwright/test`
|
|
85
|
+
* for its own `page.goto`/error-listener setup) can construct one.
|
|
86
|
+
*/
|
|
87
|
+
export class PageTransport {
|
|
88
|
+
page;
|
|
89
|
+
constructor(page) {
|
|
90
|
+
this.page = page;
|
|
91
|
+
}
|
|
92
|
+
async call(method, callArgs) {
|
|
93
|
+
return this.page.evaluate(bridgeCallInPage, { method, callArgs });
|
|
94
|
+
}
|
|
95
|
+
async callAsync(method, callArgs) {
|
|
96
|
+
return this.page.evaluate(bridgeCallInPageAsync, { method, callArgs });
|
|
97
|
+
}
|
|
98
|
+
async isHidden() {
|
|
99
|
+
return this.page.evaluate(() => document.hidden);
|
|
100
|
+
}
|
|
101
|
+
async bringToFront() {
|
|
102
|
+
await this.page.bringToFront();
|
|
103
|
+
}
|
|
104
|
+
async screenshot(path) {
|
|
105
|
+
await mkdir(dirname(path), { recursive: true });
|
|
106
|
+
await this.page.screenshot({ path });
|
|
107
|
+
}
|
|
108
|
+
/** Wave-2: the ONE transport that runs a `game.page()` step against a REAL
|
|
109
|
+
* Playwright `Page` — no serialization, so `step`'s own closures work
|
|
110
|
+
* here (see `bridge-transport.ts`'s `runPageScript` doc comment for the
|
|
111
|
+
* full honesty-boundary contract; `src` is unused on this leg, kept only
|
|
112
|
+
* to satisfy the shared interface). */
|
|
113
|
+
async runPageScript(_src, step) {
|
|
114
|
+
try {
|
|
115
|
+
const result = await step(this.page);
|
|
116
|
+
return { ok: true, result };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: { code: undefined, message: err instanceof Error ? err.message : String(err) },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A relay-transport call that overran its async-invoke timeout budget
|
|
128
|
+
* surfaces as a `RELAY_UNREACHABLE` `SessionError` whose message names the
|
|
129
|
+
* timeout (`relay-transport.ts`'s `postCommand` uses `AbortSignal.timeout`,
|
|
130
|
+
* and Node's fetch rejects an aborted-by-timeout signal with a message
|
|
131
|
+
* containing "timeout") — a genuine network-down failure gets the SAME code
|
|
132
|
+
* but never mentions timeout, so this narrows on the message text, not just
|
|
133
|
+
* the code, before deciding to append the split-the-hold hint. Appends
|
|
134
|
+
* rather than replaces so the underlying transport message stays visible.
|
|
135
|
+
* `action` is whatever the caller passed to `hold()` (a single name or an
|
|
136
|
+
* array of them) — `JSON.stringify` renders either shape sensibly in the
|
|
137
|
+
* hint.
|
|
138
|
+
*/
|
|
139
|
+
function rethrowIfHoldTimedOut(err, action, simSeconds) {
|
|
140
|
+
if (err instanceof SessionError &&
|
|
141
|
+
err.code === 'RELAY_UNREACHABLE' &&
|
|
142
|
+
/timeout/i.test(err.message)) {
|
|
143
|
+
throw new SessionError(err.code, `${err.message}\nhint: game.input.hold(${JSON.stringify(action)}, { simSeconds: ${simSeconds} }) ` +
|
|
144
|
+
"may have exceeded the relay transport's async-invoke timeout budget — split it into " +
|
|
145
|
+
'multiple shorter game.input.hold() calls instead of one long hold', err.data);
|
|
146
|
+
}
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
/** {@link GameInput.tap}'s budget: one fixed tick at the default 60Hz
|
|
150
|
+
* timestep. `waitForHoldBudget` resolves on the FIRST reading that covers
|
|
151
|
+
* the budget, so this asks for the shortest press a tick can actually
|
|
152
|
+
* service — never a wall-clock duration. A project running a non-default
|
|
153
|
+
* fixed timestep simply covers it in its own single (longer) tick. */
|
|
154
|
+
const TAP_SIM_SECONDS = 1 / 60;
|
|
155
|
+
export class GameInput {
|
|
156
|
+
client;
|
|
157
|
+
constructor(client) {
|
|
158
|
+
this.client = client;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Holds one action, or SIMULTANEOUSLY holds several — `hold('jump')` and
|
|
162
|
+
* `hold(['move_right', 'move_forward'])` (the natural way to drive a
|
|
163
|
+
* diagonal) are both first-class. `action` is deliberately typed as
|
|
164
|
+
* `string | string[]`, never just `string`: a bare JS array silently
|
|
165
|
+
* stringifies to a comma-joined name (`['move_right','move_forward']` →
|
|
166
|
+
* `"move_right,move_forward"`) wherever it crosses a template literal or
|
|
167
|
+
* the bridge's JSON boundary, which used to surface as a baffling
|
|
168
|
+
* `unknown action "move_right,move_forward"` — the array was accepted
|
|
169
|
+
* structurally (nothing rejected it) and then silently mangled instead.
|
|
170
|
+
* Accepting the array for real, instead of merely rejecting it, is the
|
|
171
|
+
* useful behavior: every 2D/3D mover needs "hold two directions at once"
|
|
172
|
+
* as its ordinary case, not an edge case.
|
|
173
|
+
*
|
|
174
|
+
* Single action: one `holdFor` bridge call (`runtime/debug-bridge.ts`)
|
|
175
|
+
* instead of the old set → `waitSimTime`'s 150ms-interval snapshot poll
|
|
176
|
+
* loop → clear (15+ transport round trips over the editor relay for a
|
|
177
|
+
* multi-second hold). The wait uses ordinary host-loop ticks while visible.
|
|
178
|
+
* If the browser has hidden-paused that loop, the bridge drives the held
|
|
179
|
+
* action through the same game phases with deterministic ticks; this
|
|
180
|
+
* collapses transport cost without letting a background tab deadlock it.
|
|
181
|
+
*
|
|
182
|
+
* A relay call has a bounded async-invoke timeout budget (see
|
|
183
|
+
* `relay-transport.ts`'s `INVOKE_TIMEOUT_MS`) — a `simSeconds` long enough
|
|
184
|
+
* to exceed it at worst-case sim speed is NOT silently truncated; the
|
|
185
|
+
* transport timeout is caught and rethrown with a hint to split the hold
|
|
186
|
+
* into multiple shorter `hold()` calls instead.
|
|
187
|
+
*
|
|
188
|
+
* Multiple actions: `holdFor` is a single-action bridge primitive, so this
|
|
189
|
+
* presses every action (`setVirtualAction(action, true)`, in order),
|
|
190
|
+
* waits the shared `simSeconds` once via `waitSimTime`, and releases every
|
|
191
|
+
* action it managed to press — in a `finally`, so a gated press (a
|
|
192
|
+
* `delivered: false` result throws `INPUT_GATED` immediately) or a stalled
|
|
193
|
+
* clock during the wait still leaves no action stuck held.
|
|
194
|
+
*/
|
|
195
|
+
async hold(action, budget) {
|
|
196
|
+
// `hold('left', 0.5)` would otherwise send `undefined` sim-seconds down the
|
|
197
|
+
// `holdFor` bridge call — the same options-object-only contract as
|
|
198
|
+
// `waitSimTime`, named for the method the caller actually wrote.
|
|
199
|
+
assertValidWaitForBudget(budget, 'input.hold');
|
|
200
|
+
const actions = Array.isArray(action) ? action : [action];
|
|
201
|
+
if (actions.length === 0) {
|
|
202
|
+
throw new Error('game.input.hold: action array must not be empty');
|
|
203
|
+
}
|
|
204
|
+
if (actions.length === 1) {
|
|
205
|
+
const single = actions[0];
|
|
206
|
+
let result;
|
|
207
|
+
try {
|
|
208
|
+
result = await this.client.callBridgeAsync('holdFor', single, budget.simSeconds);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
throw rethrowIfHoldTimedOut(err, single, budget.simSeconds);
|
|
212
|
+
}
|
|
213
|
+
if (!result.delivered)
|
|
214
|
+
throw inputGatedError(single, result.reason);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const pressed = [];
|
|
218
|
+
try {
|
|
219
|
+
for (const single of actions) {
|
|
220
|
+
const result = await this.client.callBridge('input.setVirtualAction', single, true);
|
|
221
|
+
if (!result.delivered)
|
|
222
|
+
throw inputGatedError(single, result.reason);
|
|
223
|
+
pressed.push(single);
|
|
224
|
+
}
|
|
225
|
+
await this.client.waitSimTime(budget);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
for (const single of pressed) {
|
|
229
|
+
await this.client
|
|
230
|
+
.callBridge('input.setVirtualAction', single, false)
|
|
231
|
+
.catch(() => {
|
|
232
|
+
// Best-effort release — a failure here must never mask whatever
|
|
233
|
+
// the try block itself threw (or replace a clean success with a
|
|
234
|
+
// spurious one), and there is nothing more this call can do
|
|
235
|
+
// about a bridge/session that is no longer reachable.
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Keep one or more honest game actions pressed while `observe` runs, then
|
|
242
|
+
* release every action in a `finally` block. This is the evidence-oriented
|
|
243
|
+
* sibling of {@link hold}: `hold()` resolves after release, which is ideal
|
|
244
|
+
* for asserting distance travelled but cannot capture a moving pose or read
|
|
245
|
+
* transient "currently sprinting" state. `whileHeld()` makes that interval
|
|
246
|
+
* explicit without asking tests to hand-roll unsafe set/clear cleanup.
|
|
247
|
+
*
|
|
248
|
+
* The callback uses the same `GameClient` instance already in the test, so
|
|
249
|
+
* it may call `game.waitFor`, `game.state`, `game.screenshot`, or any other
|
|
250
|
+
* ordinary observation. No state is fabricated and the actions still enter
|
|
251
|
+
* through `input.setVirtualAction`/the project's real input map.
|
|
252
|
+
*/
|
|
253
|
+
async whileHeld(action, observe) {
|
|
254
|
+
const actions = Array.isArray(action) ? action : [action];
|
|
255
|
+
if (actions.length === 0) {
|
|
256
|
+
throw new Error('game.input.whileHeld: action array must not be empty');
|
|
257
|
+
}
|
|
258
|
+
const pressed = [];
|
|
259
|
+
try {
|
|
260
|
+
for (const single of actions) {
|
|
261
|
+
const result = await this.client.callBridge('input.setVirtualAction', single, true);
|
|
262
|
+
if (!result.delivered)
|
|
263
|
+
throw inputGatedError(single, result.reason);
|
|
264
|
+
pressed.push(single);
|
|
265
|
+
}
|
|
266
|
+
return await observe();
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
for (const single of pressed) {
|
|
270
|
+
await this.client
|
|
271
|
+
.callBridge('input.setVirtualAction', single, false)
|
|
272
|
+
.catch(() => {
|
|
273
|
+
// Best-effort release: preserve the callback/gating failure while
|
|
274
|
+
// never leaving an action held merely because teardown lost the
|
|
275
|
+
// relay.
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* One honest one-shot press — pressed, carried through AT LEAST ONE FULL
|
|
282
|
+
* FIXED TICK, then released, as a single bridge call.
|
|
283
|
+
*
|
|
284
|
+
* The contract is deliberately "a tick services it", not "the bridge
|
|
285
|
+
* accepted it", because the raw primitive underneath (`InputManager.
|
|
286
|
+
* tapVirtualAction`) only QUEUES: `poll()` promotes the queue to that tick's
|
|
287
|
+
* just-pressed set, and nothing else ever does. On a visible tab a tick
|
|
288
|
+
* lands ~16ms later so the difference is invisible; on a HIDDEN tab the host
|
|
289
|
+
* loop is stopped outright, so a queue-only tap sat unserviced and then died
|
|
290
|
+
* — silently, having already reported `delivered: true` (measured:
|
|
291
|
+
* `input.trace: { ticks: [] }`, no event, while `whileHeld` on the same
|
|
292
|
+
* action worked every time). Routing through the `holdFor` primitive is what
|
|
293
|
+
* makes the press TICK-ALIGNED by construction: that call owns the wait, and
|
|
294
|
+
* its hidden-tab leg drives the tick deterministically
|
|
295
|
+
* (`runtime/debug-bridge.ts`'s `waitForHoldBudget`), so a tap can no longer
|
|
296
|
+
* fall between ticks on any transport or any tab state.
|
|
297
|
+
*
|
|
298
|
+
* A gated action still fails loudly and immediately with `INPUT_GATED`,
|
|
299
|
+
* exactly as {@link hold} does — same `delivered:false` contract, same
|
|
300
|
+
* error.
|
|
301
|
+
*/
|
|
302
|
+
async tap(action) {
|
|
303
|
+
const result = await this.client.callBridgeAsync('holdFor', action, TAP_SIM_SECONDS);
|
|
304
|
+
if (!result.delivered)
|
|
305
|
+
throw inputGatedError(action, result.reason);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Sets one action, or the SAME value on several at once — same
|
|
309
|
+
* `string | string[]` shape as {@link hold}, and for the same reason: a
|
|
310
|
+
* bare array used to silently stringify into a single bogus action name
|
|
311
|
+
* instead of being rejected or honored.
|
|
312
|
+
*/
|
|
313
|
+
async set(action, value) {
|
|
314
|
+
const actions = Array.isArray(action) ? action : [action];
|
|
315
|
+
if (actions.length === 0) {
|
|
316
|
+
throw new Error('game.input.set: action array must not be empty');
|
|
317
|
+
}
|
|
318
|
+
for (const single of actions) {
|
|
319
|
+
const result = await this.client.callBridge('input.setVirtualAction', single, value);
|
|
320
|
+
if (!result.delivered)
|
|
321
|
+
throw inputGatedError(single, result.reason);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
export class GameEvents {
|
|
326
|
+
client;
|
|
327
|
+
constructor(client) {
|
|
328
|
+
this.client = client;
|
|
329
|
+
}
|
|
330
|
+
async expect(names, opts) {
|
|
331
|
+
// Run-4 friction #5: fence on `fenceSeq` (unambiguous — see its doc
|
|
332
|
+
// comment on `GameClientOptions`), not `fenceTick`. The old
|
|
333
|
+
// `snapshot(fenceTick)` call used the tick-based filter, which silently
|
|
334
|
+
// dropped an event emitted at the fence tick itself — exactly what a
|
|
335
|
+
// debug-command handler running right at test start produces.
|
|
336
|
+
const snapshot = await this.client.snapshot(this.client.fenceSeq);
|
|
337
|
+
// M9: pass the REAL fence tick (the test's own start), not
|
|
338
|
+
// `snapshot.events[0].tick` — see events-matcher.ts's doc comment.
|
|
339
|
+
const result = matchEventsSubsequence(names, snapshot.events, this.client.fenceTick, opts);
|
|
340
|
+
if (result.matched)
|
|
341
|
+
return;
|
|
342
|
+
const providers = await this.client.providers();
|
|
343
|
+
// D5: real deltas off the fence, not the absolute (since-boot)
|
|
344
|
+
// simSeconds + a hardcoded wallElapsedMs: 0 — the pre-fix code fabricated
|
|
345
|
+
// a "sim speed 0.00x — loop stalled" reading on every single
|
|
346
|
+
// events.expect failure regardless of actual game health.
|
|
347
|
+
const block = assembleFailureBlock({
|
|
348
|
+
headline: `game.events.expect failed: expected ${JSON.stringify(names)} as an ${opts?.exact ? 'exact' : 'ordered subsequence'} of events since test start`,
|
|
349
|
+
simElapsedSeconds: snapshot.time.simSeconds - this.client.fenceSimSeconds,
|
|
350
|
+
wallElapsedMs: Date.now() - this.client.fenceWallMs,
|
|
351
|
+
tick: snapshot.time.tick,
|
|
352
|
+
predicateSource: describeEventsExpectation(names, opts),
|
|
353
|
+
providers,
|
|
354
|
+
lastState: snapshot.state,
|
|
355
|
+
lastEvents: snapshot.events,
|
|
356
|
+
screenshotPath: await this.client.screenshot('events-expect-failure').catch(() => null),
|
|
357
|
+
consoleErrors: this.client.consoleErrors,
|
|
358
|
+
pageErrors: this.client.pageErrors,
|
|
359
|
+
recoveryNotices: this.client.recoveryNotices,
|
|
360
|
+
// Issue #175: same hidden-tab diagnosis toSessionFailure gets below —
|
|
361
|
+
// an events.expect failure deserves the same honesty about WHY the
|
|
362
|
+
// clock looks frozen.
|
|
363
|
+
loopLiveness: snapshot.time.loopLiveness,
|
|
364
|
+
});
|
|
365
|
+
throw new SessionFailure('EVENTS_EXPECT_FAILED', block);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* The `game` fixture value. Every method reaches `window.__vgai` (or its
|
|
370
|
+
* relay-side equivalent) through `this.#transport` — see `bridge-transport.ts`'s
|
|
371
|
+
* module doc for the seam, and this file's own module doc for why it, alone,
|
|
372
|
+
* is allowed to import Playwright (via `PageTransport`, above).
|
|
373
|
+
*/
|
|
374
|
+
export class GameClient {
|
|
375
|
+
input = new GameInput(this);
|
|
376
|
+
events = new GameEvents(this);
|
|
377
|
+
fenceTick;
|
|
378
|
+
/** Run-4 friction #5: see `GameClientOptions.fenceSeq`. */
|
|
379
|
+
fenceSeq;
|
|
380
|
+
/** D5: see `GameClientOptions.fenceSimSeconds`. */
|
|
381
|
+
fenceSimSeconds;
|
|
382
|
+
/** D5: see `GameClientOptions.fenceWallMs`. */
|
|
383
|
+
fenceWallMs;
|
|
384
|
+
pageErrors;
|
|
385
|
+
consoleErrors;
|
|
386
|
+
/** M13: hidden-tab recovery's own structured notices — kept SEPARATE from
|
|
387
|
+
* `consoleErrors` (see `hidden-recovery.ts`'s hook below and
|
|
388
|
+
* `failure-block.ts`'s `recoveryNotices` member). */
|
|
389
|
+
recoveryNotices = [];
|
|
390
|
+
// Everything below is `#`-PRIVATE, not `private` — deliberately, and the
|
|
391
|
+
// difference is observable. TypeScript's `private` is erased at compile
|
|
392
|
+
// time: `this.transport = …` leaves an ordinary own property that
|
|
393
|
+
// `Object.getOwnPropertyNames(game)` reports and `game.transport.call(…)`
|
|
394
|
+
// happily invokes. `vgai eval --list` enumerates this object's REAL members
|
|
395
|
+
// (it must — `input`/`events` are instance fields no prototype walk can
|
|
396
|
+
// see), so an erased-private field is a plumbing detail advertised to every
|
|
397
|
+
// agent as a door, right next to the doors it should actually use. `#` is
|
|
398
|
+
// the only privacy the runtime enforces, so it is the only privacy an
|
|
399
|
+
// introspecting listing can respect.
|
|
400
|
+
#transport;
|
|
401
|
+
/** Where a labelled `screenshot()` lands — project-scoped by `@vgai/live`, cwd-relative otherwise. Public: a caller reading it is asking a fair question, and `screenshot()` returns a path under it anyway. */
|
|
402
|
+
artifactsDir;
|
|
403
|
+
/** See `GameClientOptions.warmSession`. */
|
|
404
|
+
#warmSession;
|
|
405
|
+
/** See `GameClientOptions.testTitle`. */
|
|
406
|
+
#testTitle;
|
|
407
|
+
#screenshotCounter = 0;
|
|
408
|
+
/** Per-test tick-rate samples, fed by every `snapshot()` read (a poll the
|
|
409
|
+
* client was making anyway — zero extra page.evaluate round trips). */
|
|
410
|
+
#tps = new TpsAccumulator();
|
|
411
|
+
/** Hidden-tab recovery (hollowstone field lesson: the engine hard-stops
|
|
412
|
+
* while `document.hidden`). Client-lifetime state so `bringToFront()`
|
|
413
|
+
* fires at most once per test, across ALL waitFor/waitSimTime loops. */
|
|
414
|
+
#hiddenRecovery;
|
|
415
|
+
/** run-3 friction #2 — throttled "a bridge call is flowing" liveness
|
|
416
|
+
* heartbeat (see bridge-heartbeat.ts's module doc), client-lifetime so
|
|
417
|
+
* the 30s throttle applies across the whole test, not per call site. */
|
|
418
|
+
#bridgeHeartbeat;
|
|
419
|
+
constructor(opts) {
|
|
420
|
+
this.#transport = opts.transport;
|
|
421
|
+
this.pageErrors = opts.pageErrors;
|
|
422
|
+
this.consoleErrors = opts.consoleErrors;
|
|
423
|
+
this.fenceTick = opts.fenceTick;
|
|
424
|
+
this.fenceSeq = opts.fenceSeq;
|
|
425
|
+
this.fenceSimSeconds = opts.fenceSimSeconds;
|
|
426
|
+
this.fenceWallMs = opts.fenceWallMs;
|
|
427
|
+
this.artifactsDir = opts.artifactsDir ?? resolve('.vgai/last-run');
|
|
428
|
+
this.#warmSession = opts.warmSession ?? false;
|
|
429
|
+
this.#testTitle = opts.testTitle ?? 'test';
|
|
430
|
+
this.#bridgeHeartbeat = { lastEmitWallMs: Date.now() };
|
|
431
|
+
this.#hiddenRecovery = new HiddenRecoveryDriver({
|
|
432
|
+
sampleHidden: () => this.#transport.isHidden(),
|
|
433
|
+
bringToFront: () => this.#transport.bringToFront(),
|
|
434
|
+
log: (line) => {
|
|
435
|
+
// M13: structured line on stdout AND into its OWN recoveryNotices
|
|
436
|
+
// collection — NOT consoleErrors, so a real console error and a
|
|
437
|
+
// benign "we recovered a backgrounded tab" notice never conflate in
|
|
438
|
+
// the failure block's console/page-errors section.
|
|
439
|
+
console.log(line);
|
|
440
|
+
this.recoveryNotices.push(line);
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
async state(name) {
|
|
445
|
+
return this.callBridge('state', name);
|
|
446
|
+
}
|
|
447
|
+
async command(name, ...args) {
|
|
448
|
+
return this.callBridgeAsync('invoke', name, args);
|
|
449
|
+
}
|
|
450
|
+
async providers() {
|
|
451
|
+
return this.callBridge('providers');
|
|
452
|
+
}
|
|
453
|
+
async commands() {
|
|
454
|
+
return this.callBridge('commands');
|
|
455
|
+
}
|
|
456
|
+
async snapshot(sinceSeq) {
|
|
457
|
+
const snap = await this.callBridge('snapshot', sinceSeq);
|
|
458
|
+
// Both hardening seams hang off the read every poll loop already makes:
|
|
459
|
+
// one tick-rate sample per snapshot, and one hidden-tab-recovery
|
|
460
|
+
// observation (which only pays an extra evaluate once the tick has been
|
|
461
|
+
// frozen for HIDDEN_RECOVERY_STALL_POLLS consecutive reads).
|
|
462
|
+
this.#tps.record(snap.time.tick, Date.now());
|
|
463
|
+
await this.#hiddenRecovery.observeTick(snap.time.tick);
|
|
464
|
+
return snap;
|
|
465
|
+
}
|
|
466
|
+
/** Fixture-teardown perf read: p50/p95 effective ticks-per-second over all
|
|
467
|
+
* the snapshot polls this test performed. */
|
|
468
|
+
tpsStats() {
|
|
469
|
+
return this.#tps.stats();
|
|
470
|
+
}
|
|
471
|
+
/** True if the hidden-tab recovery fired (page.bringToFront was called). */
|
|
472
|
+
hiddenRecoveryTriggered() {
|
|
473
|
+
return this.#hiddenRecovery.wasTriggered();
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* D15/T-D15.4 — synchronously drives `budget` worth of sim time/ticks via
|
|
477
|
+
* the live `Game`'s `runTicks` (through the debug bridge), instead of
|
|
478
|
+
* waiting for real wall-clock time to pass. Doctrine (see
|
|
479
|
+
* `fast-forward.ts`'s module doc, which also documents the two honesty
|
|
480
|
+
* decisions this wraps): SETUP/STAGING traversal — reaching a known
|
|
481
|
+
* late-game state fast — not a substitute for real-input proofs, which
|
|
482
|
+
* still run in real ticks. Returns the final `{time, state, events,
|
|
483
|
+
* pageErrors}` snapshot (a completely ordinary `snapshot()` read, taken
|
|
484
|
+
* AFTER the tps baseline reset below, so it never itself corrupts the tps
|
|
485
|
+
* stats either).
|
|
486
|
+
*/
|
|
487
|
+
async fastForward(budget, opts) {
|
|
488
|
+
// Same options-object-only contract as `waitSimTime` (`ticksForBudget`
|
|
489
|
+
// would otherwise fail on `'simTicks' in 0.5` with a raw TypeError that
|
|
490
|
+
// names neither the method nor the shape).
|
|
491
|
+
assertValidWaitForBudget(budget, 'fastForward');
|
|
492
|
+
const clock = {
|
|
493
|
+
runTicksBatch: (n, render) => this.callBridgeVoid('runTicks', n, { render }),
|
|
494
|
+
readTime: async () => {
|
|
495
|
+
// Raw bridge read — deliberately NOT `this.snapshot()`, which would
|
|
496
|
+
// feed the TpsAccumulator (see fast-forward.ts's module doc, point 2).
|
|
497
|
+
const snap = await this.callBridge('snapshot');
|
|
498
|
+
return { tick: snap.time.tick, simSeconds: snap.time.simSeconds };
|
|
499
|
+
},
|
|
500
|
+
heartbeat: (info) => {
|
|
501
|
+
console.log(`vgai fastForward: ${info.ticksDone}/${info.ticksTotal} ticks driven`);
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
await runFastForward(budget, opts ?? {}, clock);
|
|
505
|
+
// The burst is over — reset the baseline so the very next ordinary poll
|
|
506
|
+
// (including the `snapshot()` call right below) treats itself as a fresh
|
|
507
|
+
// "first observation" rather than diffing across the burst's enormous
|
|
508
|
+
// tick delta over a near-zero wall delta.
|
|
509
|
+
this.#tps.resetBaseline();
|
|
510
|
+
return this.snapshot();
|
|
511
|
+
}
|
|
512
|
+
async waitFor(pred, budget) {
|
|
513
|
+
try {
|
|
514
|
+
await runWaitFor(pred, budget, {
|
|
515
|
+
snapshot: () => this.snapshot(),
|
|
516
|
+
now: () => Date.now(),
|
|
517
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
518
|
+
// Fixture heartbeat (Wave-6 findings ledger) — real stdout, the
|
|
519
|
+
// same channel a caller watching stdout already treats as
|
|
520
|
+
// liveness (see wait-for.ts's module doc for the emission
|
|
521
|
+
// invariant `maybeHeartbeat` enforces).
|
|
522
|
+
log: (line) => console.log(line),
|
|
523
|
+
}, this.#testTitle);
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
if (err instanceof WaitForTimeoutError)
|
|
527
|
+
throw await this.toSessionFailure(err);
|
|
528
|
+
throw err;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/** Used by `input.hold` — waits for a sim-time delta to pass with no
|
|
532
|
+
* predicate (a predicate that is never true would misreport as a
|
|
533
|
+
* waitFor failure, so this is its own tiny loop, not `runWaitFor` with a
|
|
534
|
+
* false predicate).
|
|
535
|
+
*
|
|
536
|
+
* D2: this loop has the SAME frozen-clock bug `runWaitFor` had — with no
|
|
537
|
+
* predicate at all, a stalled sim clock would poll forever, since
|
|
538
|
+
* `current.time.simSeconds` would never advance. Carries the identical
|
|
539
|
+
* stall guard (`WAIT_FOR_STALL_POLL_LIMIT` consecutive polls with an
|
|
540
|
+
* unchanged tick — see wait-for.ts's doc comment for the rationale),
|
|
541
|
+
* throwing the same `WaitForTimeoutError` shape so it renders through the
|
|
542
|
+
* same honest failure block as an ordinary `waitFor`/`waitSimTime` budget
|
|
543
|
+
* exhaustion. */
|
|
544
|
+
async waitSimTime(budget) {
|
|
545
|
+
// A positional budget (`waitSimTime(0.5)`) reads `undefined` here and the
|
|
546
|
+
// exit comparison below is then false FOREVER — and against a hidden tab
|
|
547
|
+
// the client drives its own ticks, so the frozen-clock stall guard never
|
|
548
|
+
// fires either. Refuse in the caller's own vocabulary instead of hanging.
|
|
549
|
+
assertValidWaitForBudget(budget, 'waitSimTime');
|
|
550
|
+
const startWall = Date.now();
|
|
551
|
+
const start = await this.snapshot();
|
|
552
|
+
let lastTick = null;
|
|
553
|
+
let stalledPolls = 0;
|
|
554
|
+
// Fixture heartbeat (Wave-6 findings ledger) — same invariant as
|
|
555
|
+
// `wait-for.ts`'s `runWaitFor`: a heartbeat requires BOTH 60s of wall
|
|
556
|
+
// silence AND the tick having advanced since the last one emitted, so a
|
|
557
|
+
// genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
|
|
558
|
+
// goes heartbeat-silent well before this loop's own guard ever needs to.
|
|
559
|
+
let heartbeat = { lastEmitWallMs: startWall, lastEmitTick: start.time.tick };
|
|
560
|
+
for (;;) {
|
|
561
|
+
const current = await this.snapshot();
|
|
562
|
+
if (current.time.simSeconds - start.time.simSeconds >= budget.simSeconds)
|
|
563
|
+
return;
|
|
564
|
+
stalledPolls = lastTick !== null && current.time.tick === lastTick ? stalledPolls + 1 : 0;
|
|
565
|
+
lastTick = current.time.tick;
|
|
566
|
+
if (stalledPolls >= WAIT_FOR_STALL_POLL_LIMIT) {
|
|
567
|
+
throw await this.toSessionFailure(new WaitForTimeoutError({
|
|
568
|
+
budget,
|
|
569
|
+
startSnapshot: start,
|
|
570
|
+
lastSnapshot: current,
|
|
571
|
+
wallElapsedMs: Date.now() - startWall,
|
|
572
|
+
predicateSource: '(no predicate — game.input.hold is waiting for a sim-time delta to pass)',
|
|
573
|
+
touchedProviders: [],
|
|
574
|
+
}));
|
|
575
|
+
}
|
|
576
|
+
const heartbeatResult = maybeHeartbeat({
|
|
577
|
+
nowMs: Date.now(),
|
|
578
|
+
tick: current.time.tick,
|
|
579
|
+
simSeconds: current.time.simSeconds,
|
|
580
|
+
testTitle: this.#testTitle,
|
|
581
|
+
state: heartbeat,
|
|
582
|
+
});
|
|
583
|
+
heartbeat = heartbeatResult.state;
|
|
584
|
+
if (heartbeatResult.line)
|
|
585
|
+
console.log(heartbeatResult.line);
|
|
586
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Capture the running game to disk and return the ABSOLUTE path written.
|
|
591
|
+
*
|
|
592
|
+
* `labelOrPath` is read as a LABEL when it is a bare identifier
|
|
593
|
+
* (`'waitfor-timeout'`) — numbered into the artifacts directory, the
|
|
594
|
+
* long-standing spec behaviour — and as a DESTINATION PATH the moment it
|
|
595
|
+
* carries a separator or a file extension, in which case the bytes land
|
|
596
|
+
* exactly there (relative to `process.cwd()`). See
|
|
597
|
+
* `screenshot-target.ts`'s header for the live incident that made this
|
|
598
|
+
* distinction mandatory: a caller naming a path used to get "success" and
|
|
599
|
+
* an empty path.
|
|
600
|
+
*/
|
|
601
|
+
async screenshot(labelOrPath) {
|
|
602
|
+
const target = resolveScreenshotTarget({
|
|
603
|
+
arg: labelOrPath,
|
|
604
|
+
artifactsDir: this.artifactsDir,
|
|
605
|
+
sequence: this.#screenshotCounter + 1,
|
|
606
|
+
cwd: process.cwd(),
|
|
607
|
+
});
|
|
608
|
+
if (target.consumedSequence)
|
|
609
|
+
this.#screenshotCounter += 1;
|
|
610
|
+
await mkdir(dirname(target.path), { recursive: true });
|
|
611
|
+
await this.#transport.screenshot(target.path);
|
|
612
|
+
return target.path;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Wave-2 "one dialect, full capability" — runs a UI-automation step
|
|
616
|
+
* written as a literal Playwright `async (page) => {...}` (interface
|
|
617
|
+
* doctrine §3.2/§4 rung 4: "the AI should think it is basically just
|
|
618
|
+
* executing Playwright"). Under `PageTransport` this drives the REAL
|
|
619
|
+
* `Page` (real closures, real hit-testing); under `RelayTransport` it
|
|
620
|
+
* posts the step's own `toString()` source to the editor dev server's
|
|
621
|
+
* `page-script` op, which reconstructs it in-page against
|
|
622
|
+
* `playwright-shim.ts`'s in-page shim (`isTrusted: false` synthetic
|
|
623
|
+
* DOM events, no real hit testing). This is the honest input path for a
|
|
624
|
+
* React-only DOM game; canvas gameplay continues to use `game.input.*`.
|
|
625
|
+
* Write specs as if ALWAYS serialized — inline every value the step needs,
|
|
626
|
+
* never close over imported `expect`/helpers/outer variables, and return
|
|
627
|
+
* observations to assert outside the callback. See `bridge-transport.ts`'s
|
|
628
|
+
* `runPageScript` doc comment for the full contract this method wraps.
|
|
629
|
+
*/
|
|
630
|
+
async page(step) {
|
|
631
|
+
const erased = (arg) => step(arg);
|
|
632
|
+
const outcome = await this.#transport.runPageScript(step.toString(), erased);
|
|
633
|
+
return this.unwrap(outcome);
|
|
634
|
+
}
|
|
635
|
+
async toSessionFailure(err) {
|
|
636
|
+
const providers = await this.providers();
|
|
637
|
+
const screenshotPath = await this.screenshot('waitfor-timeout').catch(() => null);
|
|
638
|
+
const block = assembleFailureBlock({
|
|
639
|
+
headline: err.message,
|
|
640
|
+
simElapsedSeconds: err.info.lastSnapshot.time.simSeconds - err.info.startSnapshot.time.simSeconds,
|
|
641
|
+
wallElapsedMs: err.info.wallElapsedMs,
|
|
642
|
+
tick: err.info.lastSnapshot.time.tick,
|
|
643
|
+
predicateSource: err.info.predicateSource,
|
|
644
|
+
providers,
|
|
645
|
+
touchedProviders: err.info.touchedProviders,
|
|
646
|
+
lastState: err.info.lastSnapshot.state,
|
|
647
|
+
lastEvents: err.info.lastSnapshot.events,
|
|
648
|
+
screenshotPath,
|
|
649
|
+
consoleErrors: this.consoleErrors,
|
|
650
|
+
pageErrors: this.pageErrors,
|
|
651
|
+
recoveryNotices: this.recoveryNotices,
|
|
652
|
+
// Issue #175 — "state must never claim health it cannot observe": a
|
|
653
|
+
// waitFor/waitSimTime timeout with a ~0x sim-speed ratio used to read
|
|
654
|
+
// as a generic "loop is stalled" no matter WHY the clock was frozen.
|
|
655
|
+
// `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
|
|
656
|
+
// before this failure fires (see this class's constructor) — if the
|
|
657
|
+
// loop is STILL hidden-paused here, recovery couldn't reach the tab
|
|
658
|
+
// (headless run, no-op bringToFront), and the failure block must say
|
|
659
|
+
// so explicitly rather than leaving an agent to guess "stalled" for a
|
|
660
|
+
// tab that is simply backgrounded.
|
|
661
|
+
loopLiveness: err.info.lastSnapshot.time.loopLiveness,
|
|
662
|
+
});
|
|
663
|
+
return new SessionFailure('WAIT_FOR_TIMEOUT', block);
|
|
664
|
+
}
|
|
665
|
+
/** run-3 friction #2 — called at the top of every bridge dispatch, before
|
|
666
|
+
* the await, so a burst of short calls (e.g. many `hold()`s) keeps
|
|
667
|
+
* printing throttled liveness even while individually near-silent. See
|
|
668
|
+
* bridge-heartbeat.ts's module doc for why this needs no sim-tick
|
|
669
|
+
* awareness the way `wait-for.ts`'s poll-loop heartbeat does. */
|
|
670
|
+
emitBridgeHeartbeatIfDue(method) {
|
|
671
|
+
const result = maybeBridgeHeartbeat({
|
|
672
|
+
nowMs: Date.now(),
|
|
673
|
+
method,
|
|
674
|
+
testTitle: this.#testTitle,
|
|
675
|
+
state: this.#bridgeHeartbeat,
|
|
676
|
+
});
|
|
677
|
+
this.#bridgeHeartbeat = result.state;
|
|
678
|
+
if (result.line)
|
|
679
|
+
console.log(result.line);
|
|
680
|
+
}
|
|
681
|
+
/** @internal exposed for GameInput/GameEvents in this module only. */
|
|
682
|
+
async callBridge(method, ...callArgs) {
|
|
683
|
+
this.emitBridgeHeartbeatIfDue(method);
|
|
684
|
+
const outcome = await this.#transport.call(method, callArgs);
|
|
685
|
+
return this.unwrap(outcome);
|
|
686
|
+
}
|
|
687
|
+
/** @internal like `callBridge`, but awaits the page-side call (for async
|
|
688
|
+
* bridge methods like `invoke`). */
|
|
689
|
+
async callBridgeAsync(method, ...callArgs) {
|
|
690
|
+
this.emitBridgeHeartbeatIfDue(method);
|
|
691
|
+
const outcome = await this.#transport.callAsync(method, callArgs);
|
|
692
|
+
return this.unwrap(outcome);
|
|
693
|
+
}
|
|
694
|
+
/** @internal void-returning convenience (clearVirtualActions). */
|
|
695
|
+
async callBridgeVoid(method, ...callArgs) {
|
|
696
|
+
await this.callBridge(method, ...callArgs);
|
|
697
|
+
}
|
|
698
|
+
unwrap(outcome) {
|
|
699
|
+
if (!outcome.ok) {
|
|
700
|
+
const error = outcome.error ?? { code: undefined, message: 'unknown bridge error' };
|
|
701
|
+
const message = appendWarmSessionHint(error.message, error.code, this.#warmSession);
|
|
702
|
+
throw new SessionError(error.code ?? 'DEBUG_COMMAND_FAILED', message, error.data);
|
|
703
|
+
}
|
|
704
|
+
return outcome.result;
|
|
705
|
+
}
|
|
706
|
+
}
|