gipity 1.0.384 → 1.0.386
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/auth.js +10 -9
- package/dist/commands/db.js +4 -1
- package/dist/commands/deploy.js +14 -2
- package/dist/commands/init.js +21 -8
- package/dist/commands/page-eval.js +135 -9
- 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/sandbox.js +67 -14
- package/dist/commands/status.js +8 -4
- package/dist/commands/test.js +12 -3
- package/dist/commands/text.js +1 -1
- package/dist/commands/workflow.js +78 -19
- package/dist/helpers/text-analysis.js +9 -5
- package/dist/index.js +25 -13
- package/dist/knowledge.js +8 -1
- package/dist/project-setup.js +2 -2
- package/dist/setup.js +71 -3
- package/dist/sync.js +43 -5
- package/package.json +2 -2
package/dist/auth.js
CHANGED
|
@@ -52,17 +52,18 @@ export function isExpired() {
|
|
|
52
52
|
const buffer = 5 * 60 * 1000; // 5 minute buffer
|
|
53
53
|
return Date.now() > expiresAt - buffer;
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
/** True only when re-login is genuinely required: the refresh token itself
|
|
56
|
+
* has expired. Access-token expiry (`expiresAt` / isExpired) is invisible
|
|
57
|
+
* to users — every API call renews it via refreshTokenIfNeeded() — so it
|
|
58
|
+
* must never be surfaced as a session warning. */
|
|
59
|
+
export function sessionExpired() {
|
|
56
60
|
const auth = getAuth();
|
|
57
61
|
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`;
|
|
62
|
+
return true;
|
|
63
|
+
const exp = decodeJwtExp(auth.refreshToken);
|
|
64
|
+
if (!exp)
|
|
65
|
+
return false; // undecodable - let the refresh path decide
|
|
66
|
+
return Date.now() > exp * 1000;
|
|
66
67
|
}
|
|
67
68
|
export async function refreshTokenIfNeeded() {
|
|
68
69
|
if (!isExpired())
|
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
|
@@ -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,64 @@
|
|
|
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
|
+
// Shown when an eval runs cleanly but returns nothing serializable. Turns a
|
|
7
|
+
// bare/opaque `null` into a deterministic, actionable nudge so the agent shapes
|
|
8
|
+
// a returnable value instead of guessing and retrying.
|
|
9
|
+
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. ' +
|
|
10
|
+
'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)`.';
|
|
11
|
+
/** Normalize a raw eval result for display. The eval can come back as a useful
|
|
12
|
+
* serialized value, the literal `null`/`undefined`/empty string, or — when the
|
|
13
|
+
* script returns undefined — agent-browser's raw envelope leaking through
|
|
14
|
+
* (`{"success":true,"data":{"origin":…,"result":null},"error":null}`). The last
|
|
15
|
+
* two mean the same thing to the agent: no value came back. Unwrap the leaked
|
|
16
|
+
* envelope so it never reaches the agent as an opaque blob, and flag the
|
|
17
|
+
* no-value cases so the caller can attach EVAL_NO_VALUE_HINT. */
|
|
18
|
+
export function normalizeEvalResult(raw) {
|
|
19
|
+
const trimmed = (raw ?? '').trim();
|
|
20
|
+
if (trimmed === '' || trimmed === 'null' || trimmed === 'undefined') {
|
|
21
|
+
return { result: trimmed, noValue: true };
|
|
22
|
+
}
|
|
23
|
+
// A leaked agent-browser eval envelope (only emitted when the eval returns
|
|
24
|
+
// undefined): unwrap to the inner value. Strict shape match — exact key set
|
|
25
|
+
// plus a string origin — so a genuine user object never trips this.
|
|
26
|
+
if (trimmed.startsWith('{') && trimmed.includes('"result"')) {
|
|
27
|
+
try {
|
|
28
|
+
const env = JSON.parse(trimmed);
|
|
29
|
+
const isEnvelope = env && typeof env === 'object'
|
|
30
|
+
&& Object.keys(env).every((k) => k === 'success' || k === 'data' || k === 'error')
|
|
31
|
+
&& env.data && typeof env.data === 'object'
|
|
32
|
+
&& typeof env.data.origin === 'string' && 'result' in env.data;
|
|
33
|
+
if (isEnvelope) {
|
|
34
|
+
const inner = env.data.result;
|
|
35
|
+
if (inner == null)
|
|
36
|
+
return { result: 'null', noValue: true };
|
|
37
|
+
return { result: typeof inner === 'string' ? inner : JSON.stringify(inner), noValue: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { /* not the envelope — fall through and show the raw value */ }
|
|
41
|
+
}
|
|
42
|
+
return { result: raw, noValue: false };
|
|
43
|
+
}
|
|
5
44
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
45
|
+
// A single browser session is held open synchronously for the whole --wait, so
|
|
46
|
+
// the server caps it at the gateway idle timeout. Longer is impossible in one
|
|
47
|
+
// shot; watching an app past 30s means several windows, not one big wait.
|
|
48
|
+
export const MAX_WAIT_MS = 30_000;
|
|
49
|
+
/** Parse --wait (defaulting to 500ms), clamping to the per-call cap. When the
|
|
50
|
+
* caller asks for more than the cap, clamp and explain — to stderr, so --json
|
|
51
|
+
* stdout stays clean — and point at the windowed watch primitive instead of
|
|
52
|
+
* leaking the server's raw "Too big" validation error. */
|
|
53
|
+
export function capWaitMs(rawWait, url) {
|
|
54
|
+
const parsed = parseInt(rawWait, 10);
|
|
55
|
+
const wait = Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
|
|
56
|
+
if (wait <= MAX_WAIT_MS)
|
|
57
|
+
return wait;
|
|
58
|
+
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. ` +
|
|
59
|
+
`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.`));
|
|
60
|
+
return MAX_WAIT_MS;
|
|
61
|
+
}
|
|
6
62
|
/** Poll the async eval job until it finishes. Eval runs server-side as a
|
|
7
63
|
* short-lived job (so a long --wait can't trip the gateway idle timeout);
|
|
8
64
|
* we submit, then poll the result out of the job store. `expectedWorkMs` is
|
|
@@ -35,21 +91,74 @@ export async function pollEvalResult(evalJobId, expectedWorkMs) {
|
|
|
35
91
|
}
|
|
36
92
|
throw new ApiError(504, 'EVAL_TIMEOUT', 'Eval did not finish in time; narrow the expression or lower --wait');
|
|
37
93
|
}
|
|
94
|
+
// The in-page execution budget for an eval body's OWN runtime (its `await`/
|
|
95
|
+
// `setTimeout` pauses), enforced by agent-browser's per-command CDP timeout
|
|
96
|
+
// (AGENT_BROWSER_DEFAULT_TIMEOUT) — distinct from --wait, which only sleeps
|
|
97
|
+
// BEFORE the eval. Used to translate the opaque timeout envelope into guidance.
|
|
98
|
+
const EVAL_EXEC_BUDGET_MS = 20_000;
|
|
99
|
+
/** When the eval body's own runtime overruns the in-page execution budget,
|
|
100
|
+
* agent-browser aborts the `Runtime.evaluate` CDP call and the failure comes
|
|
101
|
+
* back as a `{success:false, error:"CDP command timed out: Runtime.evaluate"}`
|
|
102
|
+
* envelope that the server surfaces verbatim as the eval `result` — opaque to
|
|
103
|
+
* the caller (no timeout named, no distinction from the page or --wait). Detect
|
|
104
|
+
* exactly that envelope and return an actionable message; null otherwise. */
|
|
105
|
+
export function evalExecTimeoutMessage(result) {
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(result);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (!parsed || parsed.success !== false || typeof parsed.error !== 'string')
|
|
114
|
+
return null;
|
|
115
|
+
if (!/CDP command timed out:\s*Runtime\.evaluate/i.test(parsed.error))
|
|
116
|
+
return null;
|
|
117
|
+
return (`the expression hit the ~${EVAL_EXEC_BUDGET_MS / 1000}s in-page execution budget — the eval body ` +
|
|
118
|
+
`(including its own await/setTimeout pauses) ran longer than that. This budget is the time the ` +
|
|
119
|
+
`expression itself is allowed to run; it is separate from --wait, which only sleeps BEFORE the eval ` +
|
|
120
|
+
`and cannot extend it. Split a long interactive check into several shorter 'page eval' calls (e.g. ` +
|
|
121
|
+
`one per state to verify), keeping each body's in-page waits well under ${EVAL_EXEC_BUDGET_MS / 1000}s.`);
|
|
122
|
+
}
|
|
38
123
|
// The long-tail escape hatch alongside `page inspect`'s fixed bundle: when the
|
|
39
124
|
// curated metrics don't cover what you need (computed styles, element rects,
|
|
40
125
|
// visibility, z-index stacks), eval an expression in page context and get the
|
|
41
126
|
// serialized result back. Runs in the same browser sandbox as inspect.
|
|
127
|
+
//
|
|
128
|
+
// The body runs as an async function, so it can be an inline expression OR a
|
|
129
|
+
// multi-statement script with `return`/`await`. Pass a saved script with
|
|
130
|
+
// --file to functionally exercise a page's own code paths headlessly (drive
|
|
131
|
+
// tools, undo/redo, transforms) and `return` a JSON-serializable result —
|
|
132
|
+
// no /tmp + shell command-substitution harness needed.
|
|
42
133
|
export const pageEvalCommand = new Command('eval')
|
|
43
|
-
.description('Evaluate
|
|
134
|
+
.description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script)')
|
|
44
135
|
.argument('<url>', 'URL to load')
|
|
45
|
-
.argument('
|
|
46
|
-
.option('--
|
|
136
|
+
.argument('[expr]', 'JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file.')
|
|
137
|
+
.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.')
|
|
138
|
+
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
|
|
47
139
|
.option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
|
|
48
140
|
.option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
|
|
49
141
|
.option('--json', 'Output as JSON')
|
|
50
|
-
.action((url,
|
|
51
|
-
|
|
52
|
-
|
|
142
|
+
.action((url, exprArg, opts) => run('Page eval', async () => {
|
|
143
|
+
// Arg-shape errors go through commander's error() so the enableHelpAfterError
|
|
144
|
+
// hook renders this command's help inline with the one-line error LAST
|
|
145
|
+
// (survives `| tail`), same as commander-detected errors like a missing url.
|
|
146
|
+
if (exprArg !== undefined && opts.file) {
|
|
147
|
+
pageEvalCommand.error('error: Pass either an inline <expr> arg or --file <path>, not both');
|
|
148
|
+
}
|
|
149
|
+
if (exprArg === undefined && !opts.file) {
|
|
150
|
+
pageEvalCommand.error('error: Provide an inline <expr> arg or --file <path>');
|
|
151
|
+
}
|
|
152
|
+
let expr = exprArg;
|
|
153
|
+
if (opts.file) {
|
|
154
|
+
try {
|
|
155
|
+
expr = readFileSync(opts.file, 'utf8');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
pageEvalCommand.error(`error: Cannot read file: ${opts.file}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const waitMs = capWaitMs(opts.wait, url);
|
|
53
162
|
const parsedTimeout = parseInt(opts.waitTimeout, 10);
|
|
54
163
|
const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
|
|
55
164
|
const kickoff = await post('/tools/browser/eval', {
|
|
@@ -58,16 +167,22 @@ export const pageEvalCommand = new Command('eval')
|
|
|
58
167
|
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
59
168
|
});
|
|
60
169
|
const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
|
|
170
|
+
const { result, noValue } = normalizeEvalResult(d.result);
|
|
171
|
+
const execTimeout = evalExecTimeoutMessage(d.result);
|
|
172
|
+
if (execTimeout)
|
|
173
|
+
throw new Error(execTimeout);
|
|
61
174
|
if (opts.json) {
|
|
62
|
-
console.log(JSON.stringify(d));
|
|
175
|
+
console.log(JSON.stringify(noValue ? { ...d, result, hint: EVAL_NO_VALUE_HINT } : { ...d, result }));
|
|
63
176
|
return;
|
|
64
177
|
}
|
|
65
178
|
console.log(`${brand('Eval')} ${bold(d.url || url)}`);
|
|
66
179
|
if (d.navigationIncomplete) {
|
|
67
180
|
console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
|
|
68
181
|
}
|
|
69
|
-
console.log(`${muted('Expression:')} ${expr}`);
|
|
70
|
-
console.log(`\n${
|
|
182
|
+
console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${expr}`);
|
|
183
|
+
console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
|
|
184
|
+
if (noValue)
|
|
185
|
+
console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
|
|
71
186
|
if (d.truncated)
|
|
72
187
|
console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
|
|
73
188
|
}));
|
|
@@ -77,6 +192,17 @@ export const pageEvalCommand = new Command('eval')
|
|
|
77
192
|
// concurrent `page test --observe` instead, which overlaps N clients and reports
|
|
78
193
|
// whether they actually ran together.
|
|
79
194
|
pageEvalCommand.addHelpText('after', `
|
|
195
|
+
Examples:
|
|
196
|
+
gipity page eval "https://dev.gipity.ai/me/app/" "document.title"
|
|
197
|
+
# Functionally test a page's own code paths: save a script that drives the UI
|
|
198
|
+
# and returns a JSON-serializable result, then run it (no /tmp + shell quoting):
|
|
199
|
+
gipity page eval "https://dev.gipity.ai/me/app/" --file ./tests/draw-flow.js --json
|
|
200
|
+
|
|
201
|
+
The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
|
|
202
|
+
pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long
|
|
203
|
+
interactive sequence, split it into several shorter evals (one per state to verify)
|
|
204
|
+
rather than one body with many long waits.
|
|
205
|
+
|
|
80
206
|
Testing realtime/shared state across clients?
|
|
81
207
|
Separate 'page eval' calls run sequentially (one finishes before the next
|
|
82
208
|
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));
|
|
@@ -18,12 +18,33 @@ async function inspectClient(url, waitMs, i) {
|
|
|
18
18
|
}
|
|
19
19
|
const MAX_HOLD_MS = 15_000; // keep each in-page await under the ~20s browser action timeout
|
|
20
20
|
const MIN_HOLD_MS = 1_000;
|
|
21
|
-
/** Splice per-client values into a user
|
|
22
|
-
* label, `{{i}}` → its 0-based index. Plain string
|
|
23
|
-
*
|
|
21
|
+
/** Splice per-client values into a user string (URL, --action, or --observe).
|
|
22
|
+
* `{{label}}` → the client's label, `{{i}}` → its 0-based index. Plain string
|
|
23
|
+
* replace (no regex) so the string's own characters are never treated as
|
|
24
|
+
* patterns. */
|
|
24
25
|
function subst(expr, label, i) {
|
|
25
26
|
return expr.split('{{label}}').join(label).split('{{i}}').join(String(i));
|
|
26
27
|
}
|
|
28
|
+
/** Collect any `{{...}}` placeholders the runner does NOT recognize, so an
|
|
29
|
+
* invented token (e.g. `{{name}}`) is flagged instead of passing through
|
|
30
|
+
* verbatim into every client's URL/expression. */
|
|
31
|
+
function unknownTokens(...strings) {
|
|
32
|
+
const out = new Set();
|
|
33
|
+
for (const s of strings) {
|
|
34
|
+
for (const m of (s ?? '').match(/\{\{[^}]*\}\}/g) ?? []) {
|
|
35
|
+
if (m !== '{{i}}' && m !== '{{label}}')
|
|
36
|
+
out.add(m);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return [...out];
|
|
40
|
+
}
|
|
41
|
+
/** One-time warning (to stderr, so --json stdout stays clean) for
|
|
42
|
+
* unrecognized placeholders left as-is. */
|
|
43
|
+
function warnUnknownTokens(unknown) {
|
|
44
|
+
if (unknown.length === 0)
|
|
45
|
+
return;
|
|
46
|
+
console.error(warning(`⚠ Unrecognized placeholder ${unknown.join(', ')} left as-is — only {{i}} (0-based client index) and {{label}} are substituted per client. Set per-client values with --labels and reference them as {{label}}.`));
|
|
47
|
+
}
|
|
27
48
|
/** Build the statement-body script one client runs: do the one-time action,
|
|
28
49
|
* then sample `observe` `samples` times across `holdMs`, stamping in-page
|
|
29
50
|
* start/end so the caller can confirm the clients overlapped. */
|
|
@@ -98,11 +119,22 @@ function fmtSamples(samples) {
|
|
|
98
119
|
async function runInteractive(url, observe, opts) {
|
|
99
120
|
const clients = Math.max(1, parseInt(opts.clients, 10) || 2);
|
|
100
121
|
const stagger = opts.stagger != null ? Math.max(0, parseInt(opts.stagger, 10) || 0) : 0;
|
|
101
|
-
const
|
|
122
|
+
const rawHold = parseInt(opts.hold, 10) || 8000;
|
|
123
|
+
const hold = Math.min(MAX_HOLD_MS, Math.max(MIN_HOLD_MS, rawHold));
|
|
124
|
+
if (rawHold > MAX_HOLD_MS) {
|
|
125
|
+
// Surface the clamp (to stderr, so --json stdout stays clean) instead of
|
|
126
|
+
// leaving the agent to infer it from the printed "hold Nms" line.
|
|
127
|
+
console.error(warning(`--hold ${rawHold}ms exceeds the ${MAX_HOLD_MS}ms per-client cap (each client samples inside one browser eval, bounded by the server's eval budget) — using ${MAX_HOLD_MS}ms. ` +
|
|
128
|
+
`Co-launch every role in this one command (put {{label}}/{{i}} in the URL) so all clients overlap for the whole window; a separately-started background client overlaps only the sliver of its window that lines up.`));
|
|
129
|
+
}
|
|
102
130
|
const samples = Math.min(30, Math.max(2, parseInt(opts.samples, 10) || 6));
|
|
103
131
|
const settle = opts.waitFor ? 200 : 1000;
|
|
104
132
|
const labels = (opts.labels ? String(opts.labels).split(',').map((s) => s.trim()) : []).filter(Boolean);
|
|
105
133
|
const labelFor = (i) => labels[i] ?? `client-${i}`;
|
|
134
|
+
// Only {{label}} and {{i}} are substituted. Warn once on any other {{token}}
|
|
135
|
+
// (a natural guess like {{name}} or {{index}}) so it isn't sent literally to
|
|
136
|
+
// every client — the silent wrong-behavior trap of identical clients.
|
|
137
|
+
warnUnknownTokens(unknownTokens(url, opts.action, observe));
|
|
106
138
|
if (!opts.json) {
|
|
107
139
|
console.log(`${brand('Page test')} ${muted('(interactive)')} ${bold(url)}`);
|
|
108
140
|
console.log(muted(`${clients} client(s), stagger ${stagger}s, hold ${hold}ms, ${samples} samples each`));
|
|
@@ -113,8 +145,12 @@ async function runInteractive(url, observe, opts) {
|
|
|
113
145
|
await sleep(i * stagger * 1000);
|
|
114
146
|
if (!opts.json)
|
|
115
147
|
console.log(muted(`client ${i} (${labelFor(i)}) joining`));
|
|
148
|
+
// {{label}}/{{i}} substitute into the URL too, so one invocation can launch
|
|
149
|
+
// asymmetric roles concurrently (e.g. ?role={{label}} with --labels host,join)
|
|
150
|
+
// and the overlap check still confirms they coexisted.
|
|
151
|
+
const clientUrl = subst(url, labelFor(i), i);
|
|
116
152
|
const expr = buildHarness(opts.action ? subst(opts.action, labelFor(i), i) : undefined, subst(observe, labelFor(i), i), labelFor(i), hold, samples);
|
|
117
|
-
return observeClient(
|
|
153
|
+
return observeClient(clientUrl, expr, i, labelFor(i), settle, hold, opts.waitFor);
|
|
118
154
|
})());
|
|
119
155
|
}
|
|
120
156
|
const results = (await Promise.all(runs)).sort((a, b) => a.i - b.i);
|
|
@@ -159,6 +195,9 @@ async function runPassive(url, opts) {
|
|
|
159
195
|
const clients = Math.max(1, parseInt(opts.clients, 10) || 2);
|
|
160
196
|
const stagger = opts.stagger != null ? Math.max(0, parseInt(opts.stagger, 10) || 0) : 12;
|
|
161
197
|
const wait = Math.min(30000, Math.max(2000, parseInt(opts.wait, 10) || 24000));
|
|
198
|
+
const labels = (opts.labels ? String(opts.labels).split(',').map((s) => s.trim()) : []).filter(Boolean);
|
|
199
|
+
const labelFor = (i) => labels[i] ?? `client-${i}`;
|
|
200
|
+
warnUnknownTokens(unknownTokens(url));
|
|
162
201
|
if (!opts.json) {
|
|
163
202
|
console.log(`${brand('Page test')} ${bold(url)}`);
|
|
164
203
|
console.log(`${muted(`${clients} client(s), stagger ${stagger}s, ${wait}ms open each`)}`);
|
|
@@ -169,7 +208,7 @@ async function runPassive(url, opts) {
|
|
|
169
208
|
await sleep(i * stagger * 1000);
|
|
170
209
|
if (!opts.json)
|
|
171
210
|
console.log(`${muted(`client ${i}${i === 0 ? ' (first)' : ''} starting`)}`);
|
|
172
|
-
return inspectClient(url, wait, i);
|
|
211
|
+
return inspectClient(subst(url, labelFor(i), i), wait, i);
|
|
173
212
|
})());
|
|
174
213
|
}
|
|
175
214
|
const results = (await Promise.all(runs)).sort((a, b) => a.i - b.i);
|
|
@@ -222,14 +261,14 @@ async function runPassive(url, opts) {
|
|
|
222
261
|
// just because the clients never actually ran together.
|
|
223
262
|
export const pageTestCommand = new Command('test')
|
|
224
263
|
.description('Multi-client realtime check: load a URL in N concurrent headless clients; flag console errors, or drive an action and observe shared state (--observe)')
|
|
225
|
-
.argument('<url>', 'Deployed URL to load in every client')
|
|
264
|
+
.argument('<url>', 'Deployed URL to load in every client. {{label}}/{{i}} substitute per client in both modes (e.g. ?name=Bot{{i}}, or ?role={{label}} with --labels host,join), so one invocation can give each client a distinct role.')
|
|
226
265
|
.option('--clients <n>', 'Number of headless clients to launch', '2')
|
|
227
266
|
.option('--stagger <s>', 'Seconds between client starts (passive default 12; interactive default 0)')
|
|
228
267
|
.option('--wait <ms>', 'Passive mode: ms each client stays open after load (max 30000)', '24000')
|
|
229
268
|
// Interactive mode (--observe drives it):
|
|
230
269
|
.option('--observe <expr>', 'Interactive: JS expression sampled in each client to read shared state (e.g. presence count). Switches on interactive mode.')
|
|
231
270
|
.option('--action <expr>', 'Interactive: one-time JS run in each client before observing (e.g. fill a name + submit). {{label}}/{{i}} are substituted per client.')
|
|
232
|
-
.option('--labels <csv>', '
|
|
271
|
+
.option('--labels <csv>', 'Per-client labels substituted for {{label}} in the URL/--action/--observe (default client-0, client-1, …)')
|
|
233
272
|
.option('--hold <ms>', `Interactive: total observe window per client (${MIN_HOLD_MS}-${MAX_HOLD_MS}ms)`, '8000')
|
|
234
273
|
.option('--samples <k>', 'Interactive: number of observations across the hold window (2-30)', '6')
|
|
235
274
|
.option('--wait-for <selector>', 'Interactive: wait for this CSS selector before running --action (deterministic readiness gate)')
|
|
@@ -239,12 +278,22 @@ Examples:
|
|
|
239
278
|
# Passive: load in 3 staggered clients, flag console errors
|
|
240
279
|
gipity page test "https://dev.gipity.ai/me/app/" --clients 3 --stagger 8
|
|
241
280
|
|
|
281
|
+
# Per-client URL params: each client joins under a distinct name (Bot0, Bot1, …)
|
|
282
|
+
gipity page test "https://dev.gipity.ai/me/app/?name=Bot{{i}}" --clients 2
|
|
283
|
+
|
|
242
284
|
# Interactive: two concurrent clients each join with a name, then watch the
|
|
243
285
|
# live presence count. The command confirms the clients actually overlapped.
|
|
244
286
|
gipity page test "https://dev.gipity.ai/me/app/" --clients 2 \\
|
|
245
287
|
--action "document.querySelector('#name').value='{{label}}'; document.querySelector('form').requestSubmit();" \\
|
|
246
288
|
--observe "document.querySelectorAll('.present').length" \\
|
|
247
|
-
--labels Alice,Bob
|
|
289
|
+
--labels Alice,Bob
|
|
290
|
+
|
|
291
|
+
# Asymmetric roles in ONE invocation: {{label}} in the URL routes client 0 to
|
|
292
|
+
# host and client 1 to join. They overlap in time (verified), so the joiner
|
|
293
|
+
# observes the live state the host is driving — no background-process dance.
|
|
294
|
+
gipity page test "https://dev.gipity.ai/me/app/?test-action={{label}}" --clients 2 \\
|
|
295
|
+
--labels host,join \\
|
|
296
|
+
--observe "document.querySelector('[data-screen]')?.dataset.screen"`)
|
|
248
297
|
.action((url, opts) => run('Page test', async () => {
|
|
249
298
|
if (opts.observe) {
|
|
250
299
|
await runInteractive(url, opts.observe, opts);
|