gipity 1.0.428 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/agents/claude-code.js +53 -0
  2. package/dist/agents/codex.js +49 -0
  3. package/dist/agents/grok.js +54 -0
  4. package/dist/agents/index.js +23 -0
  5. package/dist/agents/types.js +18 -0
  6. package/dist/api.js +7 -0
  7. package/dist/capture/sources/codex.js +216 -0
  8. package/dist/capture/sources/grok.js +178 -0
  9. package/dist/client-context.js +2 -0
  10. package/dist/commands/build.js +1352 -0
  11. package/dist/commands/chat.js +9 -3
  12. package/dist/commands/claude.js +4 -13
  13. package/dist/commands/db.js +12 -5
  14. package/dist/commands/deploy.js +19 -1
  15. package/dist/commands/doctor.js +8 -2
  16. package/dist/commands/generate.js +61 -4
  17. package/dist/commands/init.js +9 -15
  18. package/dist/commands/page-eval.js +404 -56
  19. package/dist/commands/page-inspect.js +15 -5
  20. package/dist/commands/page-screenshot.js +269 -64
  21. package/dist/commands/project.js +1 -1
  22. package/dist/commands/relay-install.js +1 -1
  23. package/dist/commands/relay.js +2 -2
  24. package/dist/commands/sandbox.js +62 -16
  25. package/dist/commands/setup.js +16 -10
  26. package/dist/commands/uninstall.js +25 -3
  27. package/dist/hooks/capture-runner.js +136 -39
  28. package/dist/index.js +1962 -668
  29. package/dist/knowledge.js +3 -3
  30. package/dist/page-fixtures.js +92 -3
  31. package/dist/prefs.js +50 -0
  32. package/dist/project-setup.js +2 -10
  33. package/dist/provider-docs.js +10 -10
  34. package/dist/relay/daemon.js +140 -18
  35. package/dist/relay/device-http.js +22 -0
  36. package/dist/relay/diagnostics.js +4 -2
  37. package/dist/relay/media-upload.js +184 -0
  38. package/dist/relay/onboarding.js +2 -2
  39. package/dist/setup.js +262 -18
  40. package/package.json +4 -3
@@ -5,11 +5,13 @@ 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
- // a returnable value instead of guessing and retrying.
12
- export const EVAL_NO_VALUE_HINT = 'The eval ran but returned no JSON-serializable value. A statement body with no `return`, an assignment, a void call, or a DOM node/function all serialize to null. ' +
11
+ // a returnable value instead of guessing and retrying. (A body ending in a bare
12
+ // expression is auto-returned SERVER-side before this can fire so this is the
13
+ // residue: block/loop endings, explicit undefined returns, DOM nodes, functions.)
14
+ export const EVAL_NO_VALUE_HINT = 'The eval ran but returned no JSON-serializable value. A body ending in a loop/if-block, a `return` of undefined, or a DOM node/function all serialize to null. ' +
13
15
  'End the script with an expression — or an explicit `return` — that yields plain data, e.g. `return { label: input.value, count: items.length }` or `return JSON.stringify(payload)`.';
14
16
  /** Normalize a raw eval result for display. The eval can come back as a useful
15
17
  * serialized value, the literal `null`/`undefined`/empty string, or — when the
@@ -44,6 +46,52 @@ export function normalizeEvalResult(raw) {
44
46
  }
45
47
  return { result: raw, noValue: false };
46
48
  }
49
+ // Shown when a run returns a structurally empty value ({}, [], or an object whose
50
+ // every field is null/''). That is NOT the same as "no value": the script ran and
51
+ // read the page — it just read it at an instant where the state it wanted did not
52
+ // exist. A fixed --wait samples ONE moment, and transient state (a round result,
53
+ // a toast, a frame's detection) may have already been cleared by then, so the run
54
+ // looks like a pass and the agent has to reason its way to "I sampled too late".
55
+ 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. ' +
56
+ 'A fixed --wait samples ONE moment; transient state (a round result, a toast, a detection) can be gone by then. ' +
57
+ 'Poll INSIDE the body and return the moment the condition holds (raise --timeout if the sequence needs longer), ' +
58
+ "or gate the read on the app's own signal with --wait-for '<selector>'.";
59
+ // The one-run experiment that separates "the frame is unreadable" from "the app
60
+ // never routes frames to the model" — the ambiguity that otherwise costs a
61
+ // re-generate-and-re-run cycle per candidate image. Named once, reused by every
62
+ // camera-run message below so the caller is told the same thing wherever it
63
+ // notices something is off.
64
+ 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 "
65
+ + "detector on it) and return its RAW output alongside the app's state. Model saw nothing => the frame is at fault: "
66
+ + 'regenerate it (subject filling the frame, plain background, even light), do not re-run the same one. '
67
+ + "Model saw it but the app's state is empty => the bug is in the app's wiring, not the picture.";
68
+ // A camera run that comes back empty must NOT be sent down the generic
69
+ // "raise --timeout and poll harder" path below: on a looped still frame that is
70
+ // a guaranteed-identical re-run, and that escalation (60s, then 70s, then 80s)
71
+ // is the single most expensive way a vision app gets verified. Give it the
72
+ // verdict and the disambiguating experiment instead.
73
+ 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 '
74
+ + 'on it, so a longer --wait/--timeout re-runs the identical inference and returns the identical nothing — do not escalate. '
75
+ + `Two suspects: the frame, or the app's wiring. Tell them apart in one run: ${CAMERA_FRAME_CHECK} `
76
+ + "(If the app only starts its pipeline on a click, a headless run never clicks — start it on load and gate this eval on "
77
+ + "the app's own ready signal with --wait-for '<selector>'.)";
78
+ /** True when the eval came back with a structurally empty container: `{}`, `[]`,
79
+ * or an object whose every value is null/undefined/''. Exported for tests. */
80
+ export function isEmptyStateResult(result) {
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(result);
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ if (Array.isArray(parsed))
89
+ return parsed.length === 0;
90
+ if (!parsed || typeof parsed !== 'object')
91
+ return false;
92
+ const values = Object.values(parsed);
93
+ return values.every((v) => v === null || v === undefined || v === '');
94
+ }
47
95
  // A one-line inline expr is worth echoing back — it's the thing you're asserting
