gipity 1.0.424 → 1.0.425
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/api.js +5 -3
- package/dist/commands/add.js +6 -0
- package/dist/commands/bug.js +21 -1
- package/dist/commands/fn.js +21 -2
- package/dist/commands/page-eval.js +75 -5
- package/dist/commands/page-inspect.js +1 -1
- package/dist/commands/page-screenshot.js +27 -2
- package/dist/commands/page.js +1 -1
- package/dist/commands/sandbox.js +21 -0
- package/dist/commands/test.js +39 -3
- package/dist/commands/workflow.js +24 -16
- package/dist/index.js +235 -39
- package/dist/knowledge.js +1 -0
- package/dist/sync.js +17 -3
- package/dist/trace.js +69 -0
- package/dist/updater/shim.js +55 -4
- package/package.json +2 -2
package/dist/api.js
CHANGED
|
@@ -353,12 +353,14 @@ export async function getAccountSlug() {
|
|
|
353
353
|
accountSlugCache = res.data.accountSlug;
|
|
354
354
|
return accountSlugCache;
|
|
355
355
|
}
|
|
356
|
-
/** Unauthenticated request (for login/verify
|
|
357
|
-
|
|
356
|
+
/** Unauthenticated request (for login/verify, and anonymous-visitor calls like
|
|
357
|
+
* `fn call --anon`). extraHeaders lets callers attach non-auth headers such as
|
|
358
|
+
* X-App-Token; no Authorization header is ever sent. */
|
|
359
|
+
export async function publicPost(path, body, extraHeaders) {
|
|
358
360
|
const url = `${baseUrl()}${path}`;
|
|
359
361
|
const res = await fetch(url, {
|
|
360
362
|
method: 'POST',
|
|
361
|
-
headers: { ...clientHeaders(), 'Content-Type': 'application/json' },
|
|
363
|
+
headers: { ...clientHeaders(), 'Content-Type': 'application/json', ...extraHeaders },
|
|
362
364
|
body: JSON.stringify(body),
|
|
363
365
|
});
|
|
364
366
|
if (!res.ok) {
|
package/dist/commands/add.js
CHANGED
|
@@ -206,6 +206,12 @@ export const addCommand = new Command('add')
|
|
|
206
206
|
}
|
|
207
207
|
// The server runs the whole install pipeline before responding; animate the
|
|
208
208
|
// wait, then clear the spinner so the installed-files list is the result.
|
|
209
|
+
// The spinner is TTY-only, so piped/agent transcripts would otherwise show
|
|
210
|
+
// a silent multi-second gap (favicon generation on a cold cache can take
|
|
211
|
+
// ~10s, cli#117) - leave one stderr marker so the wait reads as work.
|
|
212
|
+
if (!opts.json && !process.stdout.isTTY) {
|
|
213
|
+
console.error(muted('Installing (server writes files + generates favicons; first add for a title can take ~10s)...'));
|
|
214
|
+
}
|
|
209
215
|
const doAdd = () => post(`/projects/${config.projectGuid}/add`, body);
|
|
210
216
|
const res = opts.json
|
|
211
217
|
? await doAdd()
|
package/dist/commands/bug.js
CHANGED
|
@@ -15,7 +15,9 @@ export const bugCommand = new Command('bug')
|
|
|
15
15
|
`\nso the team can triage it into a fix.\n` +
|
|
16
16
|
`\nCategories: ${CATEGORIES.join(', ')}` +
|
|
17
17
|
`\nSeverity: ${SEVERITY_HINT}` +
|
|
18
|
-
`\nNever include PII or user data — describe the platform problem in the abstract.`
|
|
18
|
+
`\nNever include PII or user data — describe the platform problem in the abstract.` +
|
|
19
|
+
`\nFiled one by mistake? Withdraw it yourself with \`gipity bug retract <id>\`` +
|
|
20
|
+
`\n(works until a human picks it up) — don't file a second report asking for a close.`);
|
|
19
21
|
bugCommand
|
|
20
22
|
.command('report')
|
|
21
23
|
.description('File a bug / friction report about the Gipity platform')
|
|
@@ -43,6 +45,24 @@ bugCommand
|
|
|
43
45
|
}
|
|
44
46
|
console.log(success(`✓ Bug report filed (${res.data.report_guid}) — queued for triage.`));
|
|
45
47
|
}));
|
|
48
|
+
bugCommand
|
|
49
|
+
.command('retract <id>')
|
|
50
|
+
.description('Withdraw a bug report you filed (e.g. it was your own mistake)')
|
|
51
|
+
.option('--reason <text>', 'Short note for triage on why you are retracting')
|
|
52
|
+
.option('--project <guid-or-slug>', 'Resolve auth against a specific project instead of cwd / Home')
|
|
53
|
+
.option('--json', 'Output raw JSON')
|
|
54
|
+
.addHelpText('after', `\nOnly works on your own reports, and only while they are still queued` +
|
|
55
|
+
`\n(status new/triaged). Once a report is filed/fixed/dismissed, a human` +
|
|
56
|
+
`\nowns closing it out. Find report ids with \`gipity bug list\`.`)
|
|
57
|
+
.action((id, opts) => run('Bug report', async () => {
|
|
58
|
+
const { config } = await resolveProjectContext({ projectOverride: opts.project });
|
|
59
|
+
const res = await post(`/api/${config.projectGuid}/services/bug-report/retract`, { report_guid: id, reason: opts.reason });
|
|
60
|
+
if (opts.json) {
|
|
61
|
+
console.log(JSON.stringify(res.data));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
console.log(success(`✓ Bug report ${res.data.report_guid} retracted — it is out of the triage queue.`));
|
|
65
|
+
}));
|
|
46
66
|
bugCommand
|
|
47
67
|
.command('list')
|
|
48
68
|
.description('List the bug / friction reports you have filed')
|
package/dist/commands/fn.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { get, post, del } from '../api.js';
|
|
2
|
+
import { get, post, del, publicPost } from '../api.js';
|
|
3
3
|
import { requireConfig } from '../config.js';
|
|
4
4
|
import { error as clrError, bold, muted, success, warning } from '../colors.js';
|
|
5
5
|
import { run, printList, emitField } from '../helpers/index.js';
|
|
@@ -42,17 +42,36 @@ fnCommand
|
|
|
42
42
|
return line;
|
|
43
43
|
});
|
|
44
44
|
}));
|
|
45
|
+
/** Invoke a function exactly like an anonymous visitor: mint the same
|
|
46
|
+
* short-lived public app token the browser SDK uses (best-effort — public
|
|
47
|
+
* functions run with no token at all), POST with no user credentials, and
|
|
48
|
+
* return the standard envelope. A 401 here is the correct answer for an
|
|
49
|
+
* auth-gated function — the server's message is surfaced as-is, with no
|
|
50
|
+
* "run: gipity login" misdirection. */
|
|
51
|
+
async function callAnon(projectGuid, name, body) {
|
|
52
|
+
let appToken;
|
|
53
|
+
try {
|
|
54
|
+
const minted = await publicPost('/api/token', { app: projectGuid });
|
|
55
|
+
appToken = minted.data.token;
|
|
56
|
+
}
|
|
57
|
+
catch { /* public functions work without a token; auth-gated ones will 401 with the real reason */ }
|
|
58
|
+
return publicPost(`/api/${projectGuid}/fn/${encodeURIComponent(name)}`, body, appToken ? { 'X-App-Token': appToken } : undefined);
|
|
59
|
+
}
|
|
45
60
|
fnCommand
|
|
46
61
|
.command('call <name> [body]')
|
|
47
62
|
.description('Call a function')
|
|
48
63
|
.option('--data <json>', 'JSON request body')
|
|
64
|
+
.option('--anon', 'Call as an anonymous visitor (the public path a signed-out user hits) instead of as your signed-in account')
|
|
49
65
|
.option('--field <path>', 'Print only this field of the result (dot path, e.g. items.0.short_guid)')
|
|
50
66
|
.option('--json', 'Output as JSON')
|
|
51
67
|
.action((name, bodyArg, opts) => run('Call', async () => {
|
|
52
68
|
const config = requireConfig();
|
|
53
69
|
const raw = bodyArg || opts.data || '{}';
|
|
54
70
|
const body = JSON.parse(raw);
|
|
55
|
-
const
|
|
71
|
+
const path = `/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`;
|
|
72
|
+
const res = opts.anon
|
|
73
|
+
? await callAnon(config.projectGuid, name, body)
|
|
74
|
+
: await post(path, body);
|
|
56
75
|
if (opts.field) {
|
|
57
76
|
emitField(res.data, opts.field);
|
|
58
77
|
return;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { Command, Option } from 'commander';
|
|
3
3
|
import { post, get, ApiError } from '../api.js';
|
|
4
|
-
import { brand, bold, muted, warning } from '../colors.js';
|
|
4
|
+
import { brand, bold, muted, warning, success } from '../colors.js';
|
|
5
5
|
import { run } from '../helpers/index.js';
|
|
6
|
+
import { getAuth } from '../auth.js';
|
|
6
7
|
import { resolveProjectContext } from '../config.js';
|
|
7
8
|
import { uploadPublicFixture, deleteFixture } from '../page-fixtures.js';
|
|
8
9
|
// Shown when an eval runs cleanly but returns nothing serializable. Turns a
|
|
@@ -43,6 +44,26 @@ export function normalizeEvalResult(raw) {
|
|
|
43
44
|
}
|
|
44
45
|
return { result: raw, noValue: false };
|
|
45
46
|
}
|
|
47
|
+
// A one-line inline expr is worth echoing back — it's the thing you're asserting
|
|
48
|
+
// on. A multi-line driver script is not: echoing 30 lines of the caller's own
|
|
49
|
+
// source above the result buries the value, and an echo that lands right before
|
|
50
|
+
// `(empty result)` reads like the parser choked on the script rather than like
|
|
51
|
+
// the script returned nothing. Collapse anything bigger than a single short line
|
|
52
|
+
// to its first line plus a shape summary.
|
|
53
|
+
const EXPR_ECHO_MAX_CHARS = 120;
|
|
54
|
+
export function summarizeExpr(expr) {
|
|
55
|
+
const lines = expr.split('\n');
|
|
56
|
+
const meaningful = lines.filter((l) => l.trim() !== '');
|
|
57
|
+
const oneLine = meaningful.length <= 1;
|
|
58
|
+
if (oneLine && expr.trim().length <= EXPR_ECHO_MAX_CHARS)
|
|
59
|
+
return expr.trim();
|
|
60
|
+
const first = (meaningful[0] ?? '').trim();
|
|
61
|
+
const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 1)}…` : first;
|
|
62
|
+
const shape = oneLine
|
|
63
|
+
? `(${expr.trim().length} chars)`
|
|
64
|
+
: `(+${meaningful.length - 1} more ${meaningful.length - 1 === 1 ? 'line' : 'lines'}, ${expr.trim().length} chars)`;
|
|
65
|
+
return `${head} ${shape}`;
|
|
66
|
+
}
|
|
46
67
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
47
68
|
// A single browser session is held open synchronously for the whole --wait, so
|
|
48
69
|
// the server caps it at the gateway idle timeout. Longer is impossible in one
|
|
@@ -140,11 +161,13 @@ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr',
|
|
|
140
161
|
// tools, undo/redo, transforms) and `return` a JSON-serializable result —
|
|
141
162
|
// no /tmp + shell command-substitution harness needed.
|
|
142
163
|
export const pageEvalCommand = new Command('eval')
|
|
143
|
-
.description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script)')
|
|
164
|
+
.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')
|
|
144
165
|
.argument('<url>', 'URL to load')
|
|
145
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.')
|
|
146
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>.')
|
|
147
168
|
.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
|
+
.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
|
+
.option('--reload-file <path>', 'Read the post-reload expression from a file instead of inline --reload (mutually exclusive)')
|
|
148
171
|
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
|
|
149
172
|
.option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
|
|
150
173
|
.option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
|
|
@@ -186,6 +209,20 @@ export const pageEvalCommand = new Command('eval')
|
|
|
186
209
|
pageEvalCommand.error(`error: Cannot read file: ${opts.file}`);
|
|
187
210
|
}
|
|
188
211
|
}
|
|
212
|
+
// Post-reload expression: inline --reload or --reload-file, same shape
|
|
213
|
+
// rules as the primary <expr>/--file pair.
|
|
214
|
+
if (opts.reload !== undefined && opts.reloadFile) {
|
|
215
|
+
pageEvalCommand.error('error: Pass either --reload <expr> or --reload-file <path>, not both');
|
|
216
|
+
}
|
|
217
|
+
let reloadExpr = opts.reload;
|
|
218
|
+
if (opts.reloadFile) {
|
|
219
|
+
try {
|
|
220
|
+
reloadExpr = readFileSync(opts.reloadFile, 'utf8');
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
pageEvalCommand.error(`error: Cannot read file: ${opts.reloadFile}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
189
226
|
const waitMs = capWaitMs(opts.wait, url);
|
|
190
227
|
const parsedTimeout = parseInt(opts.waitTimeout, 10);
|
|
191
228
|
const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
|
|
@@ -214,31 +251,54 @@ export const pageEvalCommand = new Command('eval')
|
|
|
214
251
|
}
|
|
215
252
|
const kickoff = await post('/tools/browser/eval', {
|
|
216
253
|
url, expr: sentExpr, waitMs,
|
|
254
|
+
reloadExpr,
|
|
217
255
|
waitForSelector: opts.waitFor || undefined,
|
|
218
256
|
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
219
257
|
auth: opts.auth || undefined,
|
|
220
258
|
});
|
|
221
|
-
|
|
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);
|
|
222
261
|
const { result, noValue } = normalizeEvalResult(d.result);
|
|
262
|
+
const reload = d.reloadResult !== undefined ? normalizeEvalResult(d.reloadResult) : undefined;
|
|
223
263
|
const execTimeout = evalExecTimeoutMessage(d.result);
|
|
224
264
|
if (execTimeout)
|
|
225
265
|
throw new Error(execTimeout);
|
|
226
266
|
if (opts.json) {
|
|
227
|
-
console.log(JSON.stringify(
|
|
267
|
+
console.log(JSON.stringify({
|
|
268
|
+
...d, result,
|
|
269
|
+
...(reload ? { reloadResult: reload.result } : {}),
|
|
270
|
+
...(noValue ? { hint: EVAL_NO_VALUE_HINT } : {}),
|
|
271
|
+
}));
|
|
228
272
|
return;
|
|
229
273
|
}
|
|
230
274
|
console.log(`${brand('Eval')} ${bold(d.url || url)}`);
|
|
231
275
|
if (d.navigationIncomplete) {
|
|
232
276
|
console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
|
|
233
277
|
}
|
|
278
|
+
// Auth state: without this line an agent can't distinguish "signed-in
|
|
279
|
+
// eval" from "--auth silently no-op'd against the anonymous page".
|
|
280
|
+
if (d.auth?.requested) {
|
|
281
|
+
const who = getAuth()?.email;
|
|
282
|
+
console.log(d.auth.established
|
|
283
|
+
? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
|
|
284
|
+
: `${warning('Auth: session NOT established')}${d.auth.detail ? ` — ${d.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
|
|
285
|
+
}
|
|
234
286
|
if (hosted.length)
|
|
235
287
|
console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
|
|
236
|
-
console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${expr}`);
|
|
288
|
+
console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
|
|
237
289
|
console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
|
|
238
290
|
if (noValue)
|
|
239
291
|
console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
|
|
240
292
|
if (d.truncated)
|
|
241
293
|
console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
|
|
294
|
+
if (reload) {
|
|
295
|
+
console.log(`\n${bold('After reload')} ${muted('(page reloaded in place — storage preserved)')}`);
|
|
296
|
+
console.log(reload.result.trim() ? reload.result : muted('(empty result)'));
|
|
297
|
+
if (reload.noValue)
|
|
298
|
+
console.log(muted(EVAL_NO_VALUE_HINT));
|
|
299
|
+
if (d.reloadTruncated)
|
|
300
|
+
console.log(muted('(reload result truncated to fit context - narrow the expression for the full value)'));
|
|
301
|
+
}
|
|
242
302
|
}
|
|
243
303
|
finally {
|
|
244
304
|
for (const h of hosted) {
|
|
@@ -271,6 +331,16 @@ Examples:
|
|
|
271
331
|
# fetch-able 'fixtureUrl', runs the eval, then deletes the hosted copy:
|
|
272
332
|
gipity page eval "https://dev.gipity.ai/me/app/" --fixture ./sample.mp3 \\
|
|
273
333
|
"(async()=>{ const b = await fetch(fixtureUrl).then(r=>r.arrayBuffer()); return window.App.parseId3(b); })()"
|
|
334
|
+
# Verify persisted state survives a reload (localStorage/sessionStorage kept):
|
|
335
|
+
# run <expr>, reload the page in place, then run the --reload expression:
|
|
336
|
+
gipity page eval "https://dev.gipity.ai/me/app/" \\
|
|
337
|
+
"localStorage.setItem('todo','milk'); document.title" \\
|
|
338
|
+
--reload "({ restored: localStorage.getItem('todo'), heading: document.querySelector('h1')?.textContent })"
|
|
339
|
+
|
|
340
|
+
Module resolution: dynamic import() specifiers starting with ./ or ../ resolve
|
|
341
|
+
against the PAGE URL, so import('./packages/i18n/index.js') loads the app's own
|
|
342
|
+
module without hand-building the deployed /account/project/ path. Absolute paths
|
|
343
|
+
and full URLs pass through unchanged.
|
|
274
344
|
|
|
275
345
|
The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
|
|
276
346
|
pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long
|
|
@@ -32,7 +32,7 @@ function shortUrl(url, truncate = true, maxLen = 100) {
|
|
|
32
32
|
return result.slice(0, headLen) + '…' + result.slice(-tailLen);
|
|
33
33
|
}
|
|
34
34
|
export const pageInspectCommand = new Command('inspect')
|
|
35
|
-
.description('Inspect a web page (console, failed resources, timing, layout overflow)')
|
|
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`')
|
|
36
36
|
.argument('<url>', 'URL to inspect')
|
|
37
37
|
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle; max 30000)', '500')
|
|
38
38
|
.option('--wait-for <selector>', 'Wait until this CSS selector appears before capturing (deterministic; replaces --wait)')
|
|
@@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync } from 'fs';
|
|
|
3
3
|
import { dirname, join, resolve as resolvePath } from 'path';
|
|
4
4
|
import { postForTarEntries } from '../api.js';
|
|
5
5
|
import { getProjectRoot } from '../config.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getAuth } from '../auth.js';
|
|
7
|
+
import { brand, bold, muted, success, warning } from '../colors.js';
|
|
7
8
|
import { formatSize } from '../utils.js';
|
|
8
9
|
import { run } from '../helpers/index.js';
|
|
9
10
|
import { withSpinner } from '../progress.js';
|
|
@@ -28,6 +29,25 @@ function label(text) {
|
|
|
28
29
|
function fmtMs(ms) {
|
|
29
30
|
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
|
|
30
31
|
}
|
|
32
|
+
/** Auth state line (same format as page inspect/eval): without it an agent
|
|
33
|
+
* can't tell a signed-in capture from "--auth silently no-op'd and this is
|
|
34
|
+
* the anonymous page". */
|
|
35
|
+
function printAuthLine(auth) {
|
|
36
|
+
if (!auth?.requested)
|
|
37
|
+
return;
|
|
38
|
+
const who = getAuth()?.email;
|
|
39
|
+
console.log(auth.established
|
|
40
|
+
? `${label('Auth')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
|
|
41
|
+
: `${warning('Auth: session NOT established')}${auth.detail ? ` — ${auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
|
|
42
|
+
}
|
|
43
|
+
/** A failed --action still yields a screenshot - of the page the action never
|
|
44
|
+
* 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
|
+
function printActionErrorLine(actionError) {
|
|
47
|
+
if (!actionError)
|
|
48
|
+
return;
|
|
49
|
+
console.log(`${warning('⚠ --action failed:')} ${actionError} ${muted('(this image shows the page BEFORE the action ran)')}`);
|
|
50
|
+
}
|
|
31
51
|
function fmtPerformance(p) {
|
|
32
52
|
const parts = [
|
|
33
53
|
`TTFB ${fmtMs(p.ttfb)}`,
|
|
@@ -137,7 +157,7 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
137
157
|
// so the `?? opts.wait` merge below would never see the --wait alias. Default
|
|
138
158
|
// is applied in the merge instead.
|
|
139
159
|
.option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms (default: 1000)')
|
|
140
|
-
.option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs after the post-load delay, then settles again before the shot.')
|
|
160
|
+
.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.')
|
|
141
161
|
.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.')
|
|
142
162
|
.option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
|
|
143
163
|
.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, [])
|
|
@@ -223,6 +243,7 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
223
243
|
final_url: meta.finalUrl,
|
|
224
244
|
status: meta.status,
|
|
225
245
|
performance: meta.performance,
|
|
246
|
+
...(meta.auth ? { auth: meta.auth } : {}),
|
|
226
247
|
},
|
|
227
248
|
screenshots: meta.screenshots.map((s, i) => ({
|
|
228
249
|
file: savedFiles[i],
|
|
@@ -253,6 +274,8 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
253
274
|
console.log(`${label('Web page status')} ${meta.status}`);
|
|
254
275
|
if (meta.performance)
|
|
255
276
|
console.log(`${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
|
|
277
|
+
printAuthLine(meta.auth);
|
|
278
|
+
printActionErrorLine(meta.actionError);
|
|
256
279
|
if (s.viewport.device)
|
|
257
280
|
console.log(`${label('Emulated device')} ${s.viewport.device} ${muted('(touch events, mobile user-agent)')}`);
|
|
258
281
|
const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
|
|
@@ -271,6 +294,8 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
271
294
|
console.log(`${label('Web page status')} ${meta.status}`);
|
|
272
295
|
if (meta.performance)
|
|
273
296
|
console.log(`${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
|
|
297
|
+
printAuthLine(meta.auth);
|
|
298
|
+
printActionErrorLine(meta.actionError);
|
|
274
299
|
for (let i = 0; i < meta.screenshots.length; i++) {
|
|
275
300
|
const s = meta.screenshots[i];
|
|
276
301
|
const dims = `${s.viewport.width}×${s.viewport.height}${s.viewport.deviceScaleFactor > 1 ? ` @${s.viewport.deviceScaleFactor}x` : ''}`;
|
package/dist/commands/page.js
CHANGED
|
@@ -10,7 +10,7 @@ import { pageFetchCommand } from './page-fetch.js';
|
|
|
10
10
|
// top-level surface lean and makes the siblings discoverable via `page --help`.
|
|
11
11
|
// `inspect` is the rendered DOM (browser); `fetch` is the raw asset (plain HTTP).
|
|
12
12
|
export const pageCommand = new Command('page')
|
|
13
|
-
.description('Inspect web pages')
|
|
13
|
+
.description('Inspect, drive, and test web pages in a real browser (page test = N concurrent clients for realtime/presence verification)')
|
|
14
14
|
.addCommand(pageInspectCommand)
|
|
15
15
|
.addCommand(pageEvalCommand)
|
|
16
16
|
.addCommand(pageScreenshotCommand)
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -169,6 +169,11 @@ sandboxCommand
|
|
|
169
169
|
.option('--file <path>', 'Read the code body from a file instead of the inline <code> arg; --language is inferred from the extension when not given')
|
|
170
170
|
.option('--timeout <seconds>', 'Execution timeout in seconds', '30')
|
|
171
171
|
.option('--input <path>', 'Narrow to specific project files instead of auto-mirroring the whole tree (repeatable). Use this only for >1 GB projects or when you want surgical control.', (v, prev) => [...(prev ?? []), v])
|
|
172
|
+
// Commander maps a `--no-` prefixed flag to the un-prefixed camelCase name
|
|
173
|
+
// (`opts.syncOutput`); the explicit `[]` default stops commander's negated-
|
|
174
|
+
// boolean convention from defaulting it to `true`. Collected as an array so
|
|
175
|
+
// the flag is repeatable.
|
|
176
|
+
.option('--no-sync-output <glob>', 'Do not persist run outputs matching this glob back to the project (repeatable). For byproducts you want to inspect once but never keep, e.g. --no-sync-output "docs/preview*". Supports *, **, and dir/ prefixes.', (v, prev) => [...prev, v], [])
|
|
172
177
|
.option('--json', 'Output as JSON')
|
|
173
178
|
.addHelpText('after', `
|
|
174
179
|
By default the whole project is auto-mirrored into /work/ (up to 1 GB) -
|
|
@@ -263,6 +268,13 @@ GCC/Rust).
|
|
|
263
268
|
// This runs BEFORE the project sync and the server round trip below, so a
|
|
264
269
|
// missing language costs nothing but the message.
|
|
265
270
|
const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath, inlineCode });
|
|
271
|
+
// Validate --no-sync-output globs while we're still pre-network: an empty
|
|
272
|
+
// pattern (e.g. a quoting mishap) would silently match nothing server-side.
|
|
273
|
+
const noSyncOutput = opts.syncOutput ?? [];
|
|
274
|
+
if (noSyncOutput.some((g) => !g.trim())) {
|
|
275
|
+
console.error(clrError('--no-sync-output requires a non-empty glob (e.g. --no-sync-output "docs/preview*")'));
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
266
278
|
// Args are good - now it's worth resolving (and announcing) the project.
|
|
267
279
|
const { config } = await resolveProjectContext();
|
|
268
280
|
const timeout = parseInt(opts.timeout, 10);
|
|
@@ -297,6 +309,10 @@ GCC/Rust).
|
|
|
297
309
|
timeout: isNaN(timeout) ? 30 : timeout,
|
|
298
310
|
input_files: opts.input,
|
|
299
311
|
cwd,
|
|
312
|
+
// The filter must run SERVER-side (in the output extractor): skipping
|
|
313
|
+
// only in the CLI would still write the files into project storage and
|
|
314
|
+
// make every later sync propose deleting them.
|
|
315
|
+
noSyncOutput: noSyncOutput.length ? noSyncOutput : undefined,
|
|
300
316
|
});
|
|
301
317
|
const res = opts.json
|
|
302
318
|
? await doRun()
|
|
@@ -333,6 +349,11 @@ GCC/Rust).
|
|
|
333
349
|
for (const f of res.data.outputFiles)
|
|
334
350
|
console.log(`${f}`);
|
|
335
351
|
}
|
|
352
|
+
if (res.data.skippedOutputFiles && res.data.skippedOutputFiles.length > 0) {
|
|
353
|
+
console.log(dim('\nNot persisted (--no-sync-output):'));
|
|
354
|
+
for (const f of res.data.skippedOutputFiles)
|
|
355
|
+
console.log(dim(`${f}`));
|
|
356
|
+
}
|
|
336
357
|
if (res.data.exitCode !== 0) {
|
|
337
358
|
// No "did you mean another language?" hint is needed: the language is now
|
|
338
359
|
// always something the caller pinned, never a silent default we chose.
|
package/dist/commands/test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { get, post } from '../api.js';
|
|
3
|
-
import { requireConfig } from '../config.js';
|
|
3
|
+
import { requireConfig, getProjectRoot } from '../config.js';
|
|
4
4
|
import { success, error as clrError, warning, muted, bold, dim } from '../colors.js';
|
|
5
5
|
import { run, syncBeforeAction } from '../helpers/index.js';
|
|
6
6
|
// Absolute poll ceiling - the server reaps stalled runs (~65 min) well before
|
|
@@ -15,6 +15,22 @@ function statusIcon(status) {
|
|
|
15
15
|
return muted('→');
|
|
16
16
|
return muted('?');
|
|
17
17
|
}
|
|
18
|
+
/** When the runner finds nothing, list the test-looking files it deliberately
|
|
19
|
+
* skipped (kit tests under src/packages/, .spec.* files) so "no tests" never
|
|
20
|
+
* contradicts what's on disk, and point at the sandbox as the way to run
|
|
21
|
+
* frontend/kit module tests (cli#120/cli#121). */
|
|
22
|
+
function printSkippedCandidates(skipped) {
|
|
23
|
+
if (skipped.length === 0)
|
|
24
|
+
return;
|
|
25
|
+
console.log('');
|
|
26
|
+
console.log(muted(`Found ${skipped.length} test-looking file${skipped.length === 1 ? '' : 's'} that gipity test does not run:`));
|
|
27
|
+
for (const s of skipped.slice(0, 10)) {
|
|
28
|
+
console.log(muted(` ${s.path} (${s.reason})`));
|
|
29
|
+
}
|
|
30
|
+
if (skipped.length > 10)
|
|
31
|
+
console.log(muted(` ... and ${skipped.length - 10} more`));
|
|
32
|
+
console.log(muted('Run frontend/kit module tests in the sandbox: gipity sandbox run bash "node <file>"'));
|
|
33
|
+
}
|
|
18
34
|
// Long-run hint for non-TTY runs: after this, print a one-time "not hung" + faster-path hint.
|
|
19
35
|
const LONG_RUN_MS = 60000;
|
|
20
36
|
async function pollTestStatus(projectGuid, runGuid, opts) {
|
|
@@ -169,9 +185,21 @@ export const testCommand = new Command('test')
|
|
|
169
185
|
process.exit(1);
|
|
170
186
|
}
|
|
171
187
|
if (!filterPath && data.total === 0 && data.results.length === 0) {
|
|
172
|
-
|
|
188
|
+
// Name the searched root: discovery is server-side over the synced
|
|
189
|
+
// project tree's tests/, never the shell cwd - an agent running from a
|
|
190
|
+
// subdirectory must not read this as "the project has no tests" for
|
|
191
|
+
// the wrong reason (cli#120).
|
|
192
|
+
const root = getProjectRoot();
|
|
193
|
+
console.log(muted(`No tests ran - no tests/*.test.js files under the project root${root ? ` (${root})` : ''}.`));
|
|
173
194
|
console.log(muted('gipity test runs server-function tests only; a frontend-only app (no functions/) has nothing here to run.'));
|
|
174
195
|
console.log(muted('Verify a frontend app by driving the live page: gipity page inspect <url> (or gipity page eval).'));
|
|
196
|
+
// Enumerate test-looking files the runner deliberately skips (kit
|
|
197
|
+
// tests, .spec.*) so this answer never contradicts what's on disk.
|
|
198
|
+
try {
|
|
199
|
+
const listRes = await get(`/projects/${config.projectGuid}/test/list`);
|
|
200
|
+
printSkippedCandidates(listRes.data.skipped ?? []);
|
|
201
|
+
}
|
|
202
|
+
catch { /* the skipped list is advisory - never fail the run over it */ }
|
|
175
203
|
return;
|
|
176
204
|
}
|
|
177
205
|
if (data.status === 'failed' && data.errorMessage) {
|
|
@@ -268,9 +296,17 @@ testCommand
|
|
|
268
296
|
return;
|
|
269
297
|
}
|
|
270
298
|
if (total === 0) {
|
|
299
|
+
// Discovery is server-side over the synced project tree's tests/ -
|
|
300
|
+
// never the shell cwd. Say which root was searched so this can't be
|
|
301
|
+
// misread as a definitive project-level fact from a subdirectory
|
|
302
|
+
// (cli#120), and list any test-looking files deliberately skipped
|
|
303
|
+
// (kit tests, .spec.*) instead of asserting none exist (cli#121).
|
|
304
|
+
const root = getProjectRoot();
|
|
271
305
|
console.log(muted(pathFilter
|
|
272
306
|
? `No test files matched filter: ${pathFilter}`
|
|
273
|
-
:
|
|
307
|
+
: `No test files found under tests/ at the project root${root ? ` (${root})` : ''}. Add *.test.js files under tests/.`));
|
|
308
|
+
if (!pathFilter)
|
|
309
|
+
printSkippedCandidates(res.data.skipped ?? []);
|
|
274
310
|
return;
|
|
275
311
|
}
|
|
276
312
|
console.log(bold(`Test files${pathFilter ? ` (filter: ${pathFilter})` : ''}: ${total}`));
|
|
@@ -29,6 +29,25 @@ function formatRunLine(r) {
|
|
|
29
29
|
const statusColor = r.status === 'completed' ? success : r.status === 'failed' ? clrError : muted;
|
|
30
30
|
return `${muted(r.short_guid)} ${statusColor(r.status)} ${dur} ${runTokens(r)} tokens ${muted(fmtTime(r.started_at))}`;
|
|
31
31
|
}
|
|
32
|
+
/** Print each step's status, tokens, model, error and output. A run line alone
|
|
33
|
+
* says a run finished, not what it did — without the steps you can't tell a
|
|
34
|
+
* workflow that wrote a row from one that silently skipped every step. */
|
|
35
|
+
function printStepRuns(steps, emptyNote) {
|
|
36
|
+
if (steps.length === 0) {
|
|
37
|
+
console.log(` ${muted(emptyNote)}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const s of steps) {
|
|
41
|
+
const statusColor = s.status === 'completed' ? success : s.status === 'failed' ? clrError : muted;
|
|
42
|
+
const model = s.model_used ? ` ${muted(`[${s.model_used}]`)}` : '';
|
|
43
|
+
console.log(` ${s.step_order}. ${statusColor(s.status)} ${s.tokens_used ?? 0} tokens${model}`);
|
|
44
|
+
if (s.error_message)
|
|
45
|
+
console.log(` ${clrError(s.error_message)}`);
|
|
46
|
+
if (s.output_json !== null && s.output_json !== undefined) {
|
|
47
|
+
console.log(JSON.stringify(s.output_json, null, 2).split('\n').map(l => ` ${l}`).join('\n'));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
32
51
|
const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
|
33
52
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
34
53
|
/**
|
|
@@ -155,6 +174,10 @@ workflowCommand
|
|
|
155
174
|
console.log(formatRunLine(r));
|
|
156
175
|
if (r.error_message)
|
|
157
176
|
console.log(` ${clrError(r.error_message)}`);
|
|
177
|
+
// The whole point of --wait is to see what the run did. The detail endpoint
|
|
178
|
+
// we just polled already carries the steps, so show them rather than make
|
|
179
|
+
// the caller re-query the database to find out whether anything happened.
|
|
180
|
+
printStepRuns(r.step_runs ?? [], '(no steps recorded)');
|
|
158
181
|
}
|
|
159
182
|
if (r.status !== 'completed')
|
|
160
183
|
process.exit(1);
|
|
@@ -176,22 +199,7 @@ workflowCommand
|
|
|
176
199
|
return;
|
|
177
200
|
}
|
|
178
201
|
console.log(formatRunLine(r));
|
|
179
|
-
|
|
180
|
-
if (steps.length === 0) {
|
|
181
|
-
console.log(' (no steps recorded)');
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
for (const s of steps) {
|
|
185
|
-
const statusColor = s.status === 'completed' ? success : s.status === 'failed' ? clrError : muted;
|
|
186
|
-
const model = s.model_used ? ` ${muted(`[${s.model_used}]`)}` : '';
|
|
187
|
-
console.log(` ${s.step_order}. ${statusColor(s.status)} ${s.tokens_used ?? 0} tokens${model}`);
|
|
188
|
-
if (s.error_message)
|
|
189
|
-
console.log(` ${clrError(s.error_message)}`);
|
|
190
|
-
if (s.output_json !== null && s.output_json !== undefined) {
|
|
191
|
-
const pretty = JSON.stringify(s.output_json, null, 2).split('\n').map(l => ` ${l}`).join('\n');
|
|
192
|
-
console.log(pretty);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
202
|
+
printStepRuns(r.step_runs ?? [], '(no steps recorded)');
|
|
195
203
|
return;
|
|
196
204
|
}
|
|
197
205
|
const res = await get(`/workflows/${wf.short_guid}/runs`);
|