gipity 1.0.428 → 1.0.429

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.
@@ -5,7 +5,7 @@ import { brand, bold, muted, warning, success } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
6
6
  import { getAuth } from '../auth.js';
7
7
  import { resolveProjectContext } from '../config.js';
8
- import { uploadPublicFixture, deleteFixture } from '../page-fixtures.js';
8
+ import { uploadPublicFixture, uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
9
9
  // Shown when an eval runs cleanly but returns nothing serializable. Turns a
10
10
  // bare/opaque `null` into a deterministic, actionable nudge so the agent shapes
11
11
  // a returnable value instead of guessing and retrying.
@@ -44,6 +44,52 @@ export function normalizeEvalResult(raw) {
44
44
  }
45
45
  return { result: raw, noValue: false };
46
46
  }
47
+ // Shown when a run returns a structurally empty value ({}, [], or an object whose
48
+ // every field is null/''). That is NOT the same as "no value": the script ran and
49
+ // read the page — it just read it at an instant where the state it wanted did not
50
+ // exist. A fixed --wait samples ONE moment, and transient state (a round result,
51
+ // a toast, a frame's detection) may have already been cleared by then, so the run
52
+ // looks like a pass and the agent has to reason its way to "I sampled too late".
53
+ export const EVAL_EMPTY_STATE_HINT = 'The eval returned an empty value — the script ran, but the state it read was not there at that instant. ' +
54
+ 'A fixed --wait samples ONE moment; transient state (a round result, a toast, a detection) can be gone by then. ' +
55
+ 'Poll INSIDE the body and return the moment the condition holds (raise --timeout if the sequence needs longer), ' +
56
+ "or gate the read on the app's own signal with --wait-for '<selector>'.";
57
+ // The one-run experiment that separates "the frame is unreadable" from "the app
58
+ // never routes frames to the model" — the ambiguity that otherwise costs a
59
+ // re-generate-and-re-run cycle per candidate image. Named once, reused by every
60
+ // camera-run message below so the caller is told the same thing wherever it
61
+ // notices something is off.
62
+ const CAMERA_FRAME_CHECK = "in the eval body, run the model on the frame YOURSELF (grab the app's <video> element, or the app's own "
63
+ + "detector on it) and return its RAW output alongside the app's state. Model saw nothing => the frame is at fault: "
64
+ + 'regenerate it (subject filling the frame, plain background, even light), do not re-run the same one. '
65
+ + "Model saw it but the app's state is empty => the bug is in the app's wiring, not the picture.";
66
+ // A camera run that comes back empty must NOT be sent down the generic
67
+ // "raise --timeout and poll harder" path below: on a looped still frame that is
68
+ // a guaranteed-identical re-run, and that escalation (60s, then 70s, then 80s)
69
+ // is the single most expensive way a vision app gets verified. Give it the
70
+ // verdict and the disambiguating experiment instead.
71
+ export const EVAL_CAMERA_EMPTY_HINT = 'The eval returned an empty value on a --camera run. --camera loops ONE still image, and the model is deterministic '
72
+ + 'on it, so a longer --wait/--timeout re-runs the identical inference and returns the identical nothing — do not escalate. '
73
+ + `Two suspects: the frame, or the app's wiring. Tell them apart in one run: ${CAMERA_FRAME_CHECK} `
74
+ + "(If the app only starts its pipeline on a click, a headless run never clicks — start it on load and gate this eval on "
75
+ + "the app's own ready signal with --wait-for '<selector>'.)";
76
+ /** True when the eval came back with a structurally empty container: `{}`, `[]`,
77
+ * or an object whose every value is null/undefined/''. Exported for tests. */
78
+ export function isEmptyStateResult(result) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(result);
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ if (Array.isArray(parsed))
87
+ return parsed.length === 0;
88
+ if (!parsed || typeof parsed !== 'object')
89
+ return false;
90
+ const values = Object.values(parsed);
91
+ return values.every((v) => v === null || v === undefined || v === '');
92
+ }
47
93
  // A one-line inline expr is worth echoing back — it's the thing you're asserting
48
94
  // on. A multi-line driver script is not: echoing 30 lines of the caller's own
49
95
  // source above the result buries the value, and an echo that lands right before
@@ -82,14 +128,158 @@ export function capWaitMs(rawWait, url) {
82
128
  `To watch an app that keeps changing past 30s, cover the span with staggered windows in one command: gipity page test "${url}" --clients N --stagger S.`));
83
129
  return MAX_WAIT_MS;
84
130
  }