48
96
  // on. A multi-line driver script is not: echoing 30 lines of the caller's own
49
97
  // source above the result buries the value, and an echo that lands right before
@@ -82,14 +130,158 @@ export function capWaitMs(rawWait, url) {
82
130
  `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
131
  return MAX_WAIT_MS;
84
132
  }
133
+ // How many --step expressions may ride on one page load (mirrors the server's
134
+ // MAX_EVAL_STEPS). Past this, the run is really two verifications.
135
+ export const MAX_EVAL_STEPS = 4;
136
+ // Ceiling the server accepts for the --wait-for gate (its WAIT_FOR_MAX_MS).
137
+ export const WAIT_FOR_MAX_MS = 30_000;
138
+ /** Parse --wait-timeout, clamping to the gate's server-side ceiling. Over the cap
139
+ * the server answers with a zod 400 that names neither the flag nor the limit, so
140
+ * clamp and say so on stderr (--json stdout stays clean). Exported for tests. */
141
+ export function capWaitForTimeoutMs(raw) {
142
+ const parsed = parseInt(raw, 10);
143
+ const ms = Number.isFinite(parsed) && parsed >= 0 ? parsed : 5_000;
144
+ if (ms <= WAIT_FOR_MAX_MS)
145
+ return ms;
146
+ 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. ` +
147
+ `A state that takes longer than that to appear is not a settling delay: have the page reach it on load, ` +
148
+ `or watch for it INSIDE the script (--timeout ${EVAL_SCRIPT_BUDGET_MAX_MS}).`));
149
+ return WAIT_FOR_MAX_MS;
150
+ }
151
+ /** True when `s` is an expression (`document.title`, an IIFE) rather than a
152
+ * statement body (`const x = …; return x`). Mirrors the server's own parse
153
+ * choice: only an expression may be spliced into `return (…)`. Nothing is
154
+ * executed — Function() parses and throws on a statement body. */
155
+ export function parsesAsExpression(s) {
156
+ try {
157
+ // eslint-disable-next-line no-new-func
158
+ new Function(`return (${s}\n);`);
159
+ return true;
160
+ }
161
+ catch {
162
+ return false;
163
+ }
164
+ }
165
+ /** Prepend a prelude (fixture bindings, a post-gate settle) to a caller script.
166
+ * A prelude makes the whole thing a statement body, so an expression script has
167
+ * to become `return (expr)` — a statement-body script is spliced as-is. */
168
+ export function withPrelude(script, prelude) {
169
+ if (!prelude)
170
+ return script;
171
+ return parsesAsExpression(script)
172
+ ? `${prelude}\nreturn (${script});`
173
+ : `${prelude}\n${script}`;
174
+ }
175
+ // A camera app's first useful frame is never 500ms away: getUserMedia has to
176
+ // come up, then the vision pipeline downloads and initializes its model (WASM +
177
+ // weights) before it can label anything. Evaluating at the default --wait always
178
+ // reads an empty/"loading" DOM, and pushing that warm-up into the eval body
179
+ // instead blows the in-page execution budget. So --camera raises the pre-eval
180
+ // window (which is NOT on the eval's clock) to something a model load fits in.
181
+ export const CAMERA_DEFAULT_WAIT_MS = 15_000;
182
+ // Playing a real frame into the browser's webcam costs the server real time
183
+ // before the page is even navigated: it fetches the hosted feed and encodes it
184
+ // into the synthetic capture device. That is server-side setup the caller cannot
185
+ // shorten, so it has to be inside the client's patience — otherwise the CLI
186
+ // abandons a job that was always going to take this long.
187
+ const CAMERA_SETUP_BUDGET_MS = 30_000;
188
+ // How long the script may run INSIDE the page. The server enforces this (it is
189
+ // the `timeoutMs` on the eval request) and picks the roomier default on a
190
+ // synthetic-media run, where a WASM/model download IS the wait. These mirror the
191
+ // server's EVAL_SCRIPT_BUDGET_* constants; --timeout names any value up to MAX.
192
+ export const EVAL_SCRIPT_BUDGET_MS = 30_000;
193
+ export const EVAL_SCRIPT_BUDGET_CAMERA_MS = 45_000;
194
+ export const EVAL_SCRIPT_BUDGET_MAX_MS = 90_000;
195
+ /** The in-page budget this call gets: the caller's --timeout when given, else the
196
+ * default for the kind of run (roomier under --camera/--fake-media). Clamped to
197
+ * the server's accepted range — a value over the max is clamped and explained on
198
+ * stderr rather than bounced back as an opaque 400. Exported for tests. */
199
+ export function capScriptBudgetMs(rawTimeout, hasMedia) {
200
+ const fallback = hasMedia ? EVAL_SCRIPT_BUDGET_CAMERA_MS : EVAL_SCRIPT_BUDGET_MS;
201
+ if (rawTimeout === undefined)
202
+ return fallback;
203
+ const parsed = parseInt(rawTimeout, 10);
204
+ if (!Number.isFinite(parsed) || parsed <= 0)
205
+ return fallback;
206
+ if (parsed > EVAL_SCRIPT_BUDGET_MAX_MS) {
207
+ 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. ` +
208
+ `A body that needs longer than that is doing its waiting in the wrong place: start the slow work on page load and ` +
209
+ `absorb it in the pre-eval window (--wait-for '<ready-selector>'), keeping the body to a quick read.`));
210
+ return EVAL_SCRIPT_BUDGET_MAX_MS;
211
+ }
212
+ return Math.max(1_000, parsed);
213
+ }
214
+ /** Upper bound on the server-side work this eval asked for. EVERY leg the caller
215
+ * can lengthen has to be in here, or the client gives up on a job that is still
216
+ * legitimately running and reports it as the caller's fault:
217
+ * - the pre-eval window (--wait, and --wait-for's own timeout)
218
+ * - every script leg (<expr> plus each --step), each allowed its full in-page
219
+ * budget (--timeout) — they share ONE page load but not one clock
220
+ * - the reload leg, which repeats settle + eval
221
+ * - --camera's feed fetch/encode, paid before the page loads
222
+ * Browser cold-start/open overhead is NOT here; it's the flat headroom in
223
+ * pollEvalResult. Exported for tests. */
224
+ export function evalWorkBudgetMs(o) {
225
+ const script = o.scriptBudgetMs ?? (o.hasCamera ? EVAL_SCRIPT_BUDGET_CAMERA_MS : EVAL_SCRIPT_BUDGET_MS);
226
+ const legs = Math.max(1, o.legs ?? 1);
227
+ const settle = o.waitMs + (o.waitForTimeoutMs ?? 0);
228
+ const firstPass = settle + script * legs;
229
+ return firstPass + (o.hasReload ? settle + script : 0) + (o.hasCamera ? CAMERA_SETUP_BUDGET_MS : 0);
230
+ }
231
+ /** The server refuses a script that outran its in-page budget with a message that
232
+ * names the budget and says to "raise the eval timeout" — but not the flag that
233
+ * does it. Name it, with the value this run actually used, so the fix is one
234
+ * edit away instead of a guess (or a trip through --help). */
235
+ export function budgetOverrunHint(reason, usedBudgetMs) {
236
+ if (!/in-page budget/i.test(reason))
237
+ return null;
238
+ if (usedBudgetMs >= EVAL_SCRIPT_BUDGET_MAX_MS) {
239
+ return `This run already used the maximum in-page budget (--timeout ${EVAL_SCRIPT_BUDGET_MAX_MS}). ` +
240
+ `More time is not the answer: have the page start its slow work on load, wait it out in the pre-eval window ` +
241
+ `(--wait-for '<ready-selector>' --wait-timeout ${MAX_WAIT_MS}), and keep the body to a quick read.`;
242
+ }
243
+ return `That budget was ${Math.round(usedBudgetMs / 1000)}s. Raise it with --timeout <ms> ` +
244
+ `(up to ${EVAL_SCRIPT_BUDGET_MAX_MS}), e.g. --timeout ${EVAL_SCRIPT_BUDGET_MAX_MS} — ` +
245
+ `or gate on a ready signal instead: --wait-for '<selector>' --wait-timeout ${MAX_WAIT_MS}.`;
246
+ }
247
+ /** The headless browser paints slowly, and what that MEANS depends on what the
248
+ * page is doing — so the one-size warning was actively misleading on a --camera
249
+ * run. A vision app has no loop to step: its pipeline is driven by camera
250
+ * frames, one inference per painted frame.
251
+ *
252
+ * The trap this message exists to close: a slow-render warning next to a "no
253
+ * detection" result reads as "the renderer starved you", so the caller re-runs
254
+ * with a bigger --wait/--timeout, several times, and gets the identical answer
255
+ * each time. It cannot be otherwise. A --camera still is played as a LOOPED
256
+ * feed — every painted frame is the same pixels — and a vision model is
257
+ * deterministic on the same pixels. Frame count therefore buys attempts, not
258
+ * information: once the model has run once, more time cannot change the verdict.
259
+ * So this says so, and names the two real suspects (the frame, the app) plus the
260
+ * deterministic way to wait (--wait-for) instead of inviting a wall-clock
261
+ * escalation that is guaranteed to be wasted. Exported for tests. */
262
+ export function slowRenderMessage(fps, o) {
263
+ if (o.camera) {
264
+ const frames = Math.max(1, Math.round(fps * (o.waitMs / 1000)));
265
+ return `${warning('⚠ Slow render:')} page painted at ${fps} fps, so the app's vision pipeline ran on roughly `
266
+ + `${bold(`${frames} frame${frames === 1 ? '' : 's'}`)} during the ${Math.round(o.waitMs / 1000)}s before this eval `
267
+ + `(it infers once per painted frame). ${bold('That is enough:')} --camera loops your still image, so every one of `
268
+ + `those frames is the SAME pixels and the model returns the SAME answer on each — re-running with a bigger `
269
+ + `--wait/--timeout cannot change the result. ${bold('Do not escalate the wait.')} If a detection landed, it is real. `
270
+ + `If nothing was detected, the suspect is the ${bold('frame')} (a model needs the whole subject in shot — a tight crop, `
271
+ + `an odd angle or a busy background reads as nothing) or the ${bold('app')} (frames never reach the model) — never the frame rate. `
272
+ + `Settle it in ONE run: ${CAMERA_FRAME_CHECK}`;
273
+ }
274
+ return `${warning('⚠ Slow render:')} page painted at ${fps} fps. `
275
+ + `Waiting on real time (setTimeout) advances animation/physics time far slower than it looks — `
276
+ + `assertions after a wall-clock wait can report a false negative. `
277
+ + `Step the app's own loop deterministically instead (3D templates: ${bold('core.advance(seconds)')}).`;
278
+ }
85
279
  /** Poll the async eval job until it finishes. Eval runs server-side as a
86
280
  * short-lived job (so a long --wait can't trip the gateway idle timeout);
87
281
  * 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. */
