gipity 1.0.384 → 1.0.387
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 -2
- package/dist/auth.js +22 -13
- package/dist/commands/add.js +2 -1
- package/dist/commands/claude.js +4 -4
- package/dist/commands/db.js +4 -1
- package/dist/commands/deploy.js +15 -3
- package/dist/commands/init.js +21 -8
- package/dist/commands/page-eval.js +191 -22
- package/dist/commands/page-inspect.js +37 -6
- package/dist/commands/page-screenshot.js +13 -5
- package/dist/commands/page-test.js +58 -9
- package/dist/commands/records.js +42 -8
- package/dist/commands/remove.js +42 -0
- package/dist/commands/sandbox.js +67 -14
- package/dist/commands/status.js +8 -4
- package/dist/commands/test.js +16 -4
- package/dist/commands/text.js +1 -1
- package/dist/commands/workflow.js +78 -19
- package/dist/config.js +46 -2
- package/dist/helpers/sync.js +4 -2
- package/dist/helpers/text-analysis.js +9 -5
- package/dist/index.js +27 -14
- package/dist/knowledge.js +8 -1
- package/dist/page-fixtures.js +41 -0
- package/dist/project-setup.js +4 -4
- package/dist/relay/daemon.js +2 -2
- package/dist/relay/device-http.js +2 -2
- package/dist/setup.js +71 -3
- package/dist/sync.js +137 -18
- package/package.json +3 -3
package/dist/api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Readable } from 'stream';
|
|
2
2
|
import * as tar from 'tar-stream';
|
|
3
3
|
import { getAuth, refreshTokenIfNeeded } from './auth.js';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveApiBase, requireConfig, saveConfig } from './config.js';
|
|
5
5
|
export class ApiError extends Error {
|
|
6
6
|
statusCode;
|
|
7
7
|
code;
|
|
@@ -27,7 +27,7 @@ async function getHeaders() {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
function baseUrl() {
|
|
30
|
-
return
|
|
30
|
+
return resolveApiBase();
|
|
31
31
|
}
|
|
32
32
|
/** Exposed so streaming consumers (SSE) can build URLs without re-implementing
|
|
33
33
|
* the override / config resolution. */
|
|
@@ -101,6 +101,9 @@ export async function postForTarEntries(path, body) {
|
|
|
101
101
|
export function put(path, body) {
|
|
102
102
|
return request('PUT', path, body);
|
|
103
103
|
}
|
|
104
|
+
export function patch(path, body) {
|
|
105
|
+
return request('PATCH', path, body);
|
|
106
|
+
}
|
|
104
107
|
export function del(path, body) {
|
|
105
108
|
return request('DELETE', path, body);
|
|
106
109
|
}
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, chmodSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { decodeJwtExp } from './utils.js';
|
|
@@ -33,8 +33,16 @@ export function readAuthFresh() {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
export function saveAuth(data) {
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// Lock down to owner-only: this file holds the account access + 7-day refresh
|
|
37
|
+
// tokens, so a default 0644/0755 would let any other local user read them.
|
|
38
|
+
// (The relay state file already does this; auth.json is the more sensitive of
|
|
39
|
+
// the two.) chmod after write to also tighten any pre-existing loose file.
|
|
40
|
+
mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
|
|
41
|
+
writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
42
|
+
try {
|
|
43
|
+
chmodSync(AUTH_FILE, 0o600);
|
|
44
|
+
}
|
|
45
|
+
catch { /* best-effort on platforms without chmod */ }
|
|
38
46
|
cached = data;
|
|
39
47
|
}
|
|
40
48
|
export function clearAuth() {
|
|
@@ -52,17 +60,18 @@ export function isExpired() {
|
|
|
52
60
|
const buffer = 5 * 60 * 1000; // 5 minute buffer
|
|
53
61
|
return Date.now() > expiresAt - buffer;
|
|
54
62
|
}
|
|
55
|
-
|
|
63
|
+
/** True only when re-login is genuinely required: the refresh token itself
|
|
64
|
+
* has expired. Access-token expiry (`expiresAt` / isExpired) is invisible
|
|
65
|
+
* to users — every API call renews it via refreshTokenIfNeeded() — so it
|
|
66
|
+
* must never be surfaced as a session warning. */
|
|
67
|
+
export function sessionExpired() {
|
|
56
68
|
const auth = getAuth();
|
|
57
69
|
if (!auth)
|
|
58
|
-
return
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
if (mins < 60)
|
|
64
|
-
return `${mins}m remaining`;
|
|
65
|
-
return `${Math.floor(mins / 60)}h ${mins % 60}m remaining`;
|
|
70
|
+
return true;
|
|
71
|
+
const exp = decodeJwtExp(auth.refreshToken);
|
|
72
|
+
if (!exp)
|
|
73
|
+
return false; // undecodable - let the refresh path decide
|
|
74
|
+
return Date.now() > exp * 1000;
|
|
66
75
|
}
|
|
67
76
|
export async function refreshTokenIfNeeded() {
|
|
68
77
|
if (!isExpired())
|
|
@@ -72,7 +81,7 @@ export async function refreshTokenIfNeeded() {
|
|
|
72
81
|
return; // not logged in, caller will handle
|
|
73
82
|
try {
|
|
74
83
|
const config = await import('./config.js');
|
|
75
|
-
const apiBase = config.
|
|
84
|
+
const apiBase = config.resolveApiBase();
|
|
76
85
|
const res = await fetch(`${apiBase}/auth/refresh`, {
|
|
77
86
|
method: 'POST',
|
|
78
87
|
headers: { 'Content-Type': 'application/json' },
|
package/dist/commands/add.js
CHANGED
|
@@ -179,7 +179,8 @@ export const addCommand = new Command('add')
|
|
|
179
179
|
}
|
|
180
180
|
const { name: labelName, files } = buildLocalPayload(resolved);
|
|
181
181
|
const kind = sniffPayloadKind(files);
|
|
182
|
-
|
|
182
|
+
// Progress goes to stderr so `gipity add … --json` keeps stdout pure JSON.
|
|
183
|
+
console.error(muted(`Uploading ${files.length} file(s) from ${resolved} (${kind}) ...`));
|
|
183
184
|
body = {
|
|
184
185
|
name: labelName,
|
|
185
186
|
title: opts.title,
|
package/dist/commands/claude.js
CHANGED
|
@@ -19,7 +19,7 @@ function resolveCommand(cmd) {
|
|
|
19
19
|
}
|
|
20
20
|
import { getAuth, saveAuth, clearAuth } from '../auth.js';
|
|
21
21
|
import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
|
|
22
|
-
import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, getConfigPath } from '../config.js';
|
|
22
|
+
import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
|
|
23
23
|
import { sync } from '../sync.js';
|
|
24
24
|
import { slugify, setupClaudeHooks, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE, isSyncIgnored } from '../setup.js';
|
|
25
25
|
import { buildProjectContextBlock as buildProjectContextBlockText, buildNewProjectPrompt, buildResumeWrap, buildFreshWrap, } from '../prompts.js';
|
|
@@ -384,7 +384,7 @@ export const claudeCommand = new Command('claude')
|
|
|
384
384
|
accountSlug,
|
|
385
385
|
agentGuid,
|
|
386
386
|
conversationGuid: null,
|
|
387
|
-
apiBase: getApiBaseOverride() ||
|
|
387
|
+
apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
|
|
388
388
|
ignore: DEFAULT_SYNC_IGNORE,
|
|
389
389
|
});
|
|
390
390
|
console.log(`\n Using ${projectDir}`);
|
|
@@ -470,7 +470,7 @@ export const claudeCommand = new Command('claude')
|
|
|
470
470
|
err?.code === 'ETIMEDOUT' || err?.cause?.code === 'ECONNREFUSED' ||
|
|
471
471
|
err?.cause?.code === 'ENOTFOUND' || err?.cause?.code === 'ETIMEDOUT';
|
|
472
472
|
if (isConnectionError) {
|
|
473
|
-
const apiBase = getApiBaseOverride() ||
|
|
473
|
+
const apiBase = getApiBaseOverride() || DEFAULT_API_BASE;
|
|
474
474
|
console.error(` ${clrError(`Could not connect to ${apiBase}`)}`);
|
|
475
475
|
console.error(` ${muted('Check your connection and try again.')}`);
|
|
476
476
|
process.exit(1);
|
|
@@ -554,7 +554,7 @@ export const claudeCommand = new Command('claude')
|
|
|
554
554
|
accountSlug,
|
|
555
555
|
agentGuid,
|
|
556
556
|
conversationGuid: null,
|
|
557
|
-
apiBase: getApiBaseOverride() ||
|
|
557
|
+
apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
|
|
558
558
|
ignore: DEFAULT_SYNC_IGNORE,
|
|
559
559
|
});
|
|
560
560
|
console.log(`\n Using ${projectDir}`);
|
package/dist/commands/db.js
CHANGED
|
@@ -91,7 +91,10 @@ dbCommand
|
|
|
91
91
|
else {
|
|
92
92
|
const config = requireConfig();
|
|
93
93
|
const res = await get(`/projects/${config.projectGuid}/databases`);
|
|
94
|
-
|
|
94
|
+
// This list is project-scoped; the account-wide database cap counts
|
|
95
|
+
// databases across ALL projects. An empty project can still be at the
|
|
96
|
+
// cap, so point at `--all` rather than implying nothing exists.
|
|
97
|
+
printList(res.data, opts, 'No databases in this project. Run `gipity db list --all` to see every database counting toward your account cap, or create one: gipity db create <name>', db => db.friendlyName);
|
|
95
98
|
}
|
|
96
99
|
}));
|
|
97
100
|
dbCommand
|
package/dist/commands/deploy.js
CHANGED
|
@@ -20,7 +20,7 @@ export const deployCommand = new Command('deploy')
|
|
|
20
20
|
.argument('[target]', 'dev or prod', 'dev')
|
|
21
21
|
.option('--source-dir <dir>', 'Source directory to deploy from')
|
|
22
22
|
.option('--only <phases>', 'Run only specific phases (comma-separated)')
|
|
23
|
-
.option('--force', 'Re-run all phases
|
|
23
|
+
.option('--force', 'Re-run all phases (ignore checksums) and bypass the sync bulk-deletion guard')
|
|
24
24
|
.option('--no-sync', 'Skip sync-up before deploy')
|
|
25
25
|
.option('--optimize', 'Run build optimization')
|
|
26
26
|
.option('--json', 'Output as JSON')
|
|
@@ -73,8 +73,20 @@ export const deployCommand = new Command('deploy')
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
console.log(muted('─'.repeat(40)));
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
76
|
+
const failedPhases = d.phases?.filter(p => p.status === 'failed') ?? [];
|
|
77
|
+
if (failedPhases.length > 0) {
|
|
78
|
+
// The database phase can fail on the account-wide database cap, whose
|
|
79
|
+
// server message ("Maximum of N databases reached. Drop one first.")
|
|
80
|
+
// names no command. The droppable databases live in OTHER projects, so
|
|
81
|
+
// the default project-scoped `gipity db list` shows nothing — point the
|
|
82
|
+
// caller straight at the account-wide list + drop path so they don't
|
|
83
|
+
// dead-end (or reach for raw DB access) to free a slot.
|
|
84
|
+
if (failedPhases.some(p => /databases? reached|database (cap|limit)/i.test(p.summary))) {
|
|
85
|
+
console.log('');
|
|
86
|
+
console.log(muted('Free a slot under the account database cap:'));
|
|
87
|
+
console.log(` ${brand('gipity db list --all')} ${muted('# every database counting toward the cap, by project')}`);
|
|
88
|
+
console.log(` ${brand('gipity db drop <name> --project <slug>')} ${muted('# drop one from another project')}`);
|
|
89
|
+
}
|
|
78
90
|
console.log(clrError(`Deploy failed`) + muted(` (${d.elapsedMs}ms)`));
|
|
79
91
|
process.exit(1);
|
|
80
92
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -2,36 +2,38 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { basename, resolve, dirname } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { getAccountSlug } from '../api.js';
|
|
5
|
-
import { getConfig, getConfigPath } from '../config.js';
|
|
5
|
+
import { getConfig, getConfigPath, saveConfigAt } from '../config.js';
|
|
6
6
|
import { getAuth } from '../auth.js';
|
|
7
|
-
import { slugify, setupClaudeHooks, setupGitignore, SUPPORTED_TOOLS } from '../setup.js';
|
|
7
|
+
import { slugify, setupClaudeHooks, setupGitignore, SUPPORTED_TOOLS, DEFAULT_TOOLS, DEFAULT_SYNC_IGNORE } from '../setup.js';
|
|
8
8
|
import { success, error as clrError, info, muted, bold } from '../colors.js';
|
|
9
9
|
import { confirm } from '../utils.js';
|
|
10
10
|
import { scanForAdoption, adoptCurrentDir, canAdoptCwd, formatBytes, formatCwdLabel, ADOPT_THRESHOLDS, } from '../adopt-cwd.js';
|
|
11
11
|
const TOOL_KEYS = SUPPORTED_TOOLS.map(t => t.key);
|
|
12
12
|
function resolveTools(forFlag) {
|
|
13
|
-
if (!forFlag
|
|
14
|
-
return
|
|
13
|
+
if (!forFlag)
|
|
14
|
+
return DEFAULT_TOOLS;
|
|
15
15
|
const requested = forFlag.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
16
16
|
const unknown = requested.filter(k => !TOOL_KEYS.includes(k) && k !== 'all');
|
|
17
17
|
if (unknown.length) {
|
|
18
18
|
throw new Error(`Unknown --for value(s): ${unknown.join(', ')}. Valid: ${TOOL_KEYS.join(', ')}, all`);
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return SUPPORTED_TOOLS.filter(t => requested.includes(t.key));
|
|
20
|
+
// `all` expands to the default set; an opt-in tool still joins when named
|
|
21
|
+
// alongside it (`--for all,aider`).
|
|
22
|
+
return SUPPORTED_TOOLS.filter(t => requested.includes(t.key) || (!t.optIn && requested.includes('all')));
|
|
23
23
|
}
|
|
24
24
|
export const initCommand = new Command('init')
|
|
25
25
|
.description('Link this directory to a Gipity project (writes primer files so your AI coding tool understands Gipity)')
|
|
26
26
|
.argument('[name]', 'Project name/slug (defaults to current directory name)')
|
|
27
27
|
.option('--agent <guid>', 'Agent GUID to use')
|
|
28
|
-
.option('--for <tools>', `Which AI tool primer files to write (comma-separated). Default: all. Choices: ${TOOL_KEYS.join(', ')}, all`)
|
|
28
|
+
.option('--for <tools>', `Which AI tool primer files to write (comma-separated). Default: all except aider (opt-in - it also writes .aider.conf.yml). Choices: ${TOOL_KEYS.join(', ')}, all`)
|
|
29
29
|
.addHelpText('after', `
|
|
30
30
|
Examples:
|
|
31
31
|
$ gipity init Link cwd as a new project (slug = dir name).
|
|
32
32
|
$ gipity init my-app Link cwd with an explicit slug.
|
|
33
33
|
$ gipity init --for codex Write only AGENTS.md (skip Claude/Cursor/etc).
|
|
34
34
|
$ gipity init --for cursor,gemini Write only the Cursor + Gemini primers.
|
|
35
|
+
$ gipity init --for aider AGENTS.md + a read: entry in .aider.conf.yml
|
|
36
|
+
(aider auto-reads nothing, so it's opt-in).
|
|
35
37
|
|
|
36
38
|
Working with an existing Gipity project:
|
|
37
39
|
- If cwd's name matches the remote project's slug, init auto-adopts it.
|
|
@@ -79,6 +81,17 @@ Working with an existing Gipity project:
|
|
|
79
81
|
setupClaudeHooks();
|
|
80
82
|
writeAllPrimers();
|
|
81
83
|
setupGitignore();
|
|
84
|
+
// The config's ignore list was frozen at link time, so a workstation
|
|
85
|
+
// artifact introduced by a newer CLI (e.g. aider's .aider.conf.yml)
|
|
86
|
+
// would sync up as project content. Union in the current defaults.
|
|
87
|
+
if (existing) {
|
|
88
|
+
const cur = existing.ignore ?? (existing.ignore = []);
|
|
89
|
+
const missing = DEFAULT_SYNC_IGNORE.filter(e => !cur.includes(e));
|
|
90
|
+
if (missing.length) {
|
|
91
|
+
cur.push(...missing);
|
|
92
|
+
saveConfigAt(cwd, existing);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
82
95
|
console.log(success(`Refreshed primer files: ${primerSummary}.`));
|
|
83
96
|
return;
|
|
84
97
|
}
|
|
@@ -1,8 +1,66 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { Command } from 'commander';
|
|
2
3
|
import { post, get, ApiError } from '../api.js';
|
|
3
4
|
import { brand, bold, muted, warning } from '../colors.js';
|
|
4
5
|
import { run } from '../helpers/index.js';
|
|
6
|
+
import { resolveProjectContext } from '../config.js';
|
|
7
|
+
import { uploadPublicFixture, deleteFixture } from '../page-fixtures.js';
|
|
8
|
+
// Shown when an eval runs cleanly but returns nothing serializable. Turns a
|
|
9
|
+
// bare/opaque `null` into a deterministic, actionable nudge so the agent shapes
|
|
10
|
+
// a returnable value instead of guessing and retrying.
|
|
11
|
+
export const EVAL_NO_VALUE_HINT = 'The eval ran but returned no JSON-serializable value. A statement body with no `return`, an assignment, a void call, or a DOM node/function all serialize to null. ' +
|
|
12
|
+
'End the script with an expression — or an explicit `return` — that yields plain data, e.g. `return { label: input.value, count: items.length }` or `return JSON.stringify(payload)`.';
|
|
13
|
+
/** Normalize a raw eval result for display. The eval can come back as a useful
|
|
14
|
+
* serialized value, the literal `null`/`undefined`/empty string, or — when the
|
|
15
|
+
* script returns undefined — agent-browser's raw envelope leaking through
|
|
16
|
+
* (`{"success":true,"data":{"origin":…,"result":null},"error":null}`). The last
|
|
17
|
+
* two mean the same thing to the agent: no value came back. Unwrap the leaked
|
|
18
|
+
* envelope so it never reaches the agent as an opaque blob, and flag the
|
|
19
|
+
* no-value cases so the caller can attach EVAL_NO_VALUE_HINT. */
|
|
20
|
+
export function normalizeEvalResult(raw) {
|
|
21
|
+
const trimmed = (raw ?? '').trim();
|
|
22
|
+
if (trimmed === '' || trimmed === 'null' || trimmed === 'undefined') {
|
|
23
|
+
return { result: trimmed, noValue: true };
|
|
24
|
+
}
|
|
25
|
+
// A leaked agent-browser eval envelope (only emitted when the eval returns
|
|
26
|
+
// undefined): unwrap to the inner value. Strict shape match — exact key set
|
|
27
|
+
// plus a string origin — so a genuine user object never trips this.
|
|
28
|
+
if (trimmed.startsWith('{') && trimmed.includes('"result"')) {
|
|
29
|
+
try {
|
|
30
|
+
const env = JSON.parse(trimmed);
|
|
31
|
+
const isEnvelope = env && typeof env === 'object'
|
|
32
|
+
&& Object.keys(env).every((k) => k === 'success' || k === 'data' || k === 'error')
|
|
33
|
+
&& env.data && typeof env.data === 'object'
|
|
34
|
+
&& typeof env.data.origin === 'string' && 'result' in env.data;
|
|
35
|
+
if (isEnvelope) {
|
|
36
|
+
const inner = env.data.result;
|
|
37
|
+
if (inner == null)
|
|
38
|
+
return { result: 'null', noValue: true };
|
|
39
|
+
return { result: typeof inner === 'string' ? inner : JSON.stringify(inner), noValue: false };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { /* not the envelope — fall through and show the raw value */ }
|
|
43
|
+
}
|
|
44
|
+
return { result: raw, noValue: false };
|
|
45
|
+
}
|
|
5
46
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
// A single browser session is held open synchronously for the whole --wait, so
|
|
48
|
+
// the server caps it at the gateway idle timeout. Longer is impossible in one
|
|
49
|
+
// shot; watching an app past 30s means several windows, not one big wait.
|
|
50
|
+
export const MAX_WAIT_MS = 30_000;
|
|
51
|
+
/** Parse --wait (defaulting to 500ms), clamping to the per-call cap. When the
|
|
52
|
+
* caller asks for more than the cap, clamp and explain — to stderr, so --json
|
|
53
|
+
* stdout stays clean — and point at the windowed watch primitive instead of
|
|
54
|
+
* leaking the server's raw "Too big" validation error. */
|
|
55
|
+
export function capWaitMs(rawWait, url) {
|
|
56
|
+
const parsed = parseInt(rawWait, 10);
|
|
57
|
+
const wait = Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
|
|
58
|
+
if (wait <= MAX_WAIT_MS)
|
|
59
|
+
return wait;
|
|
60
|
+
console.error(warning(`--wait ${wait}ms exceeds the ${MAX_WAIT_MS}ms cap (one browser session is held open synchronously; longer trips the gateway timeout) — using ${MAX_WAIT_MS}ms. ` +
|
|
61
|
+
`To watch an app that keeps changing past 30s, cover the span with staggered windows in one command: gipity page test "${url}" --clients N --stagger S.`));
|
|
62
|
+
return MAX_WAIT_MS;
|
|
63
|
+
}
|
|
6
64
|
/** Poll the async eval job until it finishes. Eval runs server-side as a
|
|
7
65
|
* short-lived job (so a long --wait can't trip the gateway idle timeout);
|
|
8
66
|
* we submit, then poll the result out of the job store. `expectedWorkMs` is
|
|
@@ -35,41 +93,137 @@ export async function pollEvalResult(evalJobId, expectedWorkMs) {
|
|
|
35
93
|
}
|
|
36
94
|
throw new ApiError(504, 'EVAL_TIMEOUT', 'Eval did not finish in time; narrow the expression or lower --wait');
|
|
37
95
|
}
|
|
96
|
+
// The in-page execution budget for an eval body's OWN runtime (its `await`/
|
|
97
|
+
// `setTimeout` pauses), enforced by agent-browser's per-command CDP timeout
|
|
98
|
+
// (AGENT_BROWSER_DEFAULT_TIMEOUT) — distinct from --wait, which only sleeps
|
|
99
|
+
// BEFORE the eval. Used to translate the opaque timeout envelope into guidance.
|
|
100
|
+
const EVAL_EXEC_BUDGET_MS = 20_000;
|
|
101
|
+
/** When the eval body's own runtime overruns the in-page execution budget,
|
|
102
|
+
* agent-browser aborts the `Runtime.evaluate` CDP call and the failure comes
|
|
103
|
+
* back as a `{success:false, error:"CDP command timed out: Runtime.evaluate"}`
|
|
104
|
+
* envelope that the server surfaces verbatim as the eval `result` — opaque to
|
|
105
|
+
* the caller (no timeout named, no distinction from the page or --wait). Detect
|
|
106
|
+
* exactly that envelope and return an actionable message; null otherwise. */
|
|
107
|
+
export function evalExecTimeoutMessage(result) {
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(result);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (!parsed || parsed.success !== false || typeof parsed.error !== 'string')
|
|
116
|
+
return null;
|
|
117
|
+
if (!/CDP command timed out:\s*Runtime\.evaluate/i.test(parsed.error))
|
|
118
|
+
return null;
|
|
119
|
+
return (`the expression hit the ~${EVAL_EXEC_BUDGET_MS / 1000}s in-page execution budget — the eval body ` +
|
|
120
|
+
`(including its own await/setTimeout pauses) ran longer than that. This budget is the time the ` +
|
|
121
|
+
`expression itself is allowed to run; it is separate from --wait, which only sleeps BEFORE the eval ` +
|
|
122
|
+
`and cannot extend it. Split a long interactive check into several shorter 'page eval' calls (e.g. ` +
|
|
123
|
+
`one per state to verify), keeping each body's in-page waits well under ${EVAL_EXEC_BUDGET_MS / 1000}s.`);
|
|
124
|
+
}
|
|
38
125
|
// The long-tail escape hatch alongside `page inspect`'s fixed bundle: when the
|
|
39
126
|
// curated metrics don't cover what you need (computed styles, element rects,
|
|
40
127
|
// visibility, z-index stacks), eval an expression in page context and get the
|
|
41
128
|
// serialized result back. Runs in the same browser sandbox as inspect.
|
|
129
|
+
//
|
|
130
|
+
// The body runs as an async function, so it can be an inline expression OR a
|
|
131
|
+
// multi-statement script with `return`/`await`. Pass a saved script with
|
|
132
|
+
// --file to functionally exercise a page's own code paths headlessly (drive
|
|
133
|
+
// tools, undo/redo, transforms) and `return` a JSON-serializable result —
|
|
134
|
+
// no /tmp + shell command-substitution harness needed.
|
|
42
135
|
export const pageEvalCommand = new Command('eval')
|
|
43
|
-
.description('Evaluate
|
|
136
|
+
.description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script)')
|
|
44
137
|
.argument('<url>', 'URL to load')
|
|
45
|
-
.argument('
|
|
46
|
-
.option('--
|
|
138
|
+
.argument('[expr]', 'JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file.')
|
|
139
|
+
.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.')
|
|
140
|
+
.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], [])
|
|
141
|
+
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
|
|
47
142
|
.option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
|
|
48
143
|
.option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
|
|
49
144
|
.option('--json', 'Output as JSON')
|
|
50
|
-
.action((url,
|
|
51
|
-
|
|
52
|
-
|
|
145
|
+
.action((url, exprArg, opts) => run('Page eval', async () => {
|
|
146
|
+
// Arg-shape errors go through commander's error() so the enableHelpAfterError
|
|
147
|
+
// hook renders this command's help inline with the one-line error LAST
|
|
148
|
+
// (survives `| tail`), same as commander-detected errors like a missing url.
|
|
149
|
+
if (exprArg !== undefined && opts.file) {
|
|
150
|
+
pageEvalCommand.error('error: Pass either an inline <expr> arg or --file <path>, not both');
|
|
151
|
+
}
|
|
152
|
+
if (exprArg === undefined && !opts.file) {
|
|
153
|
+
pageEvalCommand.error('error: Provide an inline <expr> arg or --file <path>');
|
|
154
|
+
}
|
|
155
|
+
let expr = exprArg;
|
|
156
|
+
if (opts.file) {
|
|
157
|
+
try {
|
|
158
|
+
expr = readFileSync(opts.file, 'utf8');
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
pageEvalCommand.error(`error: Cannot read file: ${opts.file}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const waitMs = capWaitMs(opts.wait, url);
|
|
53
165
|
const parsedTimeout = parseInt(opts.waitTimeout, 10);
|
|
54
166
|
const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
167
|
+
// --fixture: host each file publicly, then splice `fixtures` / `fixtureUrl`
|
|
168
|
+
// into the eval scope so the page can fetch the bytes. The prelude makes the
|
|
169
|
+
// body a statement (const/return), so the server's expression form fails to
|
|
170
|
+
// parse and it falls back to the function-body form - which runs both inline
|
|
171
|
+
// exprs (wrapped in `return (...)`) and --file scripts. Cleanup in `finally`.
|
|
172
|
+
const fixturePaths = opts.fixture ?? [];
|
|
173
|
+
const hosted = [];
|
|
174
|
+
let projectGuid;
|
|
175
|
+
let sentExpr = expr;
|
|
176
|
+
try {
|
|
177
|
+
if (fixturePaths.length) {
|
|
178
|
+
const { config } = await resolveProjectContext({});
|
|
179
|
+
projectGuid = config.projectGuid;
|
|
180
|
+
for (const p of fixturePaths) {
|
|
181
|
+
console.log(muted(`Hosting fixture ${p}…`));
|
|
182
|
+
hosted.push(await uploadPublicFixture(projectGuid, p));
|
|
183
|
+
}
|
|
184
|
+
const map = {};
|
|
185
|
+
for (const h of hosted)
|
|
186
|
+
map[h.name] = h.url;
|
|
187
|
+
const prelude = `const fixtures=${JSON.stringify(map)};const fixtureUrl=${JSON.stringify(hosted[0].url)};`;
|
|
188
|
+
sentExpr = opts.file ? `${prelude}\n${expr}` : `${prelude}\nreturn (${expr});`;
|
|
189
|
+
}
|
|
190
|
+
const kickoff = await post('/tools/browser/eval', {
|
|
191
|
+
url, expr: sentExpr, waitMs,
|
|
192
|
+
waitForSelector: opts.waitFor || undefined,
|
|
193
|
+
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
194
|
+
});
|
|
195
|
+
const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
|
|
196
|
+
const { result, noValue } = normalizeEvalResult(d.result);
|
|
197
|
+
const execTimeout = evalExecTimeoutMessage(d.result);
|
|
198
|
+
if (execTimeout)
|
|
199
|
+
throw new Error(execTimeout);
|
|
200
|
+
if (opts.json) {
|
|
201
|
+
console.log(JSON.stringify(noValue ? { ...d, result, hint: EVAL_NO_VALUE_HINT } : { ...d, result }));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
console.log(`${brand('Eval')} ${bold(d.url || url)}`);
|
|
205
|
+
if (d.navigationIncomplete) {
|
|
206
|
+
console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
|
|
207
|
+
}
|
|
208
|
+
if (hosted.length)
|
|
209
|
+
console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
|
|
210
|
+
console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${expr}`);
|
|
211
|
+
console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
|
|
212
|
+
if (noValue)
|
|
213
|
+
console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
|
|
214
|
+
if (d.truncated)
|
|
215
|
+
console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
|
|
64
216
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
217
|
+
finally {
|
|
218
|
+
for (const h of hosted) {
|
|
219
|
+
try {
|
|
220
|
+
await deleteFixture(projectGuid, h.guid);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
console.error(warning(`⚠ Could not auto-delete fixture "${h.name}" (${h.guid}) — still hosted at ${h.url}: ${err.message}`));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
68
226
|
}
|
|
69
|
-
console.log(`${muted('Expression:')} ${expr}`);
|
|
70
|
-
console.log(`\n${d.result || muted('(empty result)')}`);
|
|
71
|
-
if (d.truncated)
|
|
72
|
-
console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
|
|
73
227
|
}));
|
|
74
228
|
// Each `page eval` call runs to completion before the next starts, so two evals
|
|
75
229
|
// fired back-to-back never coexist in time - they CANNOT test whether two live
|
|
@@ -77,6 +231,21 @@ export const pageEvalCommand = new Command('eval')
|
|
|
77
231
|
// concurrent `page test --observe` instead, which overlaps N clients and reports
|
|
78
232
|
// whether they actually ran together.
|
|
79
233
|
pageEvalCommand.addHelpText('after', `
|
|
234
|
+
Examples:
|
|
235
|
+
gipity page eval "https://dev.gipity.ai/me/app/" "document.title"
|
|
236
|
+
# Functionally test a page's own code paths: save a script that drives the UI
|
|
237
|
+
# and returns a JSON-serializable result, then run it (no /tmp + shell quoting):
|
|
238
|
+
gipity page eval "https://dev.gipity.ai/me/app/" --file ./tests/draw-flow.js --json
|
|
239
|
+
# Verify a render/parse path against a REAL file: --fixture hosts it, injects a
|
|
240
|
+
# fetch-able 'fixtureUrl', runs the eval, then deletes the hosted copy:
|
|
241
|
+
gipity page eval "https://dev.gipity.ai/me/app/" --fixture ./sample.mp3 \\
|
|
242
|
+
"(async()=>{ const b = await fetch(fixtureUrl).then(r=>r.arrayBuffer()); return window.App.parseId3(b); })()"
|
|
243
|
+
|
|
244
|
+
The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
|
|
245
|
+
pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long
|
|
246
|
+
interactive sequence, split it into several shorter evals (one per state to verify)
|
|
247
|
+
rather than one body with many long waits.
|
|
248
|
+
|
|
80
249
|
Testing realtime/shared state across clients?
|
|
81
250
|
Separate 'page eval' calls run sequentially (one finishes before the next
|
|
82
251
|
starts), so they never overlap and will each see only themselves - a false
|
|
@@ -3,6 +3,9 @@ import { post } from '../api.js';
|
|
|
3
3
|
import { formatSize } from '../utils.js';
|
|
4
4
|
import { brand, bold, error as clrError, warning, muted, info } from '../colors.js';
|
|
5
5
|
import { run } from '../helpers/index.js';
|
|
6
|
+
import { capWaitMs } from './page-eval.js';
|
|
7
|
+
/** A console line is an error-level entry (page error or console.error). */
|
|
8
|
+
const isErrorLine = (line) => /^error:/i.test(line);
|
|
6
9
|
function shortUrl(url, truncate = true, maxLen = 100) {
|
|
7
10
|
let result;
|
|
8
11
|
try {
|
|
@@ -22,7 +25,7 @@ function shortUrl(url, truncate = true, maxLen = 100) {
|
|
|
22
25
|
export const pageInspectCommand = new Command('inspect')
|
|
23
26
|
.description('Inspect a web page (console, failed resources, timing, layout overflow)')
|
|
24
27
|
.argument('<url>', 'URL to inspect')
|
|
25
|
-
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle)', '500')
|
|
28
|
+
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle; max 30000)', '500')
|
|
26
29
|
.option('--wait-for <selector>', 'Wait until this CSS selector appears before capturing (deterministic; replaces --wait)')
|
|
27
30
|
.option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
|
|
28
31
|
.option('--json', 'Output as JSON')
|
|
@@ -39,21 +42,41 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
39
42
|
process.exit(1);
|
|
40
43
|
}
|
|
41
44
|
return run('Page inspect', async () => {
|
|
42
|
-
const
|
|
43
|
-
const waitMs = Number.isFinite(parsedWait) && parsedWait >= 0 ? parsedWait : 500;
|
|
45
|
+
const waitMs = capWaitMs(opts.wait, url);
|
|
44
46
|
const parsedTimeout = parseInt(opts.waitTimeout, 10);
|
|
45
47
|
const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
|
|
46
48
|
const truncate = opts.truncate !== false;
|
|
47
49
|
const showAll = opts.all === true;
|
|
48
|
-
const
|
|
50
|
+
const inspectBody = {
|
|
49
51
|
url, waitMs,
|
|
50
52
|
waitForSelector: opts.waitFor || undefined,
|
|
51
53
|
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
52
54
|
fakeMedia: opts.fakeMedia || undefined,
|
|
53
|
-
}
|
|
55
|
+
};
|
|
56
|
+
const res = await post(`/tools/browser/inspect`, inspectBody);
|
|
54
57
|
const b = res.data;
|
|
58
|
+
// Self-verify console errors before flagging them. A freshly-deployed page's
|
|
59
|
+
// first hit can throw a one-time, non-reproducible error — typically a
|
|
60
|
+
// cross-origin "Script error." with no message/stack from a CDN asset still
|
|
61
|
+
// propagating — and reporting it as a real defect sends agents chasing a
|
|
62
|
+
// phantom. So when the first probe reports error-level console lines, re-probe
|
|
63
|
+
// once (the sticky session is now warm) and keep only the errors that recur;
|
|
64
|
+
// errors seen on a single probe are surfaced separately as transient noise.
|
|
65
|
+
let transientErrors = [];
|
|
66
|
+
if ((b.console || []).some(isErrorLine)) {
|
|
67
|
+
try {
|
|
68
|
+
const verify = await post(`/tools/browser/inspect`, inspectBody);
|
|
69
|
+
const recurring = new Set((verify.data.console || []).filter(isErrorLine));
|
|
70
|
+
transientErrors = (b.console || []).filter((l) => isErrorLine(l) && !recurring.has(l));
|
|
71
|
+
b.console = (b.console || []).filter((l) => !isErrorLine(l) || recurring.has(l));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Re-probe failed (timeout / browser error) — report the first probe's
|
|
75
|
+
// console as-is rather than hiding anything.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
55
78
|
if (opts.json) {
|
|
56
|
-
console.log(JSON.stringify(b));
|
|
79
|
+
console.log(JSON.stringify(transientErrors.length ? { ...b, transientConsole: transientErrors } : b));
|
|
57
80
|
return;
|
|
58
81
|
}
|
|
59
82
|
const timing = b.timing || { ttfb: 0, domReady: 0, load: 0 };
|
|
@@ -83,6 +106,14 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
83
106
|
else {
|
|
84
107
|
console.log(`\n${bold('Console:')} ${muted('(clean)')}`);
|
|
85
108
|
}
|
|
109
|
+
// ── Transient console errors (seen on first probe, gone on re-probe) ──
|
|
110
|
+
if (transientErrors.length > 0) {
|
|
111
|
+
console.log(`\n${bold('Transient console errors')} ${muted(`(${transientErrors.length}, not reproduced on re-probe)`)}:`);
|
|
112
|
+
for (const line of transientErrors) {
|
|
113
|
+
console.log(muted(line));
|
|
114
|
+
}
|
|
115
|
+
console.log(muted('One-time cold-load artifact (first hit of freshly-deployed assets, or a cross-origin script) — not reproducible, not in your app code. Ignore unless it recurs.'));
|
|
116
|
+
}
|
|
86
117
|
// ── Failed Resources ──
|
|
87
118
|
// Browsers auto-request /favicon.ico at the site root for every page, so a
|
|
88
119
|
// 404 there isn't a resource the page actually links — it's noise on any
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command, Option } from 'commander';
|
|
2
2
|
import { mkdirSync, writeFileSync } from 'fs';
|
|
3
|
-
import { join, resolve as resolvePath } from 'path';
|
|
3
|
+
import { dirname, join, resolve as resolvePath } from 'path';
|
|
4
4
|
import { postForTarEntries } from '../api.js';
|
|
5
5
|
import { getProjectRoot } from '../config.js';
|
|
6
6
|
import { brand, bold, muted, success } from '../colors.js';
|
|
@@ -96,7 +96,10 @@ function appendOption(value, previous = []) {
|
|
|
96
96
|
export const pageScreenshotCommand = new Command('screenshot')
|
|
97
97
|
.description('Screenshot a web page')
|
|
98
98
|
.argument('<url>', 'URL to screenshot')
|
|
99
|
-
|
|
99
|
+
// No commander default: a default here makes opts.postLoadDelay always set,
|
|
100
|
+
// so the `?? opts.wait` merge below would never see the --wait alias. Default
|
|
101
|
+
// is applied in the merge instead.
|
|
102
|
+
.option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms (default: 1000)')
|
|
100
103
|
.option('--full', 'Capture the full scrollable page (default: viewport only)')
|
|
101
104
|
.option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
|
|
102
105
|
.option('--device <names>', `Viewport preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag)`, appendOption, [])
|
|
@@ -110,7 +113,10 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
110
113
|
// rather than reject it as an unknown option and send them on a --help detour.
|
|
111
114
|
.addOption(new Option('--full-page', 'Alias for --full').hideHelp())
|
|
112
115
|
.action((url, opts) => run('Page screenshot', async () => {
|
|
113
|
-
|
|
116
|
+
// --wait is a hidden alias for --post-load-delay (agents reach for it because
|
|
117
|
+
// sibling `page inspect`/`eval` name the flag --wait). Canonical name wins if
|
|
118
|
+
// both given; fall back to the 1000ms default when neither is set.
|
|
119
|
+
const delayRaw = opts.postLoadDelay ?? opts.wait ?? '1000';
|
|
114
120
|
const postLoadDelayMs = delayRaw !== undefined ? parseInt(String(delayRaw), 10) : undefined;
|
|
115
121
|
if (postLoadDelayMs !== undefined && (!Number.isFinite(postLoadDelayMs) || postLoadDelayMs < 0)) {
|
|
116
122
|
throw new Error('--post-load-delay must be a non-negative integer (ms)');
|
|
@@ -147,8 +153,6 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
147
153
|
const slug = slugFromUrl(url);
|
|
148
154
|
const ts = timestampSlug();
|
|
149
155
|
const dir = defaultScreenshotDir();
|
|
150
|
-
if (!opts.output)
|
|
151
|
-
mkdirSync(dir, { recursive: true });
|
|
152
156
|
const savedFiles = [];
|
|
153
157
|
for (let i = 0; i < pngs.length; i++) {
|
|
154
158
|
const shot = meta.screenshots[i];
|
|
@@ -156,6 +160,10 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
156
160
|
const target = opts.output
|
|
157
161
|
? opts.output
|
|
158
162
|
: join(dir, defaultFilename(slug, ts, suffix));
|
|
163
|
+
// Create the target's parent dir so a `-o` path under a not-yet-existing
|
|
164
|
+
// directory (e.g. .gipity/screenshots/home.png) writes cleanly instead of
|
|
165
|
+
// failing with a raw ENOENT and forcing a manual `mkdir -p`.
|
|
166
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
159
167
|
writeFileSync(target, pngs[i].buffer);
|
|
160
168
|
// Absolute path so the agent knows exactly where the file landed.
|
|
161
169
|
savedFiles.push(resolvePath(target));
|