131
+ // How many --step expressions may ride on one page load (mirrors the server's
132
+ // MAX_EVAL_STEPS). Past this, the run is really two verifications.
133
+ export const MAX_EVAL_STEPS = 4;
134
+ // Ceiling the server accepts for the --wait-for gate (its WAIT_FOR_MAX_MS).
135
+ export const WAIT_FOR_MAX_MS = 30_000;
136
+ /** Parse --wait-timeout, clamping to the gate's server-side ceiling. Over the cap
137
+ * the server answers with a zod 400 that names neither the flag nor the limit, so
138
+ * clamp and say so on stderr (--json stdout stays clean). Exported for tests. */
139
+ export function capWaitForTimeoutMs(raw) {
140
+ const parsed = parseInt(raw, 10);
141
+ const ms = Number.isFinite(parsed) && parsed >= 0 ? parsed : 5_000;
142
+ if (ms <= WAIT_FOR_MAX_MS)
143
+ return ms;
144
+ console.error(warning(`--wait-timeout ${ms}ms exceeds the ${WAIT_FOR_MAX_MS}ms cap on the selector gate — using ${WAIT_FOR_MAX_MS}ms. ` +
145
+ `A state that takes longer than that to appear is not a settling delay: have the page reach it on load, ` +
146
+ `or watch for it INSIDE the script (--timeout ${EVAL_SCRIPT_BUDGET_MAX_MS}).`));
147
+ return WAIT_FOR_MAX_MS;
148
+ }
149
+ /** True when `s` is an expression (`document.title`, an IIFE) rather than a
150
+ * statement body (`const x = …; return x`). Mirrors the server's own parse
151
+ * choice: only an expression may be spliced into `return (…)`. Nothing is
152
+ * executed — Function() parses and throws on a statement body. */
153
+ export function parsesAsExpression(s) {
154
+ try {
155
+ // eslint-disable-next-line no-new-func
156
+ new Function(`return (${s}\n);`);
157
+ return true;
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ }
163
+ /** Prepend a prelude (fixture bindings, a post-gate settle) to a caller script.
164
+ * A prelude makes the whole thing a statement body, so an expression script has
165
+ * to become `return (expr)` — a statement-body script is spliced as-is. */
166
+ export function withPrelude(script, prelude) {
167
+ if (!prelude)
168
+ return script;
169
+ return parsesAsExpression(script)
170
+ ? `${prelude}\nreturn (${script});`
171
+ : `${prelude}\n${script}`;
172
+ }
173
+ // A camera app's first useful frame is never 500ms away: getUserMedia has to
174
+ // come up, then the vision pipeline downloads and initializes its model (WASM +
175
+ // weights) before it can label anything. Evaluating at the default --wait always
176
+ // reads an empty/"loading" DOM, and pushing that warm-up into the eval body
177
+ // instead blows the in-page execution budget. So --camera raises the pre-eval
178
+ // window (which is NOT on the eval's clock) to something a model load fits in.
179
+ export const CAMERA_DEFAULT_WAIT_MS = 15_000;
180
+ // Playing a real frame into the browser's webcam costs the server real time
181
+ // before the page is even navigated: it fetches the hosted feed and encodes it
182
+ // into the synthetic capture device. That is server-side setup the caller cannot
183
+ // shorten, so it has to be inside the client's patience — otherwise the CLI
184
+ // abandons a job that was always going to take this long.
185
+ const CAMERA_SETUP_BUDGET_MS = 30_000;
186
+ // How long the script may run INSIDE the page. The server enforces this (it is
187
+ // the `timeoutMs` on the eval request) and picks the roomier default on a
188
+ // synthetic-media run, where a WASM/model download IS the wait. These mirror the
189
+ // server's EVAL_SCRIPT_BUDGET_* constants; --timeout names any value up to MAX.
190
+ export const EVAL_SCRIPT_BUDGET_MS = 30_000;
191
+ export const EVAL_SCRIPT_BUDGET_CAMERA_MS = 45_000;
192
+ export const EVAL_SCRIPT_BUDGET_MAX_MS = 90_000;
193
+ /** The in-page budget this call gets: the caller's --timeout when given, else the
194
+ * default for the kind of run (roomier under --camera/--fake-media). Clamped to
195
+ * the server's accepted range — a value over the max is clamped and explained on
196
+ * stderr rather than bounced back as an opaque 400. Exported for tests. */
197
+ export function capScriptBudgetMs(rawTimeout, hasMedia) {
198
+ const fallback = hasMedia ? EVAL_SCRIPT_BUDGET_CAMERA_MS : EVAL_SCRIPT_BUDGET_MS;
199
+ if (rawTimeout === undefined)
200
+ return fallback;
201
+ const parsed = parseInt(rawTimeout, 10);
202
+ if (!Number.isFinite(parsed) || parsed <= 0)
203
+ return fallback;
204
+ if (parsed > EVAL_SCRIPT_BUDGET_MAX_MS) {
205
+ console.error(warning(`--timeout ${parsed}ms exceeds the ${EVAL_SCRIPT_BUDGET_MAX_MS}ms max in-page budget — using ${EVAL_SCRIPT_BUDGET_MAX_MS}ms. ` +
206
+ `A body that needs longer than that is doing its waiting in the wrong place: start the slow work on page load and ` +
207
+ `absorb it in the pre-eval window (--wait-for '<ready-selector>'), keeping the body to a quick read.`));
208
+ return EVAL_SCRIPT_BUDGET_MAX_MS;
209
+ }
210
+ return Math.max(1_000, parsed);
211
+ }
212
+ /** Upper bound on the server-side work this eval asked for. EVERY leg the caller
213
+ * can lengthen has to be in here, or the client gives up on a job that is still
214
+ * legitimately running and reports it as the caller's fault:
215
+ * - the pre-eval window (--wait, and --wait-for's own timeout)
216
+ * - every script leg (<expr> plus each --step), each allowed its full in-page
217
+ * budget (--timeout) — they share ONE page load but not one clock
218
+ * - the reload leg, which repeats settle + eval
219
+ * - --camera's feed fetch/encode, paid before the page loads
220
+ * Browser cold-start/open overhead is NOT here; it's the flat headroom in
221
+ * pollEvalResult. Exported for tests. */
222
+ export function evalWorkBudgetMs(o) {
223
+ const script = o.scriptBudgetMs ?? (o.hasCamera ? EVAL_SCRIPT_BUDGET_CAMERA_MS : EVAL_SCRIPT_BUDGET_MS);
224
+ const legs = Math.max(1, o.legs ?? 1);
225
+ const settle = o.waitMs + (o.waitForTimeoutMs ?? 0);
226
+ const firstPass = settle + script * legs;
227
+ return firstPass + (o.hasReload ? settle + script : 0) + (o.hasCamera ? CAMERA_SETUP_BUDGET_MS : 0);
228
+ }
229
+ /** The server refuses a script that outran its in-page budget with a message that
230
+ * names the budget and says to "raise the eval timeout" — but not the flag that
231
+ * does it. Name it, with the value this run actually used, so the fix is one
232
+ * edit away instead of a guess (or a trip through --help). */
233
+ export function budgetOverrunHint(reason, usedBudgetMs) {
234
+ if (!/in-page budget/i.test(reason))
235
+ return null;
236
+ if (usedBudgetMs >= EVAL_SCRIPT_BUDGET_MAX_MS) {
237
+ return `This run already used the maximum in-page budget (--timeout ${EVAL_SCRIPT_BUDGET_MAX_MS}). ` +
238
+ `More time is not the answer: have the page start its slow work on load, wait it out in the pre-eval window ` +
239
+ `(--wait-for '<ready-selector>' --wait-timeout ${MAX_WAIT_MS}), and keep the body to a quick read.`;
240
+ }
241
+ return `That budget was ${Math.round(usedBudgetMs / 1000)}s. Raise it with --timeout <ms> ` +
242
+ `(up to ${EVAL_SCRIPT_BUDGET_MAX_MS}), e.g. --timeout ${EVAL_SCRIPT_BUDGET_MAX_MS} — ` +
243
+ `or gate on a ready signal instead: --wait-for '<selector>' --wait-timeout ${MAX_WAIT_MS}.`;
244
+ }
245
+ /** The headless browser paints slowly, and what that MEANS depends on what the
246
+ * page is doing — so the one-size warning was actively misleading on a --camera
247
+ * run. A vision app has no loop to step: its pipeline is driven by camera
248
+ * frames, one inference per painted frame.
249
+ *
250
+ * The trap this message exists to close: a slow-render warning next to a "no
251
+ * detection" result reads as "the renderer starved you", so the caller re-runs
252
+ * with a bigger --wait/--timeout, several times, and gets the identical answer
253
+ * each time. It cannot be otherwise. A --camera still is played as a LOOPED
254
+ * feed — every painted frame is the same pixels — and a vision model is
255
+ * deterministic on the same pixels. Frame count therefore buys attempts, not
256
+ * information: once the model has run once, more time cannot change the verdict.
257
+ * So this says so, and names the two real suspects (the frame, the app) plus the
258
+ * deterministic way to wait (--wait-for) instead of inviting a wall-clock
259
+ * escalation that is guaranteed to be wasted. Exported for tests. */
260
+ export function slowRenderMessage(fps, o) {
261
+ if (o.camera) {
262
+ const frames = Math.max(1, Math.round(fps * (o.waitMs / 1000)));
263
+ return `${warning('⚠ Slow render:')} page painted at ${fps} fps, so the app's vision pipeline ran on roughly `
264
+ + `${bold(`${frames} frame${frames === 1 ? '' : 's'}`)} during the ${Math.round(o.waitMs / 1000)}s before this eval `
265
+ + `(it infers once per painted frame). ${bold('That is enough:')} --camera loops your still image, so every one of `
266
+ + `those frames is the SAME pixels and the model returns the SAME answer on each — re-running with a bigger `
267
+ + `--wait/--timeout cannot change the result. ${bold('Do not escalate the wait.')} If a detection landed, it is real. `
268
+ + `If nothing was detected, the suspect is the ${bold('frame')} (a model needs the whole subject in shot — a tight crop, `
269
+ + `an odd angle or a busy background reads as nothing) or the ${bold('app')} (frames never reach the model) — never the frame rate. `
270
+ + `Settle it in ONE run: ${CAMERA_FRAME_CHECK}`;
271
+ }
272
+ return `${warning('⚠ Slow render:')} page painted at ${fps} fps. `
273
+ + `Waiting on real time (setTimeout) advances animation/physics time far slower than it looks — `
274
+ + `assertions after a wall-clock wait can report a false negative. `
275
+ + `Step the app's own loop deterministically instead (3D templates: ${bold('core.advance(seconds)')}).`;
276
+ }
85
277
  /** Poll the async eval job until it finishes. Eval runs server-side as a
86
278
  * short-lived job (so a long --wait can't trip the gateway idle timeout);
87
279
  * we submit, then poll the result out of the job store. `expectedWorkMs` is
88
- * the time the server-side work is expected to take (settle + any in-page
89
- * awaits); the client budget is that plus 60s of headroom. */
280
+ * the time the server-side work is expected to take (see evalWorkBudgetMs);
281
+ * the client budget is that plus 60s of headroom for browser open/cold start. */
90
282
  export async function pollEvalResult(evalJobId, expectedWorkMs) {
91
- // Generous client budget: the server work is bounded by --wait plus browser
92
- // open/settle overhead; give it that plus headroom before giving up.
93
283
  const deadline = Date.now() + expectedWorkMs + 60_000;
94
284
  let missCount = 0;
95
285
  while (Date.now() < deadline) {
@@ -112,20 +302,24 @@ export async function pollEvalResult(evalJobId, expectedWorkMs) {
112
302
  throw new ApiError(rec.httpStatus, rec.code, rec.reason);
113
303
  await sleep(1000);
114
304
  }
115
- throw new ApiError(504, 'EVAL_TIMEOUT', 'Eval did not finish in time; narrow the expression or lower --wait');
305
+ // This is the CLIENT giving up, not the page failing and the budget above
306
+ // already covers everything the caller asked for, so "your expression is too
307
+ // big" is the one thing it is NOT. Never advise lowering --wait: on a camera /
308
+ // vision run the wait is what lets the model load, so that "fix" swaps a
309
+ // timeout for a silently empty read, which is worse.
310
+ throw new ApiError(504, 'EVAL_TIMEOUT', `the browser did not report back within ${Math.round((expectedWorkMs + 60_000) / 1000)}s — that budget already ` +
311
+ `covers --wait, --wait-for, the eval body and any --camera setup, so this is the browser itself being slow or ` +
312
+ `stuck (a cold sandbox, a heavy page), not your expression being too big. Lowering --wait does NOT help and on ` +
313
+ `a --camera run actively hurts (the wait is what lets the vision model load — cut it and the eval reads an ` +
314
+ `empty page instead). Just run the same command again: the sandbox is warm on the second hit.`);
116
315
  }
117
- // The in-page execution budget for an eval body's OWN runtime (its `await`/
118
- // `setTimeout` pauses), enforced by agent-browser's per-command CDP timeout
119
- // (AGENT_BROWSER_DEFAULT_TIMEOUT) distinct from --wait, which only sleeps
120
- // BEFORE the eval. Used to translate the opaque timeout envelope into guidance.
121
- const EVAL_EXEC_BUDGET_MS = 20_000;
122
- /** When the eval body's own runtime overruns the in-page execution budget,
123
- * agent-browser aborts the `Runtime.evaluate` CDP call and the failure comes
124
- * back as a `{success:false, error:"CDP command timed out: Runtime.evaluate"}`
125
- * envelope that the server surfaces verbatim as the eval `result` — opaque to
126
- * the caller (no timeout named, no distinction from the page or --wait). Detect
127
- * exactly that envelope and return an actionable message; null otherwise. */
128
- export function evalExecTimeoutMessage(result) {
316
+ /** When the script's own runtime overruns the in-page budget, agent-browser can
317
+ * abort the `Runtime.evaluate` CDP call and the failure comes back as a
318
+ * `{success:false, error:"CDP command timed out: Runtime.evaluate"}` envelope
319
+ * that the server surfaces verbatim as the eval `result` — opaque to the caller
320
+ * (no budget named, no flag to raise it). Detect exactly that envelope and
321
+ * return an actionable message; null otherwise. */
322
+ export function evalExecTimeoutMessage(result, budgetMs) {
129
323
  let parsed;
130
324
  try {
131
325
  parsed = JSON.parse(result);
@@ -137,11 +331,14 @@ export function evalExecTimeoutMessage(result) {
137
331
  return null;
138
332
  if (!/CDP command timed out:\s*Runtime\.evaluate/i.test(parsed.error))
139
333
  return null;
140
- return (`the expression hit the ~${EVAL_EXEC_BUDGET_MS / 1000}s in-page execution budget — the eval body ` +
141
- `(including its own await/setTimeout pauses) ran longer than that. This budget is the time the ` +
142
- `expression itself is allowed to run; it is separate from --wait, which only sleeps BEFORE the eval ` +
143
- `and cannot extend it. Split a long interactive check into several shorter 'page eval' calls (e.g. ` +
144
- `one per state to verify), keeping each body's in-page waits well under ${EVAL_EXEC_BUDGET_MS / 1000}s.`);
334
+ const budget = Math.round(budgetMs / 1000);
335
+ return (`the script hit its ${budget}s in-page budget the body (including its own await/setTimeout ` +
336
+ `pauses) ran longer than that. --wait sleeps BEFORE the script and does not extend it.\n` +
337
+ (budgetOverrunHint('in-page budget', budgetMs) ?? '') + '\n' +
338
+ `If the slow part is a ONE-TIME page init (a WASM/model download, a big asset, a first-frame ` +
339
+ `pipeline warm-up), do NOT split the body across several 'page eval' calls — every call is a fresh ` +
340
+ `page load that re-pays that init, so each one hits this same wall. Have the page kick it off on ` +
341
+ `load (not behind a click) and absorb it in the pre-eval window instead.`);
145
342
  }
146
343
  // Agents instinctively reach for a flag to pass the script (`--js`, `--script`,
147
344
  // `--code`, …); the JS is actually the positional <expr> (or --file for a saved
@@ -163,14 +360,18 @@ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr',
163
360
  export const pageEvalCommand = new Command('eval')
164
361
  .description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script). ONE client per call - to verify realtime/presence across concurrent clients use `page test --observe` instead')
165
362
  .argument('<url>', 'URL to load')
166
- .argument('[expr]', 'JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file. Time budget: the body has ~20s to finish after page load - keep driver scripts within it.')
167
- .option('--file <path>', 'Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work. Same ~20s post-load budget as <expr>.')
363
+ .argument('[expr]', `JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file. Time budget: the body has ${EVAL_SCRIPT_BUDGET_MS / 1000}s to finish after page load (${EVAL_SCRIPT_BUDGET_CAMERA_MS / 1000}s with --camera) - raise it with --timeout, max ${EVAL_SCRIPT_BUDGET_MAX_MS / 1000}s.`)
364
+ .option('--file <path>', `Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work. Same post-load budget as <expr> (--timeout).`)
365
+ .option('--step <expr>', `Run another expression against the SAME loaded page, after <expr> (repeat, max ${MAX_EVAL_STEPS}). Whatever that page load paid for — a vision model coming up, a game booting, a socket connecting — stays up for every step, so an N-part check costs ONE page load instead of N. Each step gets its own in-page budget and its own reported result.`, (val, prev) => [...prev, val], [])
168
366
  .option('--fixture <path>', 'Host a local file and expose it to the eval as `fixtureUrl` (and under `fixtures` by basename) to fetch in-page. For verifying a render/parse path against a real binary (an MP3, an image) - no size limit, auto-deleted after the run. Repeat for several files (single-value so it never swallows the inline <expr>).', (val, prev) => [...prev, val], [])
169
367
  .option('--reload <expr>', 'After the first eval, reload the page IN PLACE (localStorage/sessionStorage/cookies preserved) and evaluate this second expression against the post-reload DOM. One command verifies persisted state survives a reload: seed/assert state with <expr>, then assert the restored UI here.')
170
368
  .option('--reload-file <path>', 'Read the post-reload expression from a file instead of inline --reload (mutually exclusive)')
171
- .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
172
- .option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
173
- .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
369
+ .option('--camera <path>', `Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser's WEBCAM feed, so a camera app's real pipeline (getUserMedia MediaPipe/YOLOX your app logic) runs headlessly on a frame you choose. Implies --fake-media and waits ${CAMERA_DEFAULT_WAIT_MS / 1000}s for the vision model to load. No frame handy? gipity generate image "a hand making a closed fist, palm to camera".`)
370
+ .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so a voice/camera app runs headlessly instead of hitting its no-camera path. The feed is a built-in test pattern (and a tone) — nothing a vision model can recognize; to drive a vision app use --camera <path> instead.')
371
+ .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000). With --wait-for, it elapses AFTER the selector appears - gate, then settle.', '500')
372
+ .option('--wait-for <selector>', `Wait until this CSS selector appears before evaluating, then evaluate (deterministic - beats guessing a --wait). Gate on the state you are ASSERTING, not just a ready flag: '#verdict:not(:empty)' returns the instant the round lands, where a fixed wait snapshots mid-sequence. Same flag on page inspect/screenshot. Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`)
373
+ .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (max ${WAIT_FOR_MAX_MS})`, '5000')
374
+ .option('--timeout <ms>', `How long the script itself may run IN the page - its own await/setTimeout pauses count (default ${EVAL_SCRIPT_BUDGET_MS}, ${EVAL_SCRIPT_BUDGET_CAMERA_MS} with --camera/--fake-media; max ${EVAL_SCRIPT_BUDGET_MAX_MS}). Raise it to trace a sequence that unfolds over time (a game round, an animation). Distinct from --wait, which only sleeps BEFORE the script.`)
174
375
  .option('--auth', 'Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
175
376
  .option('--json', 'Output as JSON')
176
377
  .action((url, exprArg, opts) => run('Page eval', async () => {
@@ -223,22 +424,68 @@ export const pageEvalCommand = new Command('eval')
223
424
  pageEvalCommand.error(`error: Cannot read file: ${opts.reloadFile}`);
224
425
  }
225
426
  }
226
- const waitMs = capWaitMs(opts.wait, url);
227
- const parsedTimeout = parseInt(opts.waitTimeout, 10);
228
- const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
427
+ const steps = opts.step ?? [];
428
+ if (steps.length > MAX_EVAL_STEPS) {
429
+ pageEvalCommand.error(`error: at most ${MAX_EVAL_STEPS} --step expressions ride on one page load (got ${steps.length}) — ` +
430
+ `do more per step, or split this into two evals`);
431
+ }
432
+ let waitMs = capWaitMs(opts.wait, url);
433
+ const waitForTimeoutMs = capWaitForTimeoutMs(opts.waitTimeout);
434
+ // The in-page budget for the script itself. Always sent, so the caller (and
435
+ // every error message below) is talking about one known number rather than a
436
+ // server-side default nobody can see.
437
+ const hasMedia = !!opts.camera || !!opts.fakeMedia;
438
+ const scriptBudgetMs = capScriptBudgetMs(opts.timeout, hasMedia);
439
+ // Camera runs get a model-load-sized pre-eval window unless the caller chose
440
+ // their own wait (either flag). Doing this by default is the whole point: the
441
+ // eval body must not be where a vision app's warm-up happens.
442
+ const explicitWait = pageEvalCommand.getOptionValueSource('wait') !== 'default';
443
+ const chosenWait = explicitWait || !!opts.waitFor;
444
+ if (opts.camera && !chosenWait) {
445
+ waitMs = CAMERA_DEFAULT_WAIT_MS;
446
+ console.error(muted(`--camera: waiting ${CAMERA_DEFAULT_WAIT_MS / 1000}s before the eval so the camera and the app's ` +
447
+ `vision model finish loading (they cannot warm up inside the eval body — that has a ` +
448
+ `${scriptBudgetMs / 1000}s budget of its own, --timeout to change it). Override with --wait <ms>, or use ` +
449
+ `--wait-for '<ready-selector>' to stop as soon as the app is ready.`));
450
+ }
451
+ // The selector gate REPLACES the blind pre-eval sleep server-side, so a
452
+ // --wait passed alongside it was silently dropped: the script ran the instant
453
+ // the selector appeared and read a half-finished sequence (a round still
454
+ // counting down), which looks exactly like an app bug. Honour both, in the
455
+ // only order that means anything — gate, THEN settle — by moving the sleep to
456
+ // the head of the first script and paying for it out of that script's budget.
457
+ const settleAfterGateMs = opts.waitFor && explicitWait ? waitMs : 0;
458
+ const sentWaitMs = settleAfterGateMs > 0 ? 0 : waitMs;
459
+ const sentScriptBudgetMs = Math.min(scriptBudgetMs + settleAfterGateMs, EVAL_SCRIPT_BUDGET_MAX_MS);
229
460
  // --fixture: host each file publicly, then splice `fixtures` / `fixtureUrl`