282
+ * the time the server-side work is expected to take (see evalWorkBudgetMs);
283
+ * the client budget is that plus 60s of headroom for browser open/cold start. */
90
284
  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
285
  const deadline = Date.now() + expectedWorkMs + 60_000;
94
286
  let missCount = 0;
95
287
  while (Date.now() < deadline) {
@@ -112,20 +304,24 @@ export async function pollEvalResult(evalJobId, expectedWorkMs) {
112
304
  throw new ApiError(rec.httpStatus, rec.code, rec.reason);
113
305
  await sleep(1000);
114
306
  }
115
- throw new ApiError(504, 'EVAL_TIMEOUT', 'Eval did not finish in time; narrow the expression or lower --wait');
307
+ // This is the CLIENT giving up, not the page failing and the budget above
308
+ // already covers everything the caller asked for, so "your expression is too
309
+ // big" is the one thing it is NOT. Never advise lowering --wait: on a camera /
310
+ // vision run the wait is what lets the model load, so that "fix" swaps a
311
+ // timeout for a silently empty read, which is worse.
312
+ throw new ApiError(504, 'EVAL_TIMEOUT', `the browser did not report back within ${Math.round((expectedWorkMs + 60_000) / 1000)}s — that budget already ` +
313
+ `covers --wait, --wait-for, the eval body and any --camera setup, so this is the browser itself being slow or ` +
314
+ `stuck (a cold sandbox, a heavy page), not your expression being too big. Lowering --wait does NOT help and on ` +
315
+ `a --camera run actively hurts (the wait is what lets the vision model load — cut it and the eval reads an ` +
316
+ `empty page instead). Just run the same command again: the sandbox is warm on the second hit.`);
116
317
  }
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) {
318
+ /** When the script's own runtime overruns the in-page budget, agent-browser can
319
+ * abort the `Runtime.evaluate` CDP call and the failure comes back as a
320
+ * `{success:false, error:"CDP command timed out: Runtime.evaluate"}` envelope
321
+ * that the server surfaces verbatim as the eval `result` — opaque to the caller
322
+ * (no budget named, no flag to raise it). Detect exactly that envelope and
323
+ * return an actionable message; null otherwise. */
324
+ export function evalExecTimeoutMessage(result, budgetMs) {
129
325
  let parsed;
130
326
  try {
131
327
  parsed = JSON.parse(result);
@@ -137,11 +333,14 @@ export function evalExecTimeoutMessage(result) {
137
333
  return null;
138
334
  if (!/CDP command timed out:\s*Runtime\.evaluate/i.test(parsed.error))
139
335
  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.`);
336
+ const budget = Math.round(budgetMs / 1000);
337
+ return (`the script hit its ${budget}s in-page budget the body (including its own await/setTimeout ` +
338
+ `pauses) ran longer than that. --wait sleeps BEFORE the script and does not extend it.\n` +
339
+ (budgetOverrunHint('in-page budget', budgetMs) ?? '') + '\n' +
340
+ `If the slow part is a ONE-TIME page init (a WASM/model download, a big asset, a first-frame ` +
341
+ `pipeline warm-up), do NOT split the body across several 'page eval' calls — every call is a fresh ` +
342
+ `page load that re-pays that init, so each one hits this same wall. Have the page kick it off on ` +
343
+ `load (not behind a click) and absorb it in the pre-eval window instead.`);
145
344
  }
146
345
  // Agents instinctively reach for a flag to pass the script (`--js`, `--script`,
147
346
  // `--code`, …); the JS is actually the positional <expr> (or --file for a saved
@@ -149,7 +348,9 @@ export function evalExecTimeoutMessage(result) {
149
348
  // a trap, since --json is a real flag that changes output but still leaves the
150
349
  // script unset, sending the agent in a loop. Capture the common guesses as
151
350
  // hidden decoy options so the action can redirect to the positional arg exactly.
152
- const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr', '--eval', '--exec'];
351
+ // `--action` rides along: it's the sibling `page screenshot`'s spelling of
352
+ // "run JS on the page", so an agent that learned it there guesses it here.
353
+ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr', '--eval', '--exec', '--action'];
153
354
  // The long-tail escape hatch alongside `page inspect`'s fixed bundle: when the
154
355
  // curated metrics don't cover what you need (computed styles, element rects,
155
356
  // visibility, z-index stacks), eval an expression in page context and get the
@@ -163,15 +364,19 @@ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr',
163
364
  export const pageEvalCommand = new Command('eval')
164
365
  .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
366
  .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>.')
367
+ .argument('[expr]', `JavaScript to evaluate in page context (inline expression or statement body; await works, and the trailing expression is returned automatically — REPL-style — so no explicit return is needed; 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.`)
368
+ .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).`)
369
+ .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
370
  .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
371
  .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
372
  .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')
174
- .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.')
373
+ .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".`)
374
+ .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.')
375
+ .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')
376
+ .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).`)
377
+ .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (max ${WAIT_FOR_MAX_MS})`, '5000')
378
+ .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.`)
379
+ .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. Without this flag the page loads as a genuinely anonymous, signed-out visitor — nothing carries over from earlier --auth runs.')
175
380
  .option('--json', 'Output as JSON')
176
381
  .action((url, exprArg, opts) => run('Page eval', async () => {
177
382
  // A JS-intent flag guess (captured as a hidden decoy below): redirect to the
@@ -223,22 +428,71 @@ export const pageEvalCommand = new Command('eval')
223
428
  pageEvalCommand.error(`error: Cannot read file: ${opts.reloadFile}`);
224
429
  }
225
430
  }
226
- const waitMs = capWaitMs(opts.wait, url);
227
- const parsedTimeout = parseInt(opts.waitTimeout, 10);
228
- const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
431
+ const steps = opts.step ?? [];
432
+ if (steps.length > MAX_EVAL_STEPS) {
433
+ pageEvalCommand.error(`error: at most ${MAX_EVAL_STEPS} --step expressions ride on one page load (got ${steps.length}) — ` +
434
+ `do more per step, or split this into two evals`);
435
+ }
436
+ let waitMs = capWaitMs(opts.wait, url);
437
+ const waitForTimeoutMs = capWaitForTimeoutMs(opts.waitTimeout);
438
+ // The in-page budget for the script itself. Always sent, so the caller (and
439
+ // every error message below) is talking about one known number rather than a
440
+ // server-side default nobody can see.
441
+ const hasMedia = !!opts.camera || !!opts.fakeMedia;
442
+ const scriptBudgetMs = capScriptBudgetMs(opts.timeout, hasMedia);
443
+ // Camera runs get a model-load-sized pre-eval window unless the caller chose
444
+ // their own wait (either flag). Doing this by default is the whole point: the
445
+ // eval body must not be where a vision app's warm-up happens.
446
+ const explicitWait = pageEvalCommand.getOptionValueSource('wait') !== 'default';
447
+ const chosenWait = explicitWait || !!opts.waitFor;
448
+ if (opts.camera && !chosenWait) {
449
+ waitMs = CAMERA_DEFAULT_WAIT_MS;
450
+ console.error(muted(`--camera: waiting ${CAMERA_DEFAULT_WAIT_MS / 1000}s before the eval so the camera and the app's ` +
451
+ `vision model finish loading (they cannot warm up inside the eval body — that has a ` +
452
+ `${scriptBudgetMs / 1000}s budget of its own, --timeout to change it). Override with --wait <ms>, or use ` +
453
+ `--wait-for '<ready-selector>' to stop as soon as the app is ready.`));
454
+ }
455
+ // The selector gate REPLACES the blind pre-eval sleep server-side, so a
456
+ // --wait passed alongside it was silently dropped: the script ran the instant
457
+ // the selector appeared and read a half-finished sequence (a round still
458
+ // counting down), which looks exactly like an app bug. Honour both, in the
459
+ // only order that means anything — gate, THEN settle — by moving the sleep to
460
+ // the head of the first script and paying for it out of that script's budget.
461
+ const settleAfterGateMs = opts.waitFor && explicitWait ? waitMs : 0;
462
+ const sentWaitMs = settleAfterGateMs > 0 ? 0 : waitMs;
463
+ const sentScriptBudgetMs = Math.min(scriptBudgetMs + settleAfterGateMs, EVAL_SCRIPT_BUDGET_MAX_MS);
229
464
  // --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`.
