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.
- package/dist/agents/claude-code.js +53 -0
- package/dist/agents/codex.js +49 -0
- package/dist/agents/grok.js +54 -0
- package/dist/agents/index.js +23 -0
- package/dist/agents/types.js +18 -0
- package/dist/api.js +7 -0
- package/dist/capture/sources/codex.js +216 -0
- package/dist/capture/sources/grok.js +178 -0
- package/dist/client-context.js +2 -0
- package/dist/commands/build.js +1352 -0
- package/dist/commands/chat.js +9 -3
- package/dist/commands/claude.js +4 -13
- package/dist/commands/db.js +12 -5
- package/dist/commands/deploy.js +19 -1
- package/dist/commands/doctor.js +8 -2
- package/dist/commands/generate.js +61 -4
- package/dist/commands/init.js +9 -15
- package/dist/commands/page-eval.js +404 -56
- package/dist/commands/page-inspect.js +15 -5
- package/dist/commands/page-screenshot.js +269 -64
- package/dist/commands/project.js +1 -1
- package/dist/commands/relay-install.js +1 -1
- package/dist/commands/relay.js +2 -2
- package/dist/commands/sandbox.js +62 -16
- package/dist/commands/setup.js +16 -10
- package/dist/commands/uninstall.js +25 -3
- package/dist/hooks/capture-runner.js +136 -39
- package/dist/index.js +1962 -668
- package/dist/knowledge.js +3 -3
- package/dist/page-fixtures.js +92 -3
- package/dist/prefs.js +50 -0
- package/dist/project-setup.js +2 -10
- package/dist/provider-docs.js +10 -10
- package/dist/relay/daemon.js +140 -18
- package/dist/relay/device-http.js +22 -0
- package/dist/relay/diagnostics.js +4 -2
- package/dist/relay/media-upload.js +184 -0
- package/dist/relay/onboarding.js +2 -2
- package/dist/setup.js +262 -18
- package/package.json +4 -3
|
@@ -41,9 +41,9 @@ 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',
|
|
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
|
-
.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.')
|
|
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. Without this flag the page loads as a genuinely anonymous, signed-out visitor — nothing carries over from earlier --auth runs.')
|
|
47
47
|
// Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
|
|
48
48
|
// an image here (`page screenshot` is the single path for that) — just point there.
|
|
49
49
|
.addOption(new Option('--screenshot [path]', 'Capture a screenshot').hideHelp())
|
|
@@ -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
|
|
78
|
+
fakeMedia: opts.fakeMedia !== false ? true : undefined,
|
|
73
79
|
device: opts.device ? resolveTouchDevice(opts.device) : undefined,
|
|
74
80
|
auth: opts.auth || undefined,
|
|
75
81
|
};
|
|
@@ -158,14 +164,18 @@ export async function inspectPage(url, opts = {}) {
|
|
|
158
164
|
if (b.navigationIncomplete) {
|
|
159
165
|
console.log(`${warning('⚠ Navigation incomplete:')} ${b.note || 'page did not reach full load'}`);
|
|
160
166
|
}
|
|
161
|
-
//
|
|
162
|
-
// from "--auth silently no-op'd and I'm looking at the
|
|
167
|
+
// Identity line on EVERY run: without it an agent can't distinguish
|
|
168
|
+
// "signed-in render" from "--auth silently no-op'd and I'm looking at the
|
|
169
|
+
// anonymous page" — or assume an unflagged run carried a signed-in session.
|
|
163
170
|
if (b.auth?.requested) {
|
|
164
171
|
const who = getAuth()?.email;
|
|
165
172
|
console.log(b.auth.established
|
|
166
173
|
? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
|
|
167
174
|
: `${warning('Auth: session NOT established')}${b.auth.detail ? ` — ${b.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
|
|
168
175
|
}
|
|
176
|
+
else {
|
|
177
|
+
console.log(muted('Auth: anonymous visitor (signed out; pass --auth to load as your Gipity account)'));
|
|
178
|
+
}
|
|
169
179
|
console.log(`${muted('Title:')} ${b.title || '(none)'}`);
|
|
170
180
|
console.log(`${muted('Elements:')} ${b.elementCount || 0}`);
|
|
171
181
|
console.log(`${muted('Page weight:')} ${info(formatSize(b.totalBytes || 0))}`);
|
|
@@ -2,12 +2,13 @@ import { Command, Option } from 'commander';
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from 'fs';
|
|
3
3
|
import { dirname, join, resolve as resolvePath } from 'path';
|
|
4
4
|
import { postForTarEntries } from '../api.js';
|
|
5
|
-
import { getProjectRoot } from '../config.js';
|
|
5
|
+
import { getConfig, getProjectRoot } from '../config.js';
|
|
6
6
|
import { getAuth } from '../auth.js';
|
|
7
7
|
import { brand, bold, muted, success, warning } from '../colors.js';
|
|
8
8
|
import { formatSize } from '../utils.js';
|
|
9
9
|
import { run } from '../helpers/index.js';
|
|
10
10
|
import { withSpinner } from '../progress.js';
|
|
11
|
+
import { uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
|
|
11
12
|
/** `mobile` and `tablet` name a real handset/tablet rather than just a window size.
|
|
12
13
|
* The server emulates it end to end - mobile user-agent, DPR, and crucially TOUCH,
|
|
13
14
|
* so a page that gates its mobile UI on `'ontouchstart' in window` or
|
|
@@ -29,25 +30,50 @@ function label(text) {
|
|
|
29
30
|
function fmtMs(ms) {
|
|
30
31
|
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
|
|
31
32
|
}
|
|
32
|
-
/**
|
|
33
|
-
* can't tell a signed-in capture from "--auth silently no-op'd and
|
|
34
|
-
* the anonymous page"
|
|
33
|
+
/** Identity line on EVERY capture (same format as page inspect/eval): without
|
|
34
|
+
* it an agent can't tell a signed-in capture from "--auth silently no-op'd and
|
|
35
|
+
* this is the anonymous page" — or assumes an unflagged capture carried a
|
|
36
|
+
* signed-in session. */
|
|
35
37
|
function printAuthLine(auth) {
|
|
36
|
-
if (!auth?.requested)
|
|
38
|
+
if (!auth?.requested) {
|
|
39
|
+
console.log(`${label('Auth')} ${muted('anonymous visitor (signed out; pass --auth to capture as your Gipity account)')}`);
|
|
37
40
|
return;
|
|
41
|
+
}
|
|
38
42
|
const who = getAuth()?.email;
|
|
39
43
|
console.log(auth.established
|
|
40
44
|
? `${label('Auth')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
|
|
41
45
|
: `${warning('Auth: session NOT established')}${auth.detail ? ` — ${auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
|
|
42
46
|
}
|
|
43
|
-
/** A failed
|
|
47
|
+
/** A failed pre-capture script still yields a screenshot - of the page it never
|
|
44
48
|
* touched. Silence there is the trap: the image looks plausible and the command
|
|
45
|
-
* reports success, so the caller trusts a capture of the wrong state. Say so
|
|
46
|
-
|
|
49
|
+
* reports success, so the caller trusts a capture of the wrong state. Say so —
|
|
50
|
+
* and say WHICH half failed, since --wait-for and --action ride the same script:
|
|
51
|
+
* a gate that never matched is a different problem (and a different fix) from a
|
|
52
|
+
* click that threw. */
|
|
53
|
+
export function printActionErrorLine(actionError) {
|
|
47
54
|
if (!actionError)
|
|
48
55
|
return;
|
|
56
|
+
if (/wait-for:/.test(actionError)) {
|
|
57
|
+
console.log(`${warning('⚠ --wait-for never matched:')} ${actionError.replace(/^EvalError:\s*/, '')} ${muted('(the image below is the state the page WAS in when the gate gave up)')}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
49
60
|
console.log(`${warning('⚠ --action failed:')} ${actionError} ${muted('(this image shows the page BEFORE the action ran)')}`);
|
|
50
61
|
}
|
|
62
|
+
/** The browser sandbox has one wall-clock budget for the entire capture, and
|
|
63
|
+
* --action's own runtime spends it. When the sandbox times out on a run that
|
|
64
|
+
* passed --action, the server's message ("platform-side failure, the page was
|
|
65
|
+
* never reached") is the whole story only if the action was cheap — so append
|
|
66
|
+
* the lever the caller actually has. Exported for tests. */
|
|
67
|
+
export function augmentSandboxTimeout(message) {
|
|
68
|
+
if (!/did not respond within/i.test(message))
|
|
69
|
+
return message;
|
|
70
|
+
return (`${message}\n` +
|
|
71
|
+
`A pre-capture script was set (--action and/or the --wait-for gate), and its runtime is spent INSIDE ` +
|
|
72
|
+
`that same sandbox budget — a script that waits on slow work (a WASM/model download, a network fetch, ` +
|
|
73
|
+
`a long animation) can exhaust the budget on its own. Keep --action to the interaction itself (a click, ` +
|
|
74
|
+
`a keypress), let the page do the slow work on load, and absorb that with --wait <ms> or a tighter ` +
|
|
75
|
+
`--wait-for '<selector>' gate instead.`);
|
|
76
|
+
}
|
|
51
77
|
function fmtPerformance(p) {
|
|
52
78
|
const parts = [
|
|
53
79
|
`TTFB ${fmtMs(p.ttfb)}`,
|
|
@@ -74,13 +100,15 @@ function dimSuffix(vp) {
|
|
|
74
100
|
const dpr = vp.deviceScaleFactor ?? 1;
|
|
75
101
|
return dpr === 1 ? `${vp.width}x${vp.height}` : `${vp.width}x${vp.height}@${dpr}`;
|
|
76
102
|
}
|
|
77
|
-
/** Default screenshot directory: `<project-root
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
103
|
+
/** Default screenshot directory: `<project-root>/screenshots`, falling back
|
|
104
|
+
* to `./screenshots` in one-off mode (no linked project). Screenshots are
|
|
105
|
+
* part of the project's build history: the dir syncs to Gipity (the server
|
|
106
|
+
* also persists captures to VFS `screenshots/` directly — same names, so
|
|
107
|
+
* sync reconciles by content hash) but is excluded from every deploy
|
|
108
|
+
* server-side. Timestamped filenames make the history browsable. */
|
|
81
109
|
function defaultScreenshotDir() {
|
|
82
110
|
const root = getProjectRoot();
|
|
83
|
-
return join(root ?? '.', '
|
|
111
|
+
return join(root ?? '.', 'screenshots');
|
|
84
112
|
}
|
|
85
113
|
/** `yyyy-mm-dd_hh-mm-ss` per the repo timestamp convention - sorts chronologically,
|
|
86
114
|
* filesystem-safe. One stamp per invocation; viewport suffixes keep multi-shot
|
|
@@ -151,49 +179,149 @@ function appendOption(value, previous = []) {
|
|
|
151
179
|
return [...previous, value];
|
|
152
180
|
}
|
|
153
181
|
// Pre-capture scripting lives on --action, but agents reach for the JS-intent
|
|
154
|
-
// names first
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
182
|
+
// names first — --eval above all, the name they already know from `page eval`.
|
|
183
|
+
// The intent is unambiguous (run this JS in the page before the shot), so these
|
|
184
|
+
// are working hidden aliases, not errors — same accept-the-reflex-guess pattern
|
|
185
|
+
// as --full-page and --width/--height. (Supersedes the earlier decoy approach,
|
|
186
|
+
// which swallowed the script and errored with a redirect: when the guess is
|
|
187
|
+
// unambiguous, making it WORK beats making it a better error.)
|
|
188
|
+
const ACTION_ALIAS_FLAGS = ['--eval', '--js', '--javascript', '--script', '--code', '--exec'];
|
|
189
|
+
/** A capture is worthless if it fires before the app reaches the state you meant
|
|
190
|
+
* to photograph, and the only lever used to be a blind millisecond delay — so a
|
|
191
|
+
* camera/vision app got screenshotted with a guessed duration (`--wait 22000`).
|
|
192
|
+
* `--wait-for '<selector>'` is the same deterministic gate `page eval` and
|
|
193
|
+
* `page inspect` take, and it runs here as the head of the pre-capture script:
|
|
194
|
+
* poll for the element, then let any --action run. Timing out THROWS, so the
|
|
195
|
+
* server reports it (the shot still happens) instead of silently handing back a
|
|
196
|
+
* picture of the wrong moment. Exported for tests. */
|
|
197
|
+
export function buildWaitForGate(selector, timeoutMs) {
|
|
198
|
+
const sel = JSON.stringify(selector);
|
|
199
|
+
return (`await (async () => { const __t0 = Date.now(); ` +
|
|
200
|
+
`while (Date.now() - __t0 < ${timeoutMs}) { ` +
|
|
201
|
+
`try { if (document.querySelector(${sel})) return; } catch (e) { throw new Error('wait-for: invalid selector ' + ${sel}); } ` +
|
|
202
|
+
`await new Promise((r) => setTimeout(r, 100)); } ` +
|
|
203
|
+
`throw new Error('wait-for: nothing matched ' + ${sel} + ' within ${timeoutMs}ms — the page never reached that state (raise --wait-timeout, fix the selector, or check the app actually gets there headlessly)'); })();`);
|
|
204
|
+
}
|
|
205
|
+
/** Max ms the selector gate may wait. The gate runs inside the capture's sandbox
|
|
206
|
+
* exec budget (page load + delay + script + settle + render), and 15s is what
|
|
207
|
+
* that budget actually covers — asking for more would blow the whole capture and
|
|
208
|
+
* come back as "the browser sandbox did not respond", blaming the platform for a
|
|
209
|
+
* wait the CLI accepted. A state that takes longer than this to appear is not a
|
|
210
|
+
* screenshot problem: gate it on `page eval --wait-for` (30s) and read it there. */
|
|
211
|
+
export const WAIT_FOR_MAX_MS = 15_000;
|
|
212
|
+
export const WAIT_FOR_DEFAULT_MS = 15_000;
|
|
213
|
+
/** The server's cap on the post-load delay. Over it, the request is rejected by
|
|
214
|
+
* schema validation with no mention of the flag — clamp and explain instead. */
|
|
215
|
+
export const MAX_POST_LOAD_DELAY_MS = 30_000;
|
|
216
|
+
/** A camera app has nothing to photograph 1s after load: getUserMedia has to come
|
|
217
|
+
* up and the vision model (WASM + weights) has to download before the app can
|
|
218
|
+
* draw a single box or label. Same window `page eval --camera` takes — without it
|
|
219
|
+
* every camera screenshot is a picture of a loading screen, and the caller is
|
|
220
|
+
* left guessing a duration. */
|
|
221
|
+
export const CAMERA_DEFAULT_DELAY_MS = 15_000;
|
|
158
222
|
export const pageScreenshotCommand = new Command('screenshot')
|
|
159
223
|
.description('Screenshot a web page')
|
|
160
224
|
.argument('<url>', 'URL to screenshot')
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
225
|
+
// --device / --viewport lead the options: "how does this look on a phone?" is
|
|
226
|
+
// the single most common reason to screenshot, and agents routinely read the
|
|
227
|
+
// help through `--help | head -N` — buried below the timing flags, these two
|
|
228
|
+
// fell off the bottom and the run proceeded at desktop width.
|
|
229
|
+
.option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
|
|
230
|
+
.option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag), e.g. --viewport 390x844 for a phone-sized window', appendOption, [])
|
|
231
|
+
// No commander default: a default here would make the value always set, so the
|
|
232
|
+
// merge below could not tell "caller chose a delay" from "nobody did" — which
|
|
233
|
+
// is what --camera's model-load default and the --wait-for gate both hinge on.
|
|
234
|
+
.option('--wait <ms>', `Sleep this many ms after DOMContentLoaded before capturing (default 1000, max ${MAX_POST_LOAD_DELAY_MS}). With --camera it defaults to ${CAMERA_DEFAULT_DELAY_MS} so the vision model is up. Same flag as page eval/inspect.`)
|
|
235
|
+
.option('--wait-for <selector>', `Wait until this CSS selector appears before capturing, then capture (deterministic - beats guessing a --wait). Same flag as page eval/inspect. Gate on what you are photographing ('[data-vision="ready"]', '#verdict:not(:empty)'). Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`)
|
|
236
|
+
.option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (default ${WAIT_FOR_DEFAULT_MS}, max ${WAIT_FOR_MAX_MS}). Timing out is reported - the capture still happens, so you see the state it got stuck in.`)
|
|
165
237
|
.option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs as an async function body, so const/await and app-relative import(\'./…\') work. Runs after the post-load delay, then settles again before the shot. If it throws, the capture still happens and the failure is reported.')
|
|
166
238
|
.option('--full', 'Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.')
|
|
167
239
|
.option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
|
|
168
|
-
.option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
|
|
169
|
-
.option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
|
|
170
240
|
.option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
|
|
171
|
-
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly
|
|
172
|
-
.option('--
|
|
241
|
+
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly. The video feed is a built-in test pattern — to capture what the app does with a REAL frame (a hand, a face, an object), use --camera <path> instead.')
|
|
242
|
+
.option('--camera <path>', 'Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser\'s WEBCAM feed, then capture — so the shot shows the app reacting to a frame you chose (detected gesture, boxes, labels). Implies --fake-media.')
|
|
243
|
+
.option('--auth', 'Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. 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.')
|
|
244
|
+
.option('--ephemeral', 'Skip the project screenshot history: do not persist this capture to Gipity (screenshots/ in the project). Local file is still written.')
|
|
173
245
|
.option('--json', 'Output JSON metadata instead of a friendly summary')
|
|
174
|
-
.addOption(new Option('--
|
|
246
|
+
.addOption(new Option('--post-load-delay <ms>', 'Alias for --wait').hideHelp())
|
|
247
|
+
// (--eval and the other JS-intent guesses are registered in one place from
|
|
248
|
+
// ACTION_ALIAS_FLAGS below — a second standalone registration here makes
|
|
249
|
+
// commander throw "conflicting flag" at startup.)
|
|
175
250
|
// `--full-page` is the Puppeteer/Playwright name for this (their `fullPage`),
|
|
176
251
|
// so agents reach for it by reflex. Accept it as a hidden alias for `--full`
|
|
177
252
|
// rather than reject it as an unknown option and send them on a --help detour.
|
|
178
253
|
.addOption(new Option('--full-page', 'Alias for --full').hideHelp())
|
|
254
|
+
// `--width 390 --height 844` is the setViewport vocabulary agents guess first
|
|
255
|
+
// when asked for a phone-sized shot. Accept the pair as a working alias for
|
|
256
|
+
// --viewport WxH instead of bouncing the guess into a --help detour.
|
|
257
|
+
.addOption(new Option('--width <px>', 'Alias: --viewport WxH').hideHelp())
|
|
258
|
+
.addOption(new Option('--height <px>', 'Alias: --viewport WxH').hideHelp())
|
|
179
259
|
.action((url, opts) => run('Page screenshot', async () => {
|
|
180
|
-
// A JS-intent
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
260
|
+
// A JS-intent alias guess (--eval et al., hidden): fold it into the action
|
|
261
|
+
// script so the reflex guess just works, and name the canonical flag once
|
|
262
|
+
// so the next call uses it.
|
|
263
|
+
const aliasFlag = ACTION_ALIAS_FLAGS.find((f) => opts[f.slice(2)] !== undefined);
|
|
264
|
+
if (aliasFlag) {
|
|
265
|
+
console.error(muted(`Note: treating ${aliasFlag} as --action — it runs your JS in the page before the capture. --action is the canonical flag.`));
|
|
266
|
+
}
|
|
267
|
+
const actionScript = [opts.action, ...ACTION_ALIAS_FLAGS.map((f) => opts[f.slice(2)])]
|
|
268
|
+
.filter(Boolean).join('\n');
|
|
269
|
+
// --wait is the canonical name (it is what `page inspect`/`page eval` call it);
|
|
270
|
+
// --post-load-delay stays as a hidden alias. Whether the caller named EITHER is
|
|
271
|
+
// what decides the defaults below, so keep the raw "was it set" signal.
|
|
272
|
+
const delayRaw = opts.wait ?? opts.postLoadDelay;
|
|
273
|
+
const chosenDelay = delayRaw !== undefined;
|
|
274
|
+
let postLoadDelayMs = chosenDelay ? parseInt(String(delayRaw), 10) : 1000;
|
|
275
|
+
if (!Number.isFinite(postLoadDelayMs) || postLoadDelayMs < 0) {
|
|
276
|
+
throw new Error('--wait must be a non-negative integer (ms)');
|
|
277
|
+
}
|
|
278
|
+
if (postLoadDelayMs > MAX_POST_LOAD_DELAY_MS) {
|
|
279
|
+
console.error(warning(`--wait ${postLoadDelayMs}ms exceeds the ${MAX_POST_LOAD_DELAY_MS}ms cap — using ${MAX_POST_LOAD_DELAY_MS}ms. ` +
|
|
280
|
+
`Waiting longer is rarely the fix: gate on the state you want with --wait-for '<selector>' instead of guessing a duration.`));
|
|
281
|
+
postLoadDelayMs = MAX_POST_LOAD_DELAY_MS;
|
|
282
|
+
}
|
|
283
|
+
const waitForTimeoutRaw = opts.waitTimeout !== undefined ? parseInt(String(opts.waitTimeout), 10) : WAIT_FOR_DEFAULT_MS;
|
|
284
|
+
if (!Number.isFinite(waitForTimeoutRaw) || waitForTimeoutRaw < 0) {
|
|
285
|
+
throw new Error('--wait-timeout must be a non-negative integer (ms)');
|
|
286
|
+
}
|
|
287
|
+
const waitForTimeoutMs = Math.min(waitForTimeoutRaw, WAIT_FOR_MAX_MS);
|
|
288
|
+
if (waitForTimeoutRaw > WAIT_FOR_MAX_MS) {
|
|
289
|
+
console.error(warning(`--wait-timeout ${waitForTimeoutRaw}ms exceeds the ${WAIT_FOR_MAX_MS}ms the capture's browser budget covers — using ${WAIT_FOR_MAX_MS}ms. ` +
|
|
290
|
+
`A state that takes longer than that to appear is not a screenshot problem: watch for it with ` +
|
|
291
|
+
`\`gipity page eval <url> --wait-for '<selector>' --wait-timeout 30000\` (a wider budget), then capture it.`));
|
|
292
|
+
}
|
|
293
|
+
if (opts.waitTimeout !== undefined && !opts.waitFor) {
|
|
294
|
+
throw new Error("--wait-timeout only means something with --wait-for '<selector>' (it bounds that gate)");
|
|
186
295
|
}
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
296
|
+
// A camera app is a loading screen at the 1s default: the model has to come up
|
|
297
|
+
// first. Give it the same window `page eval --camera` takes, unless the caller
|
|
298
|
+
// said otherwise — either with their own --wait, or by gating on --wait-for,
|
|
299
|
+
// which ends the moment the app is actually ready.
|
|
300
|
+
if (opts.camera && !chosenDelay && !opts.waitFor) {
|
|
301
|
+
postLoadDelayMs = CAMERA_DEFAULT_DELAY_MS;
|
|
302
|
+
console.error(muted(`--camera: waiting ${CAMERA_DEFAULT_DELAY_MS / 1000}s before the capture so the camera and the app's vision ` +
|
|
303
|
+
`model finish loading. Override with --wait <ms>, or use --wait-for '<ready-selector>' to shoot the moment it's ready.`));
|
|
194
304
|
}
|
|
195
305
|
const deviceNames = splitCsv(opts.device);
|
|
196
306
|
const viewportStrs = splitCsv(opts.viewport);
|
|
307
|
+
// --width/--height: fold the alias pair into a --viewport entry. Half a
|
|
308
|
+
// pair is a mis-invocation worth one precise line, not a range error.
|
|
309
|
+
if (opts.width !== undefined || opts.height !== undefined) {
|
|
310
|
+
if (opts.width === undefined || opts.height === undefined) {
|
|
311
|
+
pageScreenshotCommand.error('error: --width and --height go together (both in px, equivalent to --viewport WxH) — ' +
|
|
312
|
+
'or use a preset like --device mobile for a real handset (touch events, mobile user-agent, DPR)');
|
|
313
|
+
}
|
|
314
|
+
if (!/^\d+$/.test(String(opts.width)) || !/^\d+$/.test(String(opts.height))) {
|
|
315
|
+
pageScreenshotCommand.error('error: --width and --height must be integers (px)');
|
|
316
|
+
}
|
|
317
|
+
viewportStrs.push(`${opts.width}x${opts.height}`);
|
|
318
|
+
// A raw phone-sized window is NOT a phone: touch-gated mobile UI still
|
|
319
|
+
// renders its desktop variant. Say so once, on the path where the caller
|
|
320
|
+
// was clearly after a phone.
|
|
321
|
+
if (parseInt(String(opts.width), 10) <= 500) {
|
|
322
|
+
console.error(muted('Tip: --device mobile emulates a real handset (touch events, mobile user-agent, DPR) — a raw viewport only resizes the window.'));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
197
325
|
const customViewports = [
|
|
198
326
|
...deviceNames.map(resolveDevice),
|
|
199
327
|
...viewportStrs.map(parseViewportString),
|
|
@@ -204,23 +332,80 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
204
332
|
// Server defaults to 1280×720 when viewports is omitted - don't send it in
|
|
205
333
|
// the no-flag case so the filename stays unsuffixed (no viewport segment).
|
|
206
334
|
const userSpecifiedViewports = customViewports.length > 0;
|
|
335
|
+
// Filenames are decided before the request so the server can persist the
|
|
336
|
+
// captures to the project VFS under the SAME names the local files get —
|
|
337
|
+
// the local screenshots/ dir and the VFS screenshot history stay 1:1 and
|
|
338
|
+
// sync reconciles them by content hash instead of duplicating.
|
|
339
|
+
const slug = slugFromUrl(url);
|
|
340
|
+
const ts = timestampSlug();
|
|
341
|
+
const shotName = (vp) => defaultFilename(slug, ts, vp && userSpecifiedViewports ? dimSuffix(vp) : undefined);
|
|
342
|
+
const names = userSpecifiedViewports ? customViewports.map(shotName) : [shotName()];
|
|
343
|
+
const projectGuid = getConfig()?.projectGuid;
|
|
344
|
+
const save = !opts.ephemeral && projectGuid ? { project_guid: projectGuid, names } : undefined;
|
|
345
|
+
// The webcam frame is hosted in the project's public file store for the
|
|
346
|
+
// browser container to fetch, so it needs a linked project. Validate the
|
|
347
|
+
// file locally first — a bad file type costs one instant error, not an
|
|
348
|
+
// upload plus an opaque browser-side failure.
|
|
349
|
+
let camera;
|
|
350
|
+
if (opts.camera) {
|
|
351
|
+
assertCameraFile(opts.camera);
|
|
352
|
+
if (!projectGuid)
|
|
353
|
+
throw new Error('--camera needs a linked project (the frame is hosted for the browser to fetch) — run `gipity link` first.');
|
|
354
|
+
console.log(muted(`Hosting camera feed ${opts.camera}…`));
|
|
355
|
+
camera = await uploadCameraFeed(projectGuid, opts.camera);
|
|
356
|
+
}
|
|
357
|
+
// The pre-capture script the server runs after the post-load delay: the
|
|
358
|
+
// --wait-for gate first (so the shot waits for the state, deterministically),
|
|
359
|
+
// then the caller's --action (with any alias-flag scripts folded in by
|
|
360
|
+
// actionScript above). All the same in-page primitive, so they
|
|
361
|
+
// compose into one script rather than needing a second server round-trip.
|
|
362
|
+
const preCapture = [
|
|
363
|
+
opts.waitFor ? buildWaitForGate(opts.waitFor, waitForTimeoutMs) : '',
|
|
364
|
+
actionScript,
|
|
365
|
+
].filter(Boolean).join('\n');
|
|
207
366
|
const body = {
|
|
208
367
|
url,
|
|
209
368
|
postLoadDelayMs,
|
|
210
369
|
full: !!(opts.full || opts.fullPage),
|
|
211
370
|
reloadBetween: opts.reloadBetween !== false,
|
|
212
371
|
...(userSpecifiedViewports ? { viewports: customViewports } : {}),
|
|
213
|
-
|
|
372
|
+
// A camera feed is played into the synthetic devices, so --camera implies
|
|
373
|
+
// --fake-media rather than failing on the pairing.
|
|
374
|
+
...(opts.fakeMedia || camera ? { fakeMedia: true } : {}),
|
|
375
|
+
...(camera ? { cameraUrl: camera.url } : {}),
|
|
214
376
|
...(opts.auth ? { auth: true } : {}),
|
|
215
|
-
...(
|
|
377
|
+
...(preCapture ? { action: preCapture } : {}),
|
|
378
|
+
...(save ? { save } : {}),
|
|
216
379
|
};
|
|
217
380
|
// Load + render across viewports runs server-side and can take many
|
|
218
381
|
// seconds; animate the wait, then clear so the saved-files summary is the
|
|
219
382
|
// result. JSON mode skips the spinner (shares stdout).
|
|
220
383
|
const doShoot = () => postForTarEntries('/tools/browser/screenshot', body);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
384
|
+
let entries;
|
|
385
|
+
try {
|
|
386
|
+
try {
|
|
387
|
+
entries = opts.json
|
|
388
|
+
? await doShoot()
|
|
389
|
+
: await withSpinner('Capturing…', doShoot, { done: null });
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
// The sandbox budget covers the WHOLE capture — load, --action, settle,
|
|
393
|
+
// render. The server's timeout text (rightly) says the page was never
|
|
394
|
+
// reached, which reads as "nothing you can do" and leaves a long-running
|
|
395
|
+
// --action looking innocent. Name it, so the retry is an informed one.
|
|
396
|
+
throw preCapture ? new Error(augmentSandboxTimeout(err.message)) : err;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
if (camera) {
|
|
401
|
+
try {
|
|
402
|
+
await deleteFixture(projectGuid, camera.guid);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
console.error(warning(`⚠ Could not auto-delete camera feed "${camera.name}" (${camera.guid}) — still hosted at ${camera.url}: ${err.message}`));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
224
409
|
const metaEntry = entries.find((e) => e.name === 'meta.json');
|
|
225
410
|
if (!metaEntry)
|
|
226
411
|
throw new Error('Server response missing meta.json');
|
|
@@ -229,16 +414,12 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
229
414
|
if (pngs.length !== meta.screenshots.length) {
|
|
230
415
|
throw new Error(`Server returned ${pngs.length} PNGs but ${meta.screenshots.length} metadata entries`);
|
|
231
416
|
}
|
|
232
|
-
const slug = slugFromUrl(url);
|
|
233
|
-
const ts = timestampSlug();
|
|
234
417
|
const dir = defaultScreenshotDir();
|
|
235
418
|
const savedFiles = [];
|
|
236
419
|
for (let i = 0; i < pngs.length; i++) {
|
|
237
|
-
const shot = meta.screenshots[i];
|
|
238
|
-
const suffix = userSpecifiedViewports ? dimSuffix(shot.viewport) : undefined;
|
|
239
420
|
const target = opts.output
|
|
240
421
|
? opts.output
|
|
241
|
-
: join(dir,
|
|
422
|
+
: join(dir, names[i] ?? shotName(meta.screenshots[i].viewport));
|
|
242
423
|
// Create the target's parent dir so a `-o` path under a not-yet-existing
|
|
243
424
|
// directory (e.g. .gipity/screenshots/home.png) writes cleanly instead of
|
|
244
425
|
// failing with a raw ENOENT and forcing a manual `mkdir -p`.
|
|
@@ -271,10 +452,22 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
271
452
|
size_bytes: s.screenshotSizeBytes,
|
|
272
453
|
full_page: meta.full,
|
|
273
454
|
phase: s.phase,
|
|
455
|
+
...(s.vfs ? { gipity: s.vfs } : {}),
|
|
274
456
|
})),
|
|
275
457
|
}));
|
|
276
458
|
return;
|
|
277
459
|
}
|
|
460
|
+
/** One-line VFS-history status for a screenshot ("saved to Gipity" or why not). */
|
|
461
|
+
const printVfsLine = (s) => {
|
|
462
|
+
if (!s.vfs)
|
|
463
|
+
return;
|
|
464
|
+
if ('error' in s.vfs) {
|
|
465
|
+
console.log(`${warning('⚠ Gipity save failed:')} ${s.vfs.error} ${muted('(local file is fine)')}`);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
console.log(`${label('Gipity history')} ${s.vfs.path} ${muted('(synced, not deployed)')}`);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
278
471
|
if (meta.screenshots.length === 1) {
|
|
279
472
|
const s = meta.screenshots[0];
|
|
280
473
|
console.log(`${brand('Screenshot')} ${bold(url)}`);
|
|
@@ -295,6 +488,7 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
295
488
|
if (s.width && s.height)
|
|
296
489
|
console.log(`${label('Screenshot dims')} ${s.width} × ${s.height}`);
|
|
297
490
|
console.log(`${label('Screenshot file')} ${success(savedFiles[0])}`);
|
|
491
|
+
printVfsLine(s);
|
|
298
492
|
return;
|
|
299
493
|
}
|
|
300
494
|
console.log(`${brand('Loading')} ${bold(url)} ${muted(`once → ${meta.screenshots.length} viewports`)}`);
|
|
@@ -318,20 +512,21 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
318
512
|
if (s.width && s.height)
|
|
319
513
|
console.log(`${label('Screenshot dims')} ${s.width} × ${s.height}`);
|
|
320
514
|
console.log(`${label('Screenshot file')} ${success(savedFiles[i])}`);
|
|
515
|
+
printVfsLine(s);
|
|
321
516
|
}
|
|
322
517
|
}));
|
|
323
|
-
// Register the JS-intent guesses as hidden
|
|
324
|
-
// the script) — the action
|
|
325
|
-
for (const f of
|
|
326
|
-
pageScreenshotCommand.addOption(new Option(`${f} <
|
|
327
|
-
// `screenshot` captures the page AFTER load + settle (+ optional --
|
|
328
|
-
// is no scroll-to-a-position
|
|
329
|
-
// --scroll
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
// --
|
|
334
|
-
//
|
|
518
|
+
// Register the JS-intent guesses as hidden aliases for --action (value-taking,
|
|
519
|
+
// so they capture the script) — the action folds them into the pre-capture body.
|
|
520
|
+
for (const f of ACTION_ALIAS_FLAGS)
|
|
521
|
+
pageScreenshotCommand.addOption(new Option(`${f} <js>`, 'Alias for --action').hideHelp());
|
|
522
|
+
// `screenshot` captures the page AFTER load + settle (+ optional --wait-for gate
|
|
523
|
+
// and --action). There is no scroll-to-a-position lever (agents reach for
|
|
524
|
+
// --scroll and get an unknown-option detour) — but `--full` does walk the page
|
|
525
|
+
// top→bottom→top before the shot so scroll-reveal content paints in. State the
|
|
526
|
+
// supported levers right here, so the help (rendered on any bad flag, and this
|
|
527
|
+
// 'after' block survives `| tail`/`| grep`) ends the hunt in one shot.
|
|
528
|
+
// --wait-for covers "shoot once it's ready"; --action covers "click, then shoot";
|
|
529
|
+
// --full + crop covers off-screen regions; `page eval` reads data, no picture.
|
|
335
530
|
pageScreenshotCommand.addHelpText('after', `
|
|
336
531
|
Examples:
|
|
337
532
|
gipity page screenshot "https://dev.gipity.ai/me/app/"
|
|
@@ -339,11 +534,21 @@ Examples:
|
|
|
339
534
|
gipity page screenshot "https://dev.gipity.ai/me/app/" --device mobile,desktop
|
|
340
535
|
gipity page screenshot "https://dev.gipity.ai/me/app/" \\
|
|
341
536
|
--action "document.getElementById('play').click()" # capture an in-game frame
|
|
537
|
+
# Camera / vision app: play a real frame in as the webcam and shoot once the app
|
|
538
|
+
# says it's ready — same --camera / --wait-for flags as 'page eval', no guessed delay:
|
|
539
|
+
gipity page screenshot "https://dev.gipity.ai/me/app/" --camera fist.png \\
|
|
540
|
+
--wait-for '[data-vision="ready"]'
|
|
541
|
+
|
|
542
|
+
Waiting for the page to reach a state before the shot?
|
|
543
|
+
--wait-for '<selector>' gates the capture on the app's own signal (deterministic).
|
|
544
|
+
Reach for --wait <ms> only when there is nothing to gate on — a guessed duration
|
|
545
|
+
either shoots too early or wastes the difference.
|
|
342
546
|
|
|
343
547
|
Capturing a state that needs an interaction (start a game, open a menu, dismiss a modal)?
|
|
344
|
-
Use --action to run JS in the page before the shot — it fires after the
|
|
345
|
-
|
|
346
|
-
that returns a base64 image: the eval result is capped
|
|
548
|
+
Use --action to run JS in the page before the shot — it fires after the wait
|
|
549
|
+
(and after any --wait-for gate), then settles again so the result has painted. Do
|
|
550
|
+
NOT hand-roll a 'page eval' that returns a base64 image: the eval result is capped
|
|
551
|
+
(~16KB) and truncates the PNG.
|
|
347
552
|
|
|
348
553
|
Capturing an off-screen region or reading element data?
|
|
349
554
|
• --full captures the ENTIRE scrollable page (then crop to the region).
|
package/dist/commands/project.js
CHANGED
|
@@ -134,7 +134,7 @@ projectCommand
|
|
|
134
134
|
console.log(`${muted('Next:')} switch to "${project.name}" in the sidebar.`);
|
|
135
135
|
}
|
|
136
136
|
else {
|
|
137
|
-
console.log(`${muted('Next:')} exit Claude (Ctrl+D), then run: ${brand('gipity
|
|
137
|
+
console.log(`${muted('Next:')} exit Claude (Ctrl+D), then run: ${brand('gipity build')}`);
|
|
138
138
|
console.log(`${muted('Pick')} "${project.name}" ${muted(`- it'll be at the top of the list.`)}`);
|
|
139
139
|
}
|
|
140
140
|
}));
|
|
@@ -6,7 +6,7 @@ import { installAutostart, removeAutostart, resolveCliPath } from '../relay/setu
|
|
|
6
6
|
function requirePaired() {
|
|
7
7
|
const device = state.getDevice();
|
|
8
8
|
if (!device) {
|
|
9
|
-
console.error(`${clrError('No paired device.')} Run ${bold('gipity
|
|
9
|
+
console.error(`${clrError('No paired device.')} Run ${bold('gipity connect')} to pair this machine.`);
|
|
10
10
|
process.exit(1);
|
|
11
11
|
}
|
|
12
12
|
return device;
|
package/dist/commands/relay.js
CHANGED
|
@@ -121,7 +121,7 @@ relayCommand
|
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
if (!s.device) {
|
|
124
|
-
console.log(`${muted('No paired device.')} Run ${brand('gipity
|
|
124
|
+
console.log(`${muted('No paired device.')} Run ${brand('gipity connect')} to pair this machine.`);
|
|
125
125
|
return;
|
|
126
126
|
}
|
|
127
127
|
console.log(`${bold('Device:')} ${brand(s.device.name)} ${muted(`(${s.device.guid})`)}`);
|
|
@@ -380,7 +380,7 @@ registerInstallCommands(relayCommand);
|
|
|
380
380
|
function requirePaired() {
|
|
381
381
|
const device = state.getDevice();
|
|
382
382
|
if (!device) {
|
|
383
|
-
console.error(`${clrError('No paired device.')} Run ${brand('gipity
|
|
383
|
+
console.error(`${clrError('No paired device.')} Run ${brand('gipity connect')} to pair this machine.`);
|
|
384
384
|
process.exit(1);
|
|
385
385
|
}
|
|
386
386
|
return device;
|