gipity 1.1.3 → 1.1.5
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/adopt-cwd.js +1 -1
- package/dist/banner.js +3 -5
- package/dist/catalog.js +1 -3
- package/dist/commands/add.js +1 -1
- package/dist/commands/build.js +111 -40
- package/dist/commands/chat.js +1 -1
- package/dist/commands/deploy.js +7 -3
- package/dist/commands/file.js +18 -0
- package/dist/commands/fn.js +6 -6
- package/dist/commands/generate.js +80 -16
- package/dist/commands/gmail.js +1 -1
- package/dist/commands/job.js +132 -10
- package/dist/commands/load.js +2 -2
- package/dist/commands/login.js +7 -2
- package/dist/commands/page-eval.js +82 -13
- package/dist/commands/page-fetch.js +14 -10
- package/dist/commands/page-inspect.js +28 -3
- package/dist/commands/page-screenshot.js +36 -7
- package/dist/commands/page-test.js +1 -1
- package/dist/commands/records.js +14 -1
- package/dist/commands/remove.js +1 -1
- package/dist/commands/sandbox.js +182 -10
- package/dist/commands/save.js +1 -1
- package/dist/commands/secrets.js +1 -1
- package/dist/commands/service.js +7 -2
- package/dist/commands/setup.js +4 -6
- package/dist/commands/status.js +41 -12
- package/dist/commands/storage.js +2 -2
- package/dist/commands/sync.js +15 -0
- package/dist/commands/test.js +33 -1
- package/dist/commands/update.js +4 -0
- package/dist/commands/upload.js +69 -121
- package/dist/commands/workflow.js +1 -1
- package/dist/helpers/body.js +117 -0
- package/dist/helpers/duration.js +34 -0
- package/dist/helpers/index.js +2 -0
- package/dist/index.js +1173 -620
- package/dist/knowledge.js +7 -7
- package/dist/login-flow.js +44 -2
- package/dist/relay/onboarding.js +26 -44
- package/dist/sync.js +6 -6
- package/dist/updater/bootstrap.js +27 -16
- package/dist/updater/check.js +86 -10
- package/dist/updater/install.js +82 -0
- package/dist/updater/shim.js +90 -27
- package/dist/utils.js +60 -3
- package/package.json +2 -2
package/dist/commands/job.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { get, post, del, getBaseUrl, getAuthHeader } from '../api.js';
|
|
3
3
|
import { requireConfig } from '../config.js';
|
|
4
4
|
import { error as clrError, bold, muted, success, warning as warn } from '../colors.js';
|
|
5
|
-
import { run, printList } from '../helpers/index.js';
|
|
5
|
+
import { run, printList, resolveBody, parseDuration } from '../helpers/index.js';
|
|
6
6
|
export const jobCommand = new Command('job')
|
|
7
7
|
.description('Run long jobs (CPU/GPU)')
|
|
8
8
|
.addHelpText('after', '\nCPU sandbox or GPU (gpu-small=L4, gpu-medium=A10G, gpu-large=A100, gpu-huge=H100). Declared in the gipity.yaml jobs: phase; submit and poll via the subcommands below.');
|
|
@@ -23,14 +23,14 @@ jobCommand
|
|
|
23
23
|
}));
|
|
24
24
|
jobCommand
|
|
25
25
|
.command('submit <name> [body]')
|
|
26
|
-
.description('Submit a job (returns a run guid;
|
|
27
|
-
.option('--data <json>', 'JSON input body')
|
|
26
|
+
.description('Submit a job (returns a run guid; block on it with `job wait <guid>`)')
|
|
27
|
+
.option('-d, --data <json>', 'JSON input body: inline JSON, @file to read a file, or @- / - for stdin')
|
|
28
|
+
.option('--file <field=@path>', 'Attach a file as { data, media_type } under <field> (the vision/media service shape), repeatable, e.g. --file image=@scan.png', (v, acc) => (acc || []).concat(v))
|
|
28
29
|
.option('--idempotency-key <key>', 'Idempotency key (replays return the existing run)')
|
|
29
30
|
.option('--json', 'Output as JSON')
|
|
30
31
|
.action((name, bodyArg, opts) => run('Submit', async () => {
|
|
31
32
|
const config = requireConfig();
|
|
32
|
-
const
|
|
33
|
-
const input = JSON.parse(raw);
|
|
33
|
+
const input = resolveBody(bodyArg || opts.data, opts.file);
|
|
34
34
|
const body = { input };
|
|
35
35
|
if (opts.idempotencyKey)
|
|
36
36
|
body.idempotency_key = opts.idempotencyKey;
|
|
@@ -41,6 +41,12 @@ jobCommand
|
|
|
41
41
|
else {
|
|
42
42
|
const tag = res.data.replayed ? warn(' (replayed)') : '';
|
|
43
43
|
console.log(`${bold(res.data.run_guid)} ${muted(res.data.status)}${tag}`);
|
|
44
|
+
// Awaiting the run is the natural next step after every submit; point the
|
|
45
|
+
// agent at the blocking command so it doesn't hand-roll a status poll loop.
|
|
46
|
+
const TERMINAL = new Set(['success', 'failed', 'cancelled', 'canceled', 'error']);
|
|
47
|
+
if (!TERMINAL.has(res.data.status)) {
|
|
48
|
+
console.error(muted(`Block until it finishes: gipity job wait ${res.data.run_guid}`));
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
}));
|
|
46
52
|
jobCommand
|
|
@@ -57,8 +63,13 @@ jobCommand
|
|
|
57
63
|
const r = res.data;
|
|
58
64
|
const statusColor = r.status === 'success' ? success : r.status === 'failed' ? clrError : muted;
|
|
59
65
|
console.log(`${statusColor(r.status)} ${muted(r.guid)}`);
|
|
60
|
-
if (r.progress_pct != null)
|
|
61
|
-
|
|
66
|
+
if (r.progress_pct != null) {
|
|
67
|
+
const prog = `${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`;
|
|
68
|
+
// A failed run can have reported 100% (done) right before dying; a bare
|
|
69
|
+
// "progress: 100% (done)" next to status=failed reads as success. Label it.
|
|
70
|
+
const failedish = r.status === 'failed' || r.status === 'cancelled' || r.status === 'canceled' || r.status === 'error';
|
|
71
|
+
console.log(failedish ? `last progress before ${r.status}: ${prog}` : `progress: ${prog}`);
|
|
72
|
+
}
|
|
62
73
|
if (r.duration_ms != null)
|
|
63
74
|
console.log(`duration: ${r.duration_ms}ms`);
|
|
64
75
|
if (r.error)
|
|
@@ -66,6 +77,71 @@ jobCommand
|
|
|
66
77
|
if (r.output)
|
|
67
78
|
console.log(`output: ${JSON.stringify(r.output)}`);
|
|
68
79
|
}));
|
|
80
|
+
jobCommand
|
|
81
|
+
.command('wait <runGuid>')
|
|
82
|
+
.description('Block until a job run finishes, or exit at --timeout so the shell never hangs (poll again to keep waiting)')
|
|
83
|
+
.option('--timeout <seconds>', 'Max seconds to wait before giving up and reporting current progress. Bare number = seconds; an explicit unit (90s) means the same here and on `sandbox run --timeout`.', '90')
|
|
84
|
+
.option('--interval <seconds>', 'Seconds between polls', '3')
|
|
85
|
+
.option('--json', 'Output as JSON')
|
|
86
|
+
.action((runGuid, opts) => run('Wait', async () => {
|
|
87
|
+
const config = requireConfig();
|
|
88
|
+
// Terminal states end the wait; anything else means "still working, keep polling".
|
|
89
|
+
const TERMINAL = new Set(['success', 'failed', 'cancelled', 'canceled', 'error']);
|
|
90
|
+
const durOpt = parseDuration(opts.timeout, 's');
|
|
91
|
+
const timeoutSec = durOpt ? Math.max(1, Math.round(durOpt.value)) : parseInt(opts.timeout, 10);
|
|
92
|
+
const timeout = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 90;
|
|
93
|
+
const interval = Math.max(1, parseInt(opts.interval, 10) || 3);
|
|
94
|
+
const deadline = Date.now() + timeout * 1000;
|
|
95
|
+
const fetchRun = async () => (await get(`/projects/${config.projectGuid}/jobs/runs/${encodeURIComponent(runGuid)}`)).data;
|
|
96
|
+
let r = await fetchRun();
|
|
97
|
+
let lastProgress = '';
|
|
98
|
+
while (!TERMINAL.has(r.status) && Date.now() < deadline) {
|
|
99
|
+
// Surface progress changes to stderr so an agent watching sees liveness
|
|
100
|
+
// without the poll noise polluting the machine-readable stdout result.
|
|
101
|
+
if (!opts.json) {
|
|
102
|
+
const prog = r.progress_pct != null
|
|
103
|
+
? `${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`
|
|
104
|
+
: r.status;
|
|
105
|
+
if (prog !== lastProgress) {
|
|
106
|
+
console.error(muted(`... ${prog}`));
|
|
107
|
+
lastProgress = prog;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const remaining = deadline - Date.now();
|
|
111
|
+
if (remaining <= 0)
|
|
112
|
+
break;
|
|
113
|
+
await new Promise(res => setTimeout(res, Math.min(interval * 1000, remaining)));
|
|
114
|
+
r = await fetchRun();
|
|
115
|
+
}
|
|
116
|
+
const done = TERMINAL.has(r.status);
|
|
117
|
+
if (opts.json) {
|
|
118
|
+
console.log(JSON.stringify(done ? r : { ...r, waiting: true, timed_out: true }));
|
|
119
|
+
}
|
|
120
|
+
else if (!done) {
|
|
121
|
+
// Not an error - the job is simply still going. Say so plainly and tell
|
|
122
|
+
// the agent exactly how to resume, then exit non-zero so a chained `&&`
|
|
123
|
+
// doesn't proceed as if the run had finished.
|
|
124
|
+
const prog = r.progress_pct != null ? ` ${Math.round(r.progress_pct * 100)}%` : '';
|
|
125
|
+
console.log(`${warn('still running')}${prog}${r.progress_message ? ` (${r.progress_message})` : ''} ${muted(runGuid)}`);
|
|
126
|
+
console.log(muted(`Not finished after ${timeout}s. Run \`gipity job wait ${runGuid}\` again to keep waiting.`));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const statusColor = r.status === 'success' ? success : clrError;
|
|
130
|
+
console.log(`${statusColor(r.status)} ${muted(r.guid)}`);
|
|
131
|
+
if (r.duration_ms != null)
|
|
132
|
+
console.log(`duration: ${r.duration_ms}ms`);
|
|
133
|
+
if (r.error)
|
|
134
|
+
console.log(`${clrError('error:')} ${r.error}`);
|
|
135
|
+
if (r.output)
|
|
136
|
+
console.log(`output: ${JSON.stringify(r.output)}`);
|
|
137
|
+
}
|
|
138
|
+
// Exit codes: success → 0, failed/cancelled → 1, still-running-at-timeout → 2
|
|
139
|
+
// (distinct from a real failure so agents can tell "not yet" from "broke").
|
|
140
|
+
if (!done)
|
|
141
|
+
process.exit(2);
|
|
142
|
+
if (r.status !== 'success')
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}));
|
|
69
145
|
jobCommand
|
|
70
146
|
.command('runs <name>')
|
|
71
147
|
.description('List recent runs for a job')
|
|
@@ -87,6 +163,7 @@ jobCommand
|
|
|
87
163
|
.description('Stream live logs for a job run (--no-follow for a one-shot snapshot)')
|
|
88
164
|
.option('--follow', 'Stream via SSE (default)', true)
|
|
89
165
|
.option('--no-follow', 'One-shot snapshot of run state instead of streaming')
|
|
166
|
+
.option('--timeout <seconds>', 'Stop streaming after N seconds and exit cleanly, so a bounded peek at the live log never hangs the shell (no shell `timeout` wrapper needed). Bare number = seconds; unit (20s) also accepted.')
|
|
90
167
|
.option('--json', 'Output as JSON')
|
|
91
168
|
.action((runGuid, opts) => run('Logs', async () => {
|
|
92
169
|
const config = requireConfig();
|
|
@@ -95,6 +172,16 @@ jobCommand
|
|
|
95
172
|
console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
|
|
96
173
|
return;
|
|
97
174
|
}
|
|
175
|
+
// Optional bounded peek: close the stream after --timeout seconds instead of
|
|
176
|
+
// holding open until the run finishes. Lets an agent glance at early stdout
|
|
177
|
+
// (e.g. catch an import error during cold start) without wrapping the command
|
|
178
|
+
// in a shell `timeout`, then poll again to keep watching.
|
|
179
|
+
let peekSeconds = 0;
|
|
180
|
+
if (opts.timeout != null) {
|
|
181
|
+
const dur = parseDuration(String(opts.timeout), 's');
|
|
182
|
+
const secs = dur ? dur.value : parseFloat(String(opts.timeout));
|
|
183
|
+
peekSeconds = Number.isFinite(secs) && secs > 0 ? secs : 0;
|
|
184
|
+
}
|
|
98
185
|
// SSE stream. Manual fetch + line buffering - Node's EventSource isn't built-in,
|
|
99
186
|
// and the SSE format is simple enough to parse inline (event: <name>\ndata: <json>\n\n).
|
|
100
187
|
const url = `${getBaseUrl()}/projects/${config.projectGuid}/jobs/runs/${encodeURIComponent(runGuid)}/logs/stream`;
|
|
@@ -102,8 +189,28 @@ jobCommand
|
|
|
102
189
|
const headers = { 'Accept': 'text/event-stream' };
|
|
103
190
|
if (authHeader)
|
|
104
191
|
headers.Authorization = authHeader;
|
|
105
|
-
|
|
192
|
+
// Abort the underlying request when the peek window elapses so the socket is
|
|
193
|
+
// torn down cleanly rather than left dangling.
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
let peekTimer;
|
|
196
|
+
let peekedOut = false;
|
|
197
|
+
if (peekSeconds > 0) {
|
|
198
|
+
peekTimer = setTimeout(() => { peekedOut = true; controller.abort(); }, peekSeconds * 1000);
|
|
199
|
+
}
|
|
200
|
+
let res;
|
|
201
|
+
try {
|
|
202
|
+
res = await fetch(url, { headers, signal: controller.signal });
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
if (peekedOut) {
|
|
206
|
+
console.error(muted(`... still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
106
211
|
if (!res.ok || !res.body) {
|
|
212
|
+
if (peekTimer)
|
|
213
|
+
clearTimeout(peekTimer);
|
|
107
214
|
console.error(clrError(`Failed to open stream: HTTP ${res.status}`));
|
|
108
215
|
process.exit(1);
|
|
109
216
|
}
|
|
@@ -112,9 +219,24 @@ jobCommand
|
|
|
112
219
|
let buffer = '';
|
|
113
220
|
let currentEvent = '';
|
|
114
221
|
while (true) {
|
|
115
|
-
|
|
116
|
-
|
|
222
|
+
let chunk;
|
|
223
|
+
try {
|
|
224
|
+
chunk = await reader.read();
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
// Peek window elapsed mid-stream: report cleanly and exit 0 (not an error).
|
|
228
|
+
if (peekedOut) {
|
|
229
|
+
console.error(muted(`... still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
const { done, value } = chunk;
|
|
235
|
+
if (done) {
|
|
236
|
+
if (peekTimer)
|
|
237
|
+
clearTimeout(peekTimer);
|
|
117
238
|
break;
|
|
239
|
+
}
|
|
118
240
|
buffer += decoder.decode(value, { stream: true });
|
|
119
241
|
let nl;
|
|
120
242
|
while ((nl = buffer.indexOf('\n')) >= 0) {
|
package/dist/commands/load.js
CHANGED
|
@@ -152,7 +152,7 @@ export const loadCommand = new Command('load')
|
|
|
152
152
|
if (opts.inspect) {
|
|
153
153
|
const res = opts.json
|
|
154
154
|
? await doInspect()
|
|
155
|
-
: await withSpinner('Inspecting
|
|
155
|
+
: await withSpinner('Inspecting...', doInspect, { done: null });
|
|
156
156
|
if (opts.json) {
|
|
157
157
|
console.log(JSON.stringify(res.data));
|
|
158
158
|
return;
|
|
@@ -162,7 +162,7 @@ export const loadCommand = new Command('load')
|
|
|
162
162
|
}
|
|
163
163
|
const { data, partial } = opts.json
|
|
164
164
|
? await doImport()
|
|
165
|
-
: await withSpinner('Importing
|
|
165
|
+
: await withSpinner('Importing...', doImport, { done: null });
|
|
166
166
|
const project = data.project;
|
|
167
167
|
// Materialize a local dir and link it, exactly like `gipity project create`:
|
|
168
168
|
// `.gipity.json` is written directly inside the new dir, then a soft sync
|
package/dist/commands/login.js
CHANGED
|
@@ -4,6 +4,7 @@ import { publicPost } from '../api.js';
|
|
|
4
4
|
import { prompt, decodeJwtExp } from '../utils.js';
|
|
5
5
|
import { success, error as clrError, muted } from '../colors.js';
|
|
6
6
|
import { flushBugQueue } from '../bug-queue.js';
|
|
7
|
+
import { warnBeforeCodeIfUnexpectedNewAccount, warnIfUnexpectedNewAccount } from '../login-flow.js';
|
|
7
8
|
export const loginCommand = new Command('login')
|
|
8
9
|
.description('Log in or sign up')
|
|
9
10
|
.option('--email <email>', 'Email address')
|
|
@@ -19,9 +20,10 @@ export const loginCommand = new Command('login')
|
|
|
19
20
|
}
|
|
20
21
|
// Email only → send code and exit (non-interactive step 1)
|
|
21
22
|
if (email && !code) {
|
|
22
|
-
await publicPost('/auth/login', { email });
|
|
23
|
+
const sendRes = await publicPost('/auth/login', { email });
|
|
23
24
|
console.log('Check your email for a 6-digit code.');
|
|
24
25
|
console.log(muted(`Then run: gipity login --email ${email} --code <code>`));
|
|
26
|
+
warnBeforeCodeIfUnexpectedNewAccount(sendRes.isNewUser, email);
|
|
25
27
|
return;
|
|
26
28
|
}
|
|
27
29
|
// Fully interactive flow
|
|
@@ -34,9 +36,10 @@ export const loginCommand = new Command('login')
|
|
|
34
36
|
console.error(clrError('Email required.'));
|
|
35
37
|
process.exit(1);
|
|
36
38
|
}
|
|
37
|
-
await publicPost('/auth/login', { email });
|
|
39
|
+
const sendRes = await publicPost('/auth/login', { email });
|
|
38
40
|
console.log('');
|
|
39
41
|
console.log('Check your email for a 6-digit code.');
|
|
42
|
+
warnBeforeCodeIfUnexpectedNewAccount(sendRes.isNewUser, email);
|
|
40
43
|
code = await prompt('Code: ');
|
|
41
44
|
await verify(email, code);
|
|
42
45
|
}
|
|
@@ -46,6 +49,7 @@ export const loginCommand = new Command('login')
|
|
|
46
49
|
}
|
|
47
50
|
});
|
|
48
51
|
async function verify(email, code) {
|
|
52
|
+
const priorAuth = getAuth();
|
|
49
53
|
const res = await publicPost('/auth/verify', { email, code });
|
|
50
54
|
const exp = decodeJwtExp(res.accessToken);
|
|
51
55
|
if (!exp) {
|
|
@@ -60,6 +64,7 @@ async function verify(email, code) {
|
|
|
60
64
|
expiresAt,
|
|
61
65
|
});
|
|
62
66
|
console.log(success(`Logged in (${email}).`));
|
|
67
|
+
warnIfUnexpectedNewAccount(res.isNewUser, email, priorAuth);
|
|
63
68
|
// A fresh session is the clearest "we're reconnected" signal - clear any bug
|
|
64
69
|
// reports that got stranded while this account's session was expired/offline.
|
|
65
70
|
const delivered = await flushBugQueue().catch(() => 0);
|
|
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { Command, Option } from 'commander';
|
|
3
3
|
import { post, get, ApiError } from '../api.js';
|
|
4
4
|
import { brand, bold, muted, warning, success } from '../colors.js';
|
|
5
|
-
import { run } from '../helpers/index.js';
|
|
5
|
+
import { run, parseDuration } from '../helpers/index.js';
|
|
6
6
|
import { getAuth } from '../auth.js';
|
|
7
7
|
import { resolveProjectContext } from '../config.js';
|
|
8
8
|
import { uploadPublicFixture, uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
|
|
@@ -106,7 +106,7 @@ export function summarizeExpr(expr) {
|
|
|
106
106
|
if (oneLine && expr.trim().length <= EXPR_ECHO_MAX_CHARS)
|
|
107
107
|
return expr.trim();
|
|
108
108
|
const first = (meaningful[0] ?? '').trim();
|
|
109
|
-
const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS -
|
|
109
|
+
const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 3)}...` : first;
|
|
110
110
|
const shape = oneLine
|
|
111
111
|
? `(${expr.trim().length} chars)`
|
|
112
112
|
: `(+${meaningful.length - 1} more ${meaningful.length - 1 === 1 ? 'line' : 'lines'}, ${expr.trim().length} chars)`;
|
|
@@ -148,6 +148,13 @@ export function capWaitForTimeoutMs(raw) {
|
|
|
148
148
|
`or watch for it INSIDE the script (--timeout ${EVAL_SCRIPT_BUDGET_MAX_MS}).`));
|
|
149
149
|
return WAIT_FOR_MAX_MS;
|
|
150
150
|
}
|
|
151
|
+
/** Read a --file/--reload-file script. `-` means stdin, so an agent can pipe a
|
|
152
|
+
* heredoc straight in — `gipity page eval <url> --file - <<'EOF' … EOF` — with
|
|
153
|
+
* no throwaway tmp file to write and then clean up. readFileSync(0) drains fd 0
|
|
154
|
+
* (stdin) to EOF. Any other value is a path on disk. */
|
|
155
|
+
export function readScriptFile(pathArg) {
|
|
156
|
+
return readFileSync(pathArg === '-' ? 0 : pathArg, 'utf8');
|
|
157
|
+
}
|
|
151
158
|
/** True when `s` is an expression (`document.title`, an IIFE) rather than a
|
|
152
159
|
* statement body (`const x = …; return x`). Mirrors the server's own parse
|
|
153
160
|
* choice: only an expression may be spliced into `return (…)`. Nothing is
|
|
@@ -192,6 +199,12 @@ const CAMERA_SETUP_BUDGET_MS = 30_000;
|
|
|
192
199
|
export const EVAL_SCRIPT_BUDGET_MS = 30_000;
|
|
193
200
|
export const EVAL_SCRIPT_BUDGET_CAMERA_MS = 45_000;
|
|
194
201
|
export const EVAL_SCRIPT_BUDGET_MAX_MS = 90_000;
|
|
202
|
+
// The smallest in-page budget the server will honour. A --timeout below this is
|
|
203
|
+
// floored to it, so any sub-floor value is useless as a real ms budget AND is
|
|
204
|
+
// almost always seconds typed as ms (--timeout 120 meaning 120s) — the guard in
|
|
205
|
+
// the action rejects it up front with the unit named, rather than letting it
|
|
206
|
+
// silently floor and then stall with an opaque "1s budget" error.
|
|
207
|
+
export const EVAL_SCRIPT_BUDGET_MIN_MS = 1_000;
|
|
195
208
|
/** The in-page budget this call gets: the caller's --timeout when given, else the
|
|
196
209
|
* default for the kind of run (roomier under --camera/--fake-media). Clamped to
|
|
197
210
|
* the server's accepted range — a value over the max is clamped and explained on
|
|
@@ -209,7 +222,7 @@ export function capScriptBudgetMs(rawTimeout, hasMedia) {
|
|
|
209
222
|
`absorb it in the pre-eval window (--wait-for '<ready-selector>'), keeping the body to a quick read.`));
|
|
210
223
|
return EVAL_SCRIPT_BUDGET_MAX_MS;
|
|
211
224
|
}
|
|
212
|
-
return Math.max(
|
|
225
|
+
return Math.max(EVAL_SCRIPT_BUDGET_MIN_MS, parsed);
|
|
213
226
|
}
|
|
214
227
|
/** Upper bound on the server-side work this eval asked for. EVERY leg the caller
|
|
215
228
|
* can lengthen has to be in here, or the client gives up on a job that is still
|
|
@@ -366,9 +379,9 @@ export const pageEvalCommand = new Command('eval')
|
|
|
366
379
|
.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')
|
|
367
380
|
.argument('<url>', 'URL to load')
|
|
368
381
|
.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.`)
|
|
369
|
-
.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).`)
|
|
382
|
+
.option('--file <path>', `Read the script body from a file instead of the inline <expr> arg (mutually exclusive), or --file - to read it from stdin (pipe a heredoc: --file - <<'EOF' ... EOF) with no tmp file. Runs as an async function body, so top-level return/await work. Same post-load budget as <expr> (--timeout).`)
|
|
370
383
|
.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], [])
|
|
371
|
-
.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], [])
|
|
384
|
+
.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. The hosted URL has permissive CORS, so `new Image(); img.crossOrigin="anonymous"; img.src=fixtureUrl` decodes untainted for a canvas/pixel read - this is the UPLOAD analog of --camera: feed a known photo/video to a file-upload vision app (web-vision-detect) and run its detector, no need to deploy a throwaway test asset into the app. Repeat for several files (single-value so it never swallows the inline <expr>).', (val, prev) => [...prev, val], [])
|
|
372
385
|
.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.')
|
|
373
386
|
.option('--reload-file <path>', 'Read the post-reload expression from a file instead of inline --reload (mutually exclusive)')
|
|
374
387
|
.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".`)
|
|
@@ -376,7 +389,7 @@ export const pageEvalCommand = new Command('eval')
|
|
|
376
389
|
.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')
|
|
377
390
|
.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).`)
|
|
378
391
|
.option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (max ${WAIT_FOR_MAX_MS})`, '5000')
|
|
379
|
-
.option('--timeout <ms>', `How long the script itself may run IN the page
|
|
392
|
+
.option('--timeout <ms>', `How long the script itself may run IN the page. Bare number = MILLISECONDS (so 90s = 90000, NOT 90); or pass an explicit unit that means the same on both this and \`sandbox run --timeout\` — --timeout 90s. Its own await/setTimeout pauses count (default ${EVAL_SCRIPT_BUDGET_MS}, ${EVAL_SCRIPT_BUDGET_CAMERA_MS} with --camera/--fake-media; floor 1000, 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.`)
|
|
380
393
|
.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.')
|
|
381
394
|
.option('--json', 'Output as JSON')
|
|
382
395
|
.action((url, exprArg, opts) => run('Page eval', async () => {
|
|
@@ -409,10 +422,12 @@ export const pageEvalCommand = new Command('eval')
|
|
|
409
422
|
let expr = exprArg;
|
|
410
423
|
if (opts.file) {
|
|
411
424
|
try {
|
|
412
|
-
expr =
|
|
425
|
+
expr = readScriptFile(opts.file);
|
|
413
426
|
}
|
|
414
427
|
catch {
|
|
415
|
-
pageEvalCommand.error(
|
|
428
|
+
pageEvalCommand.error(opts.file === '-'
|
|
429
|
+
? 'error: --file - reads the script from stdin, but stdin was empty/unreadable - pipe it in, e.g. gipity page eval "<url>" --file - <<\'EOF\' ... EOF'
|
|
430
|
+
: `error: Cannot read file: ${opts.file}`);
|
|
416
431
|
}
|
|
417
432
|
}
|
|
418
433
|
// Post-reload expression: inline --reload or --reload-file, same shape
|
|
@@ -423,7 +438,7 @@ export const pageEvalCommand = new Command('eval')
|
|
|
423
438
|
let reloadExpr = opts.reload;
|
|
424
439
|
if (opts.reloadFile) {
|
|
425
440
|
try {
|
|
426
|
-
reloadExpr =
|
|
441
|
+
reloadExpr = readScriptFile(opts.reloadFile);
|
|
427
442
|
}
|
|
428
443
|
catch {
|
|
429
444
|
pageEvalCommand.error(`error: Cannot read file: ${opts.reloadFile}`);
|
|
@@ -434,6 +449,41 @@ export const pageEvalCommand = new Command('eval')
|
|
|
434
449
|
pageEvalCommand.error(`error: at most ${MAX_EVAL_STEPS} --step expressions ride on one page load (got ${steps.length}) — ` +
|
|
435
450
|
`do more per step, or split this into two evals`);
|
|
436
451
|
}
|
|
452
|
+
// --timeout is native MILLISECONDS here but native SECONDS on the sibling
|
|
453
|
+
// `sandbox run --timeout`. An explicit unit suffix reconciles them: `--timeout
|
|
454
|
+
// 90s` means 90 seconds on BOTH commands. A suffixed value is normalized to ms
|
|
455
|
+
// up front, so the rest of this action (and the bare-number guard below) sees a
|
|
456
|
+
// plain ms number and the portable form just works.
|
|
457
|
+
if (opts.timeout !== undefined) {
|
|
458
|
+
const dur = parseDuration(opts.timeout, 'ms');
|
|
459
|
+
if (dur?.hadSuffix)
|
|
460
|
+
opts.timeout = String(Math.round(dur.value));
|
|
461
|
+
}
|
|
462
|
+
// A BARE number below the in-page floor is the unit-mixup tell: a real ms
|
|
463
|
+
// budget that small is useless (it just floors to the minimum), so a sub-floor
|
|
464
|
+
// bare number is almost always seconds typed as ms (--timeout 90 meaning 90s).
|
|
465
|
+
// Left alone it clamps SILENTLY to a ~1s budget and the script then stalls with
|
|
466
|
+
// an opaque "1s budget" error that never reveals the unit mix-up. Reject it up
|
|
467
|
+
// front, naming the unit AND the portable suffix form, so the fix is one obvious
|
|
468
|
+
// edit instead of a doomed run.
|
|
469
|
+
if (opts.timeout !== undefined) {
|
|
470
|
+
const t = parseInt(opts.timeout, 10);
|
|
471
|
+
if (Number.isFinite(t) && t > 0 && t < EVAL_SCRIPT_BUDGET_MIN_MS) {
|
|
472
|
+
const overMax = t * 1000 > EVAL_SCRIPT_BUDGET_MAX_MS;
|
|
473
|
+
const asMs = Math.min(t * 1000, EVAL_SCRIPT_BUDGET_MAX_MS);
|
|
474
|
+
// A value-mixup, not an arg-SHAPE error: the message below already carries
|
|
475
|
+
// the complete fix (pass `--timeout ${t}s`), so this does NOT go through
|
|
476
|
+
// pageEvalCommand.error() — that dumps the whole options list bracketing
|
|
477
|
+
// the message, and an agent piping the output through `| tail` reads the
|
|
478
|
+
// option list as "it rejected me with help" and misses the one-line fix.
|
|
479
|
+
// Throw instead: `run()` prints just the targeted message, nothing else.
|
|
480
|
+
throw new Error(`--timeout is in MILLISECONDS (got ${t}, under the ${EVAL_SCRIPT_BUDGET_MIN_MS}ms in-page floor). ` +
|
|
481
|
+
`Unlike \`sandbox run --timeout\` (seconds), page eval's is ms — or pass an explicit unit that means the ` +
|
|
482
|
+
`same on both: --timeout ${t}s. ${t} looks like seconds, so for ${t} second${t === 1 ? '' : 's'} pass ` +
|
|
483
|
+
`--timeout ${t}s (= ${asMs}ms)` +
|
|
484
|
+
`${overMax ? ` (${t}s is over the ${EVAL_SCRIPT_BUDGET_MAX_MS / 1000}s max, so this is the ceiling)` : ''}.`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
437
487
|
let waitMs = capWaitMs(opts.wait, url);
|
|
438
488
|
const waitForTimeoutMs = capWaitForTimeoutMs(opts.waitTimeout);
|
|
439
489
|
// The in-page budget for the script itself. Always sent, so the caller (and
|
|
@@ -490,12 +540,12 @@ export const pageEvalCommand = new Command('eval')
|
|
|
490
540
|
projectGuid = config.projectGuid;
|
|
491
541
|
}
|
|
492
542
|
if (opts.camera) {
|
|
493
|
-
console.log(muted(`Hosting camera feed ${opts.camera}
|
|
543
|
+
console.log(muted(`Hosting camera feed ${opts.camera}...`));
|
|
494
544
|
camera = await uploadCameraFeed(projectGuid, opts.camera);
|
|
495
545
|
}
|
|
496
546
|
if (fixturePaths.length) {
|
|
497
547
|
for (const p of fixturePaths) {
|
|
498
|
-
console.log(muted(`Hosting fixture ${p}
|
|
548
|
+
console.log(muted(`Hosting fixture ${p}...`));
|
|
499
549
|
hosted.push(await uploadPublicFixture(projectGuid, p));
|
|
500
550
|
}
|
|
501
551
|
const map = {};
|
|
@@ -601,7 +651,7 @@ export const pageEvalCommand = new Command('eval')
|
|
|
601
651
|
console.log(`${muted('Camera:')} ${camera.name} ${muted('(played as the webcam feed; getUserMedia resolves)')}`);
|
|
602
652
|
if (hosted.length)
|
|
603
653
|
console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
|
|
604
|
-
console.error(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
|
|
654
|
+
console.error(opts.file ? `${muted('Script:')} ${opts.file === '-' ? '(stdin)' : opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
|
|
605
655
|
console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
|
|
606
656
|
if (noValue)
|
|
607
657
|
console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
|
|
@@ -646,7 +696,16 @@ for (const f of JS_DECOY_FLAGS)
|
|
|
646
696
|
// clients see each other (presence, shared state). For that, use the genuinely-
|
|
647
697
|
// concurrent `page test --observe` instead, which overlaps N clients and reports
|
|
648
698
|
// whether they actually ran together.
|
|
649
|
-
|
|
699
|
+
//
|
|
700
|
+
// This whole narrative renders ONLY on an explicit `--help`, never on an
|
|
701
|
+
// arg-shape error. An error dumps the command via outputHelp too, but appending
|
|
702
|
+
// ~70 lines of examples/time-budget/realtime guidance on top of a one-line "URL
|
|
703
|
+
// must be absolute" buries the actual error — and its trailing realtime block,
|
|
704
|
+
// off-topic for a plain probe, has misled agents into "fixing the eval syntax".
|
|
705
|
+
// The one realtime pointer worth keeping on error already lives in the command
|
|
706
|
+
// DESCRIPTION ("ONE client per call - use `page test --observe`"), which renders
|
|
707
|
+
// on error anyway, so nothing is lost by withholding the manual here.
|
|
708
|
+
pageEvalCommand.addHelpText('after', (context) => context.error ? '' : `
|
|
650
709
|
Examples:
|
|
651
710
|
gipity page eval "https://dev.gipity.ai/me/app/" "document.title"
|
|
652
711
|
# Functionally test a page's own code paths: save a script that drives the UI
|
|
@@ -682,6 +741,16 @@ Examples:
|
|
|
682
741
|
"window.__vision.gesture()" \\
|
|
683
742
|
--step "document.getElementById('see').textContent" \\
|
|
684
743
|
--step "({ score: score.textContent, verdict: verdict.textContent })"
|
|
744
|
+
# File-UPLOAD vision app (web-vision-detect, no camera)? --fixture is the analog of
|
|
745
|
+
# --camera: it hosts your test photo at a CORS-permissive URL, so you feed the kit's
|
|
746
|
+
# detector a known image and read back the labels - no deploying a throwaway asset into
|
|
747
|
+
# the app tree (and no cleanup redeploy). crossOrigin='anonymous' keeps the canvas clean.
|
|
748
|
+
gipity generate image "street scene: three people, two cars, one dog" -o street.jpg
|
|
749
|
+
gipity page eval "https://dev.gipity.ai/me/app/" --fixture street.jpg \\
|
|
750
|
+
"const { createDetector } = await import('./packages/web-vision-detect/index.js'); \\
|
|
751
|
+
const det = await createDetector({ model: 'nano' }); \\
|
|
752
|
+
const img = new Image(); img.crossOrigin = 'anonymous'; img.src = fixtureUrl; await img.decode(); \\
|
|
753
|
+
const { detections } = await det.detect(img); return detections.map(d => d.label);"
|
|
685
754
|
|
|
686
755
|
Module resolution: dynamic import() specifiers starting with ./ or ../ resolve
|
|
687
756
|
against the PAGE URL, so import('./packages/i18n/index.js') loads the app's own
|
|
@@ -30,9 +30,6 @@ function looksLikeHtml(body) {
|
|
|
30
30
|
const head = body.slice(0, 300).toLowerCase();
|
|
31
31
|
return /<!doctype html|<html[\s>]/.test(head);
|
|
32
32
|
}
|
|
33
|
-
function sha256(s) {
|
|
34
|
-
return createHash('sha256').update(s).digest('hex');
|
|
35
|
-
}
|
|
36
33
|
function baseWithSlash(url) {
|
|
37
34
|
return url.endsWith('/') ? url : url + '/';
|
|
38
35
|
}
|
|
@@ -43,8 +40,16 @@ function resolveUrl(base, path) {
|
|
|
43
40
|
async function fetchRaw(url) {
|
|
44
41
|
try {
|
|
45
42
|
const res = await fetch(url, { redirect: 'follow' });
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// Read raw bytes, not text(): decoding binary as UTF-8 turns every invalid
|
|
44
|
+
// byte into a 3-byte replacement char, inflating the measured size ~1.7x.
|
|
45
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
46
|
+
return {
|
|
47
|
+
status: res.status,
|
|
48
|
+
contentType: res.headers.get('content-type'),
|
|
49
|
+
body: buf.toString('utf-8'),
|
|
50
|
+
bytes: buf.length,
|
|
51
|
+
sha256: createHash('sha256').update(buf).digest('hex'),
|
|
52
|
+
};
|
|
48
53
|
}
|
|
49
54
|
catch {
|
|
50
55
|
return null; // DNS failure, connection refused, etc.
|
|
@@ -59,21 +64,20 @@ async function probeShell(base) {
|
|
|
59
64
|
return {
|
|
60
65
|
served: r.status >= 200 && r.status < 300,
|
|
61
66
|
status: r.status,
|
|
62
|
-
sha256:
|
|
67
|
+
sha256: r.sha256,
|
|
63
68
|
isHtml: looksLikeHtml(r.body),
|
|
64
69
|
};
|
|
65
70
|
}
|
|
66
71
|
function classify(path, r, shell) {
|
|
67
72
|
if (!r)
|
|
68
73
|
return { status: null, contentType: null, bytes: 0, verdict: 'MISSING', detail: 'fetch failed (host unreachable)' };
|
|
69
|
-
const
|
|
70
|
-
const base = { status: r.status, contentType: r.contentType, bytes };
|
|
74
|
+
const base = { status: r.status, contentType: r.contentType, bytes: r.bytes };
|
|
71
75
|
// Honest 404/5xx - the file simply isn't there.
|
|
72
76
|
if (r.status >= 400)
|
|
73
77
|
return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status}` };
|
|
74
78
|
const expect = expectedType(path);
|
|
75
79
|
// 1) Served the catch-all shell verbatim → 200, but the file isn't deployed.
|
|
76
|
-
if (shell.served && shell.sha256 &&
|
|
80
|
+
if (shell.served && shell.sha256 && r.sha256 === shell.sha256) {
|
|
77
81
|
return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status} but served the SPA shell (file not deployed)` };
|
|
78
82
|
}
|
|
79
83
|
// 2) Asked for a non-HTML asset but got an HTML body → a per-path shell variant.
|
|
@@ -92,7 +96,7 @@ const TAG = {
|
|
|
92
96
|
'WRONG-TYPE': warning('WRONG-TYPE'),
|
|
93
97
|
};
|
|
94
98
|
export const pageFetchCommand = new Command('fetch')
|
|
95
|
-
.description('Verify deployed non-rendered files (llms.txt, AGENTS.md, robots.txt, served JSON
|
|
99
|
+
.description('Verify deployed non-rendered files (llms.txt, AGENTS.md, robots.txt, served JSON...) really exist - catches the static-host trap where a missing file returns 200 with the SPA shell instead of a 404')
|
|
96
100
|
.argument('<url>', 'Deployed app base URL; file paths resolve relative to it')
|
|
97
101
|
.argument('<paths...>', 'One or more file paths to verify, e.g. llms.txt AGENTS.md robots.txt')
|
|
98
102
|
.option('--json', 'Output as JSON')
|
|
@@ -26,10 +26,10 @@ function shortUrl(url, truncate = true, maxLen = 100) {
|
|
|
26
26
|
}
|
|
27
27
|
if (!truncate || result.length <= maxLen)
|
|
28
28
|
return result;
|
|
29
|
-
const keep = maxLen -
|
|
29
|
+
const keep = maxLen - 3;
|
|
30
30
|
const headLen = Math.ceil(keep / 2);
|
|
31
31
|
const tailLen = Math.floor(keep / 2);
|
|
32
|
-
return result.slice(0, headLen) + '
|
|
32
|
+
return result.slice(0, headLen) + '...' + result.slice(-tailLen);
|
|
33
33
|
}
|
|
34
34
|
export const pageInspectCommand = new Command('inspect')
|
|
35
35
|
.description('Inspect a web page (console, failed resources, timing, layout overflow). ONE passive load - to verify realtime/presence across concurrent clients use `page test --observe`')
|
|
@@ -80,7 +80,24 @@ export async function inspectPage(url, opts = {}) {
|
|
|
80
80
|
auth: opts.auth || undefined,
|
|
81
81
|
};
|
|
82
82
|
const res = await post(`/tools/browser/inspect`, inspectBody);
|
|
83
|
-
const b = res
|
|
83
|
+
const b = res?.data;
|
|
84
|
+
// The inspector can hand back nothing to report — an auth-gated or
|
|
85
|
+
// JS-rendered SPA whose client render hadn't run, a navigation that
|
|
86
|
+
// redirected away, or a server-side hiccup that returned an empty bundle.
|
|
87
|
+
// Left unguarded the report below either crashes on `b.failedResources` or
|
|
88
|
+
// prints a bare header that reads as blank output — no signal at the exact
|
|
89
|
+
// moment the agent reached for verification. Say plainly that nothing was
|
|
90
|
+
// captured and point at the levers that fix it.
|
|
91
|
+
if (!b || typeof b !== 'object') {
|
|
92
|
+
if (opts.json) {
|
|
93
|
+
console.log(JSON.stringify({ url, captured: false, note: 'inspector returned no page data' }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log(`${brand('Inspecting')} ${bold(url)}`);
|
|
97
|
+
console.log(warning('Captured no page data — the page returned an empty response.'));
|
|
98
|
+
console.log(muted('The page may have failed to load, redirected away, or rendered nothing server-side. Retry once; if it persists pass --wait-for <selector> to wait for a specific element to appear, or --auth if it is behind Sign in with Gipity.'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
84
101
|
// ── Move resource-load failures out of the console, where they're noise ──
|
|
85
102
|
// A sub-resource that returns an HTTP 4xx/5xx is reported twice: once in
|
|
86
103
|
// `failedResources` (CDP network layer — carries the full URL, method, and
|
|
@@ -179,6 +196,14 @@ export async function inspectPage(url, opts = {}) {
|
|
|
179
196
|
console.log(`${muted('Title:')} ${b.title || '(none)'}`);
|
|
180
197
|
console.log(`${muted('Elements:')} ${b.elementCount || 0}`);
|
|
181
198
|
console.log(`${muted('Page weight:')} ${info(formatSize(b.totalBytes || 0))}`);
|
|
199
|
+
// A page that came back with no DOM and no title rendered essentially
|
|
200
|
+
// nothing the inspector could see. That's the auth-gated / JS-rendered SPA
|
|
201
|
+
// trap: the header above alone reads as "loaded fine, just empty" when the
|
|
202
|
+
// real story is "client render or auth gate produced nothing yet." Flag it
|
|
203
|
+
// so the agent knows to wait for the render or sign in, not to trust silence.
|
|
204
|
+
if ((b.elementCount || 0) === 0 && !b.title) {
|
|
205
|
+
console.log(`${warning('⚠ Rendered no DOM (0 elements, no title)')} ${muted('— the client-side render likely had not run yet (try --wait-for <selector>), the page failed to load, or it is behind a sign-in gate (try --auth).')}`);
|
|
206
|
+
}
|
|
182
207
|
// ── Timing ──
|
|
183
208
|
console.log(`\n${bold('Timing:')}`);
|
|
184
209
|
console.log(`${muted('TTFB:')} ${timing.ttfb}ms`);
|