465
+ // into the eval scope so the page can fetch the bytes. A prelude makes the
466
+ // body a statement (const/return), so an expression script is spliced into
467
+ // `return (...)` and a statement body is kept as-is (withPrelude). Every leg
468
+ // (<expr> and each --step) gets the fixture bindings. Cleanup in `finally`.
234
469
  const fixturePaths = opts.fixture ?? [];
235
470
  const hosted = [];
471
+ // The webcam feed is hosted like a fixture but is NOT exposed to the eval
472
+ // body as `fixtures`/`fixtureUrl` — the page never fetches it; the browser
473
+ // plays it as the camera device. Tracked separately so it still gets
474
+ // cleaned up, without shifting the fixture map the caller indexes into.
475
+ let camera;
236
476
  let projectGuid;
477
+ // Completion-value semantics (a body ending in a bare expression returns it,
478
+ // REPL-style) are applied SERVER-side to every leg — one source of truth for
479
+ // every caller; the CLI sends the script as written.
237
480
  let sentExpr = expr;
481
+ let sentSteps = steps;
482
+ // Validate the camera file locally first: a wrong file type should cost one
483
+ // instant error, not an upload plus an opaque browser-side failure.
484
+ if (opts.camera)
485
+ assertCameraFile(opts.camera);
238
486
  try {
239
- if (fixturePaths.length) {
487
+ if (fixturePaths.length || opts.camera) {
240
488
  const { config } = await resolveProjectContext({});
241
489
  projectGuid = config.projectGuid;
490
+ }
491
+ if (opts.camera) {
492
+ console.log(muted(`Hosting camera feed ${opts.camera}…`));
493
+ camera = await uploadCameraFeed(projectGuid, opts.camera);
494
+ }
495
+ if (fixturePaths.length) {
242
496
  for (const p of fixturePaths) {
243
497
  console.log(muted(`Hosting fixture ${p}…`));
244
498
  hosted.push(await uploadPublicFixture(projectGuid, p));
@@ -247,27 +501,72 @@ export const pageEvalCommand = new Command('eval')
247
501
  for (const h of hosted)
248
502
  map[h.name] = h.url;
249
503
  const prelude = `const fixtures=${JSON.stringify(map)};const fixtureUrl=${JSON.stringify(hosted[0].url)};`;
250
- sentExpr = opts.file ? `${prelude}\n${expr}` : `${prelude}\nreturn (${expr});`;
504
+ sentExpr = withPrelude(sentExpr, prelude);
505
+ sentSteps = sentSteps.map((s) => withPrelude(s, prelude));
506
+ }
507
+ // The post-gate settle rides at the head of the FIRST script only: the
508
+ // later steps run on a page the gate + settle already carried forward.
509
+ if (settleAfterGateMs > 0) {
510
+ sentExpr = withPrelude(sentExpr, `await new Promise((r) => setTimeout(r, ${settleAfterGateMs}));`);
251
511
  }
252
512
  const kickoff = await post('/tools/browser/eval', {
253
- url, expr: sentExpr, waitMs,
513
+ url, expr: sentExpr, waitMs: sentWaitMs,
514
+ steps: sentSteps.length ? sentSteps : undefined,
254
515
  reloadExpr,
255
516
  waitForSelector: opts.waitFor || undefined,
256
517
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
518
+ timeoutMs: sentScriptBudgetMs,
257
519
  auth: opts.auth || undefined,
520
+ // A camera feed needs the synthetic devices in place to be played into,
521
+ // so --camera implies --fake-media rather than failing on the pairing.
522
+ fakeMedia: opts.fakeMedia || !!camera || undefined,
523
+ cameraUrl: camera?.url,
258
524
  });
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);
525
+ let d;
526
+ try {
527
+ d = await pollEvalResult(kickoff.data.evalJobId, evalWorkBudgetMs({
528
+ waitMs: sentWaitMs,
529
+ waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : 0,
530
+ hasReload: reloadExpr !== undefined,
531
+ hasCamera: !!camera,
532
+ scriptBudgetMs: sentScriptBudgetMs,
533
+ legs: 1 + sentSteps.length,
534
+ }));
535
+ }
536
+ catch (err) {
537
+ // The server's overrun message names the budget but not the flag that
538
+ // raises it — and the flag is what the caller needs next. Name it, with
539
+ // the value THIS run used, so the retry is an edit rather than a guess.
540
+ const msg = err?.message ?? '';
541
+ const hint = budgetOverrunHint(msg, scriptBudgetMs);
542
+ if (!hint)
543
+ throw err;
544
+ throw new Error(`${msg}\n${hint}`);
545
+ }
261
546
  const { result, noValue } = normalizeEvalResult(d.result);
262
547
  const reload = d.reloadResult !== undefined ? normalizeEvalResult(d.reloadResult) : undefined;
263
- const execTimeout = evalExecTimeoutMessage(d.result);
548
+ const execTimeout = evalExecTimeoutMessage(d.result, scriptBudgetMs);
264
549
  if (execTimeout)
265
550
  throw new Error(execTimeout);
551
+ // Each --step is reported on its own, against the expression that produced
552
+ // it — N results in one blob would be unreadable and unattributable.
553
+ const stepOut = steps.map((stepExpr, i) => {
554
+ const raw = d.stepResults?.[i];
555
+ const norm = raw !== undefined ? normalizeEvalResult(raw) : undefined;
556
+ return { expr: stepExpr, result: norm?.result ?? '', noValue: norm?.noValue ?? true };
557
+ });
558
+ const emptyState = !noValue && isEmptyStateResult(result);
559
+ // An empty read on a camera run has a different cause and a different fix
560
+ // than an empty read on a normal page: the generic hint's "poll harder,
561
+ // raise --timeout" is guaranteed-wasted advice against a looped still.
562
+ const emptyHint = camera ? EVAL_CAMERA_EMPTY_HINT : EVAL_EMPTY_STATE_HINT;
266
563
  if (opts.json) {
267
564
  console.log(JSON.stringify({
268
565
  ...d, result,
566
+ ...(stepOut.length ? { stepResults: stepOut.map((s) => s.result) } : {}),
269
567
  ...(reload ? { reloadResult: reload.result } : {}),
270
568
  ...(noValue ? { hint: EVAL_NO_VALUE_HINT } : {}),
569
+ ...(emptyState ? { hint: emptyHint } : {}),
271
570
  }));
272
571
  return;
273
572
  }
@@ -280,27 +579,39 @@ export const pageEvalCommand = new Command('eval')
280
579
  // `setTimeout(2000)` may advance only a fraction of a second of app time.
281
580
  // Without this line the eval returns a plausible-looking false negative.
282
581
  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)')}).`);
582
+ console.log(slowRenderMessage(d.slowRender.fps, { camera: !!camera, waitMs }));
287
583
  }