230
- // into the eval scope so the page can fetch the bytes. The prelude makes the
231
- // body a statement (const/return), so the server's expression form fails to
232
- // parse and it falls back to the function-body form - which runs both inline
233
- // exprs (wrapped in `return (...)`) and --file scripts. Cleanup in `finally`.
461
+ // into the eval scope so the page can fetch the bytes. A prelude makes the
462
+ // body a statement (const/return), so an expression script is spliced into
463
+ // `return (...)` and a statement body is kept as-is (withPrelude). Every leg
464
+ // (<expr> and each --step) gets the fixture bindings. Cleanup in `finally`.
234
465
  const fixturePaths = opts.fixture ?? [];
235
466
  const hosted = [];
467
+ // The webcam feed is hosted like a fixture but is NOT exposed to the eval
468
+ // body as `fixtures`/`fixtureUrl` — the page never fetches it; the browser
469
+ // plays it as the camera device. Tracked separately so it still gets
470
+ // cleaned up, without shifting the fixture map the caller indexes into.
471
+ let camera;
236
472
  let projectGuid;
237
473
  let sentExpr = expr;
474
+ let sentSteps = steps;
475
+ // Validate the camera file locally first: a wrong file type should cost one
476
+ // instant error, not an upload plus an opaque browser-side failure.
477
+ if (opts.camera)
478
+ assertCameraFile(opts.camera);
238
479
  try {
239
- if (fixturePaths.length) {
480
+ if (fixturePaths.length || opts.camera) {
240
481
  const { config } = await resolveProjectContext({});
241
482
  projectGuid = config.projectGuid;
483
+ }
484
+ if (opts.camera) {
485
+ console.log(muted(`Hosting camera feed ${opts.camera}…`));
486
+ camera = await uploadCameraFeed(projectGuid, opts.camera);
487
+ }
488
+ if (fixturePaths.length) {
242
489
  for (const p of fixturePaths) {
243
490
  console.log(muted(`Hosting fixture ${p}…`));
244
491
  hosted.push(await uploadPublicFixture(projectGuid, p));
@@ -247,27 +494,72 @@ export const pageEvalCommand = new Command('eval')
247
494
  for (const h of hosted)
248
495
  map[h.name] = h.url;
249
496
  const prelude = `const fixtures=${JSON.stringify(map)};const fixtureUrl=${JSON.stringify(hosted[0].url)};`;
250
- sentExpr = opts.file ? `${prelude}\n${expr}` : `${prelude}\nreturn (${expr});`;
497
+ sentExpr = withPrelude(sentExpr, prelude);
498
+ sentSteps = sentSteps.map((s) => withPrelude(s, prelude));
499
+ }
500
+ // The post-gate settle rides at the head of the FIRST script only: the
501
+ // later steps run on a page the gate + settle already carried forward.
502
+ if (settleAfterGateMs > 0) {
503
+ sentExpr = withPrelude(sentExpr, `await new Promise((r) => setTimeout(r, ${settleAfterGateMs}));`);
251
504
  }
252
505
  const kickoff = await post('/tools/browser/eval', {
253
- url, expr: sentExpr, waitMs,
506
+ url, expr: sentExpr, waitMs: sentWaitMs,
507
+ steps: sentSteps.length ? sentSteps : undefined,
254
508
  reloadExpr,
255
509
  waitForSelector: opts.waitFor || undefined,
256
510
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
511
+ timeoutMs: sentScriptBudgetMs,
257
512
  auth: opts.auth || undefined,
513
+ // A camera feed needs the synthetic devices in place to be played into,
514
+ // so --camera implies --fake-media rather than failing on the pairing.
515
+ fakeMedia: opts.fakeMedia || !!camera || undefined,
516
+ cameraUrl: camera?.url,
258
517
  });
259
- // The reload leg re-runs the settle before its eval — budget for both.
260
- const d = await pollEvalResult(kickoff.data.evalJobId, reloadExpr !== undefined ? waitMs * 2 : waitMs);
518
+ let d;
519
+ try {
520
+ d = await pollEvalResult(kickoff.data.evalJobId, evalWorkBudgetMs({
521
+ waitMs: sentWaitMs,
522
+ waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : 0,
523
+ hasReload: reloadExpr !== undefined,
524
+ hasCamera: !!camera,
525
+ scriptBudgetMs: sentScriptBudgetMs,
526
+ legs: 1 + sentSteps.length,
527
+ }));
528
+ }
529
+ catch (err) {
530
+ // The server's overrun message names the budget but not the flag that
531
+ // raises it — and the flag is what the caller needs next. Name it, with
532
+ // the value THIS run used, so the retry is an edit rather than a guess.
533
+ const msg = err?.message ?? '';
534
+ const hint = budgetOverrunHint(msg, scriptBudgetMs);
535
+ if (!hint)
536
+ throw err;
537
+ throw new Error(`${msg}\n${hint}`);
538
+ }
261
539
  const { result, noValue } = normalizeEvalResult(d.result);
262
540
  const reload = d.reloadResult !== undefined ? normalizeEvalResult(d.reloadResult) : undefined;
263
- const execTimeout = evalExecTimeoutMessage(d.result);
541
+ const execTimeout = evalExecTimeoutMessage(d.result, scriptBudgetMs);
264
542
  if (execTimeout)
265
543
  throw new Error(execTimeout);
544
+ // Each --step is reported on its own, against the expression that produced
545
+ // it — N results in one blob would be unreadable and unattributable.
546
+ const stepOut = steps.map((stepExpr, i) => {
547
+ const raw = d.stepResults?.[i];
548
+ const norm = raw !== undefined ? normalizeEvalResult(raw) : undefined;
549
+ return { expr: stepExpr, result: norm?.result ?? '', noValue: norm?.noValue ?? true };
550
+ });
551
+ const emptyState = !noValue && isEmptyStateResult(result);
552
+ // An empty read on a camera run has a different cause and a different fix
553
+ // than an empty read on a normal page: the generic hint's "poll harder,
554
+ // raise --timeout" is guaranteed-wasted advice against a looped still.
555
+ const emptyHint = camera ? EVAL_CAMERA_EMPTY_HINT : EVAL_EMPTY_STATE_HINT;
266
556
  if (opts.json) {
267
557
  console.log(JSON.stringify({
268
558
  ...d, result,
559
+ ...(stepOut.length ? { stepResults: stepOut.map((s) => s.result) } : {}),
269
560
  ...(reload ? { reloadResult: reload.result } : {}),
270
561
  ...(noValue ? { hint: EVAL_NO_VALUE_HINT } : {}),
562
+ ...(emptyState ? { hint: emptyHint } : {}),
271
563
  }));
272
564
  return;
273
565
  }
@@ -280,10 +572,7 @@ export const pageEvalCommand = new Command('eval')
280
572
  // `setTimeout(2000)` may advance only a fraction of a second of app time.
281
573
  // Without this line the eval returns a plausible-looking false negative.
282
574
  if (d.slowRender) {
283
- console.log(`${warning('⚠ Slow render:')} page painted at ${d.slowRender.fps} fps. `
284
- + `Waiting on real time (setTimeout) advances animation/physics time far slower than it looks — `
285
- + `assertions after a wall-clock wait can report a false negative. `
286
- + `Step the app's own loop deterministically instead (3D templates: ${bold('core.advance(seconds)')}).`);
575
+ console.log(slowRenderMessage(d.slowRender.fps, { camera: !!camera, waitMs }));
287
576
  }
288
577
  // Auth state: without this line an agent can't distinguish "signed-in
289
578
  // eval" from "--auth silently no-op'd against the anonymous page".
@@ -293,14 +582,25 @@ export const pageEvalCommand = new Command('eval')
293
582
  ? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
294
583
  : `${warning('Auth: session NOT established')}${d.auth.detail ? ` — ${d.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
295
584
  }
585
+ if (camera)
586
+ console.log(`${muted('Camera:')} ${camera.name} ${muted('(played as the webcam feed; getUserMedia resolves)')}`);
296
587
  if (hosted.length)
297
588
  console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
298
589
  console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
299
590
  console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
300
591
  if (noValue)
301
592
  console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
593
+ if (emptyState)
594
+ console.log(muted(`\n${emptyHint}`));
302
595
  if (d.truncated)
303
596
  console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
597
+ for (let i = 0; i < stepOut.length; i++) {
598
+ const s = stepOut[i];
599
+ console.log(`\n${bold(`Step ${i + 1}`)} ${muted(summarizeExpr(s.expr))} ${muted('(same page load)')}`);
600
+ console.log(s.result.trim() ? s.result : muted('(empty result)'));
601
+ if (s.noValue)
602
+ console.log(muted(EVAL_NO_VALUE_HINT));
603
+ }
304
604
  if (reload) {
305
605
  console.log(`\n${bold('After reload')} ${muted('(page reloaded in place — storage preserved)')}`);
306
606
  console.log(reload.result.trim() ? reload.result : muted('(empty result)'));
@@ -311,7 +611,7 @@ export const pageEvalCommand = new Command('eval')
311
611
  }
312
612
  }
313
613
  finally {
314
- for (const h of hosted) {
614
+ for (const h of [...hosted, ...(camera ? [camera] : [])]) {
315
615
  try {
316
616
  await deleteFixture(projectGuid, h.guid);
317
617
  }
@@ -347,15 +647,52 @@ Examples:
347
647
  "localStorage.setItem('todo','milk'); document.title" \\
348
648
  --reload "({ restored: localStorage.getItem('todo'), heading: document.querySelector('h1')?.textContent })"
349
649
 
650
+ # Camera app (MediaPipe / YOLOX / any getUserMedia app): play a real image as
651
+ # the webcam so the app's OWN pipeline runs on a frame you control — no need to
652
+ # stub the model or export internals just to test around a missing camera.
653
+ # --camera waits ${CAMERA_DEFAULT_WAIT_MS / 1000}s before evaluating (model load + first frame); the script
654
+ # itself then gets ${EVAL_SCRIPT_BUDGET_CAMERA_MS / 1000}s in the page (--timeout, max ${EVAL_SCRIPT_BUDGET_MAX_MS / 1000}s).
655
+ gipity generate image "a hand making a closed fist, palm to camera, plain background" -o fist.png
656
+ gipity page eval "https://dev.gipity.ai/me/app/" --camera fist.png \\
657
+ "document.querySelector('#detected-gesture').textContent"
658
+ # Camera app that only starts on a click? Start it on page load instead (a headless
659
+ # run has no user), and expose a ready signal so the wait ends the moment it's up:
660
+ gipity page eval "https://dev.gipity.ai/me/app/" --camera fist.png \\
661
+ --wait-for "#detected-gesture:not(:empty)" --wait-timeout ${WAIT_FOR_MAX_MS} \\
662
+ "document.querySelector('#detected-gesture').textContent"
663
+ # Several assertions about the SAME page? Use --step, not several commands: a camera
664
+ # page load pays for getUserMedia + the vision model ONCE and every step reuses it.
665
+ gipity page eval "https://dev.gipity.ai/me/app/" --camera fist.png \\
666
+ --wait-for '[data-vision="ready"]' \\
667
+ "window.__vision.gesture()" \\
668
+ --step "document.getElementById('see').textContent" \\
669
+ --step "({ score: score.textContent, verdict: verdict.textContent })"
670
+
350
671
  Module resolution: dynamic import() specifiers starting with ./ or ../ resolve
351
672
  against the PAGE URL, so import('./packages/i18n/index.js') loads the app's own
352
673
  module without hand-building the deployed /account/project/ path. Absolute paths
353
674
  and full URLs pass through unchanged.
354
675
 
355
- The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
356
- pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long
357
- interactive sequence, split it into several shorter evals (one per state to verify)
358
- rather than one body with many long waits.
676
+ Time budget: the script runs under a ${EVAL_SCRIPT_BUDGET_MS / 1000}s in-page budget (${EVAL_SCRIPT_BUDGET_CAMERA_MS / 1000}s with --camera/--fake-media),
677
+ counting its own await/setTimeout pauses. Two separate knobs:
678
+ --timeout <ms> how long the SCRIPT may run in the page (max ${EVAL_SCRIPT_BUDGET_MAX_MS}) raise this to
679
+ trace a sequence that unfolds over time (a game round, an animation)
680
+ --wait / --wait-for how long to settle BEFORE the script runs — a blind sleep, a
681
+ selector gate, or both (gate first, then sleep). Neither extends --timeout.
682
+ Which one:
683
+ - waiting for the app to REACH a state before you read it → --wait-for '<selector>'
684
+ (gate on the end state — '#verdict:not(:empty)' — not just a ready flag; a bare
685
+ --wait samples one arbitrary instant and can land mid-sequence). Passing both means
686
+ gate first, then settle --wait ms.
687
+ - watching state evolve (poll until a result lands, record a trace) → --timeout
688
+ - a slow ONE-TIME init (WASM/model download, big asset) → do NOT split the body across
689
+ calls; every call re-loads the page and re-pays it. Start that work on page load and
690
+ absorb it in the pre-eval window: --wait <ms> (max ${MAX_WAIT_MS}) or, better,
691
+ --wait-for '<ready-selector>' --wait-timeout ${WAIT_FOR_MAX_MS}.
692
+
693
+ Several things to check on one page? Pass --step (max ${MAX_EVAL_STEPS}) instead of running
694
+ 'page eval' N times: the steps run in order against the ONE loaded page, so a slow
695
+ boot (vision model, game, socket) is paid once, not once per assertion.
359
696
 
360
697
  Testing realtime/shared state across clients?
361
698
  Separate 'page eval' calls run sequentially (one finishes before the next
@@ -41,7 +41,7 @@ export const pageInspectCommand = new Command('inspect')
41
41
  .option('--no-reprobe', 'Skip the automatic second probe that filters one-time transient console errors - roughly halves inspect time on a page with any console error, at the cost of possibly reporting a cold-load transient as real')
42
42
  .option('--no-truncate', 'Show full URLs instead of truncating long ones with middle-ellipsis')
43
43
  .option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
44
- .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)')
44
+ .option('--no-fake-media', "Inspect with NO camera or microphone present, so a getUserMedia app takes its no-device error path (use this only when that fallback IS what you're inspecting)")
45
45
  .option('--device <name>', `Inspect as a real touch device: ${INSPECT_DEVICES.join(', ')}. Emulates touch events, mobile user-agent and DPR, so a touch-gated mobile layout is what gets inspected.`)
46
46
  .option('--auth', 'Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
47
47
  // Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
@@ -65,11 +65,17 @@ export async function inspectPage(url, opts = {}) {
65
65
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
66
66
  const truncate = opts.truncate !== false;
67
67
  const showAll = opts.all === true;
68
+ // The headless browser has a synthetic camera + mic BY DEFAULT. A real user
69
+ // inspecting a camera app has a camera; a browser without one makes every
70
+ // getUserMedia app report a NotFoundError that is an artifact of the probe,
71
+ // not a defect in the page — and worse, the app stops at its error path, so
72
+ // the pipeline the agent actually wanted inspected never runs. A synthetic
73
+ // device costs a page that never asks for media exactly nothing.
68
74
  const inspectBody = {
69
75
  url, waitMs,
70
76
  waitForSelector: opts.waitFor || undefined,
71
77
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
72
- fakeMedia: opts.fakeMedia || undefined,
78
+ fakeMedia: opts.fakeMedia !== false ? true : undefined,
73
79
  device: opts.device ? resolveTouchDevice(opts.device) : undefined,
74
80
  auth: opts.auth || undefined,
75
81
  };