288
- // Auth state: without this line an agent can't distinguish "signed-in
289
- // eval" from "--auth silently no-op'd against the anonymous page".
584
+ // Identity line on EVERY run: without it an agent can't distinguish
585
+ // "signed-in eval" from "--auth silently no-op'd against the anonymous
586
+ // page" — or, worse, assume an unflagged run carried a signed-in session.
290
587
  if (d.auth?.requested) {
291
588
  const who = getAuth()?.email;
292
589
  console.log(d.auth.established
293
590
  ? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
294
591
  : `${warning('Auth: session NOT established')}${d.auth.detail ? ` — ${d.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
295
592
  }
593
+ else {
594
+ console.log(muted('Auth: anonymous visitor (signed out; pass --auth to run as your Gipity account)'));
595
+ }
596
+ if (camera)
597
+ console.log(`${muted('Camera:')} ${camera.name} ${muted('(played as the webcam feed; getUserMedia resolves)')}`);
296
598
  if (hosted.length)
297
599
  console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
298
600
  console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
299
601
  console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
300
602
  if (noValue)
301
603
  console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
604
+ if (emptyState)
605
+ console.log(muted(`\n${emptyHint}`));
302
606
  if (d.truncated)
303
607
  console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
608
+ for (let i = 0; i < stepOut.length; i++) {
609
+ const s = stepOut[i];
610
+ console.log(`\n${bold(`Step ${i + 1}`)} ${muted(summarizeExpr(s.expr))} ${muted('(same page load)')}`);
611
+ console.log(s.result.trim() ? s.result : muted('(empty result)'));
612
+ if (s.noValue)
613
+ console.log(muted(EVAL_NO_VALUE_HINT));
614
+ }
304
615
  if (reload) {
305
616
  console.log(`\n${bold('After reload')} ${muted('(page reloaded in place — storage preserved)')}`);
306
617
  console.log(reload.result.trim() ? reload.result : muted('(empty result)'));
@@ -311,7 +622,7 @@ export const pageEvalCommand = new Command('eval')
311
622
  }
312
623
  }
313
624
  finally {
314
- for (const h of hosted) {
625
+ for (const h of [...hosted, ...(camera ? [camera] : [])]) {
315
626
  try {
316
627
  await deleteFixture(projectGuid, h.guid);
317
628
  }
@@ -347,15 +658,52 @@ Examples:
347
658
  "localStorage.setItem('todo','milk'); document.title" \\
348
659
  --reload "({ restored: localStorage.getItem('todo'), heading: document.querySelector('h1')?.textContent })"
349
660
 
661
+ # Camera app (MediaPipe / YOLOX / any getUserMedia app): play a real image as
662
+ # the webcam so the app's OWN pipeline runs on a frame you control — no need to
663
+ # stub the model or export internals just to test around a missing camera.
664
+ # --camera waits ${CAMERA_DEFAULT_WAIT_MS / 1000}s before evaluating (model load + first frame); the script
665
+ # itself then gets ${EVAL_SCRIPT_BUDGET_CAMERA_MS / 1000}s in the page (--timeout, max ${EVAL_SCRIPT_BUDGET_MAX_MS / 1000}s).
666
+ gipity generate image "a hand making a closed fist, palm to camera, plain background" -o fist.png
667
+ gipity page eval "https://dev.gipity.ai/me/app/" --camera fist.png \\
668
+ "document.querySelector('#detected-gesture').textContent"
669
+ # Camera app that only starts on a click? Start it on page load instead (a headless
670
+ # run has no user), and expose a ready signal so the wait ends the moment it's up:
671
+ gipity page eval "https://dev.gipity.ai/me/app/" --camera fist.png \\
672
+ --wait-for "#detected-gesture:not(:empty)" --wait-timeout ${WAIT_FOR_MAX_MS} \\
673
+ "document.querySelector('#detected-gesture').textContent"
674
+ # Several assertions about the SAME page? Use --step, not several commands: a camera
675
+ # page load pays for getUserMedia + the vision model ONCE and every step reuses it.
676
+ gipity page eval "https://dev.gipity.ai/me/app/" --camera fist.png \\
677
+ --wait-for '[data-vision="ready"]' \\
678
+ "window.__vision.gesture()" \\
679
+ --step "document.getElementById('see').textContent" \\
680
+ --step "({ score: score.textContent, verdict: verdict.textContent })"
681
+
350
682
  Module resolution: dynamic import() specifiers starting with ./ or ../ resolve
351
683
  against the PAGE URL, so import('./packages/i18n/index.js') loads the app's own
352
684
  module without hand-building the deployed /account/project/ path. Absolute paths
353
685
  and full URLs pass through unchanged.
354
686
 
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.
687
+ 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),
688
+ counting its own await/setTimeout pauses. Two separate knobs:
689
+ --timeout <ms> how long the SCRIPT may run in the page (max ${EVAL_SCRIPT_BUDGET_MAX_MS}) raise this to
690
+ trace a sequence that unfolds over time (a game round, an animation)
691
+ --wait / --wait-for how long to settle BEFORE the script runs — a blind sleep, a
692
+ selector gate, or both (gate first, then sleep). Neither extends --timeout.
693
+ Which one:
694
+ - waiting for the app to REACH a state before you read it → --wait-for '<selector>'
695
+ (gate on the end state — '#verdict:not(:empty)' — not just a ready flag; a bare
696
+ --wait samples one arbitrary instant and can land mid-sequence). Passing both means
697
+ gate first, then settle --wait ms.
698
+ - watching state evolve (poll until a result lands, record a trace) → --timeout
699
+ - a slow ONE-TIME init (WASM/model download, big asset) → do NOT split the body across
700
+ calls; every call re-loads the page and re-pays it. Start that work on page load and
701
+ absorb it in the pre-eval window: --wait <ms> (max ${MAX_WAIT_MS}) or, better,
702
+ --wait-for '<ready-selector>' --wait-timeout ${WAIT_FOR_MAX_MS}.
703
+
704
+ Several things to check on one page? Pass --step (max ${MAX_EVAL_STEPS}) instead of running
705
+ 'page eval' N times: the steps run in order against the ONE loaded page, so a slow
706
+ boot (vision model, game, socket) is paid once, not once per assertion.
359
707
 
360
708
  Testing realtime/shared state across clients?
361
709
  Separate 'page eval' calls run sequentially (one finishes before the next