gipity 1.0.422 → 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 +14 -3
- package/dist/commands/bug.js +21 -1
- package/dist/commands/claude.js +20 -2
- package/dist/commands/deploy.js +29 -1
- package/dist/commands/fn.js +21 -2
- package/dist/commands/page-eval.js +87 -7
- package/dist/commands/page-inspect.js +201 -186
- package/dist/commands/page-screenshot.js +27 -2
- package/dist/commands/page.js +1 -1
- package/dist/commands/push.js +12 -7
- package/dist/commands/sandbox.js +76 -14
- package/dist/commands/test.js +52 -3
- package/dist/commands/workflow.js +24 -16
- package/dist/helpers/sync.js +8 -1
- package/dist/index.js +20126 -386
- package/dist/knowledge.js +7 -5
- package/dist/sync.js +46 -3
- package/dist/trace.js +69 -0
- package/dist/updater/check.js +159 -84
- package/dist/updater/shim.js +203 -70
- package/package.json +5 -3
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Command, Option } from 'commander';
|
|
2
2
|
import { post } from '../api.js';
|
|
3
3
|
import { formatSize } from '../utils.js';
|
|
4
|
-
import { brand, bold, error as clrError, warning, muted, info } from '../colors.js';
|
|
4
|
+
import { brand, bold, error as clrError, warning, muted, info, success } from '../colors.js';
|
|
5
5
|
import { run } from '../helpers/index.js';
|
|
6
|
+
import { getAuth } from '../auth.js';
|
|
6
7
|
import { capWaitMs } from './page-eval.js';
|
|
7
8
|
import { TOUCH_DEVICES as INSPECT_DEVICES, resolveTouchDevice } from './page-screenshot.js';
|
|
8
9
|
/** A console line is an error-level entry (page error or console.error). */
|
|
@@ -31,12 +32,13 @@ function shortUrl(url, truncate = true, maxLen = 100) {
|
|
|
31
32
|
return result.slice(0, headLen) + '…' + result.slice(-tailLen);
|
|
32
33
|
}
|
|
33
34
|
export const pageInspectCommand = new Command('inspect')
|
|
34
|
-
.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`')
|
|
35
36
|
.argument('<url>', 'URL to inspect')
|
|
36
37
|
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle; max 30000)', '500')
|
|
37
38
|
.option('--wait-for <selector>', 'Wait until this CSS selector appears before capturing (deterministic; replaces --wait)')
|
|
38
39
|
.option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
|
|
39
40
|
.option('--json', 'Output as JSON')
|
|
41
|
+
.option('--no-reprobe', 'Skip the automatic second probe that filters one-time transient console errors - roughly halves inspect time on a page with any console error, at the cost of possibly reporting a cold-load transient as real')
|
|
40
42
|
.option('--no-truncate', 'Show full URLs instead of truncating long ones with middle-ellipsis')
|
|
41
43
|
.option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
|
|
42
44
|
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)')
|
|
@@ -51,206 +53,219 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
51
53
|
console.error(` gipity page screenshot ${url}${typeof opts.screenshot === 'string' ? ` -o ${opts.screenshot}` : ''}`);
|
|
52
54
|
process.exit(1);
|
|
53
55
|
}
|
|
54
|
-
return run('Page inspect',
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (resourceEchoesToDrop > 0) {
|
|
97
|
-
const isHttpStatusResourceError = (l) => /^error:\s*Failed to load resource: the server responded with a status of \d{3}/i.test(l);
|
|
98
|
-
b.console = (b.console || []).filter((l) => {
|
|
99
|
-
if (resourceEchoesToDrop > 0 && isHttpStatusResourceError(l)) {
|
|
100
|
-
resourceEchoesToDrop--;
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
return true;
|
|
104
|
-
});
|
|
56
|
+
return run('Page inspect', () => inspectPage(url, opts));
|
|
57
|
+
});
|
|
58
|
+
/** Run the full inspect pipeline (probe, transient-error re-probe, noise
|
|
59
|
+
* filtering) against a URL and print the report. Shared by `page inspect`
|
|
60
|
+
* and `deploy --inspect`, so a build agent can deploy + verify in ONE
|
|
61
|
+
* command instead of two round trips through the model. */
|
|
62
|
+
export async function inspectPage(url, opts = {}) {
|
|
63
|
+
const waitMs = capWaitMs(opts.wait ?? '500', url);
|
|
64
|
+
const parsedTimeout = parseInt(opts.waitTimeout ?? '5000', 10);
|
|
65
|
+
const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
|
|
66
|
+
const truncate = opts.truncate !== false;
|
|
67
|
+
const showAll = opts.all === true;
|
|
68
|
+
const inspectBody = {
|
|
69
|
+
url, waitMs,
|
|
70
|
+
waitForSelector: opts.waitFor || undefined,
|
|
71
|
+
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
72
|
+
fakeMedia: opts.fakeMedia || undefined,
|
|
73
|
+
device: opts.device ? resolveTouchDevice(opts.device) : undefined,
|
|
74
|
+
auth: opts.auth || undefined,
|
|
75
|
+
};
|
|
76
|
+
const res = await post(`/tools/browser/inspect`, inspectBody);
|
|
77
|
+
const b = res.data;
|
|
78
|
+
// ── Move resource-load failures out of the console, where they're noise ──
|
|
79
|
+
// A sub-resource that returns an HTTP 4xx/5xx is reported twice: once in
|
|
80
|
+
// `failedResources` (CDP network layer — carries the full URL, method, and
|
|
81
|
+
// status) and once as a generic, URL-less console echo ("Failed to load
|
|
82
|
+
// resource: the server responded with a status of 404 ()"). The echo names
|
|
83
|
+
// nothing, so it can't be triaged on its own and only duplicates — worse —
|
|
84
|
+
// what `failedResources` already says with a URL. Drop one echo per failed
|
|
85
|
+
// resource we actually have, so every attributed failure (a real app 404 AND
|
|
86
|
+
// the platform's own injected-SDK telemetry-beacon 404, which 404s on
|
|
87
|
+
// essentially every deployed page) is surfaced exactly once, under Failed
|
|
88
|
+
// resources, with its URL — never as a bare, unattributable console line.
|
|
89
|
+
// Count BEFORE stripping the platform log endpoints below, so their echoes go
|
|
90
|
+
// too. Any SURPLUS echo (a failure the network drain missed, leaving
|
|
91
|
+
// failedResources short) is KEPT — a real problem is never hidden just because
|
|
92
|
+
// we couldn't name it.
|
|
93
|
+
const isPlatformLog = (entry) => {
|
|
94
|
+
const urlPart = entry.replace(/\s*\([^)]*\)\s*$/, '');
|
|
95
|
+
try {
|
|
96
|
+
const u = new URL(urlPart);
|
|
97
|
+
return /(^|\.)gipity\.ai$/.test(u.hostname) && /\/log\/(traffic|error)$/.test(u.pathname);
|
|
105
98
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// from the failed-resource list entirely (its console echo is already gone,
|
|
109
|
-
// dropped above with every other attributed failure).
|
|
110
|
-
b.failedResources = (b.failedResources || []).filter((r) => !isPlatformLog(r));
|
|
111
|
-
// Pull message-less cross-origin "Script error." lines out first. They carry
|
|
112
|
-
// no source/stack, so they're never actionable as app-code defects, and on a
|
|
113
|
-
// Gipity-deployed page the platform's own injected SDK is itself a
|
|
114
|
-
// cross-origin script — so these are reported separately (not as app console
|
|
115
|
-
// errors, and not folded into the re-probe count) instead of misleading the
|
|
116
|
-
// agent into chasing its own code.
|
|
117
|
-
const crossOriginErrors = (b.console || []).filter(isMessagelessCrossOrigin);
|
|
118
|
-
b.console = (b.console || []).filter((l) => !isMessagelessCrossOrigin(l));
|
|
119
|
-
// Self-verify the remaining console errors before flagging them. A
|
|
120
|
-
// freshly-deployed page's first hit can throw a one-time, non-reproducible
|
|
121
|
-
// error from an asset still propagating — and reporting it as a real defect
|
|
122
|
-
// sends agents chasing a phantom. So when the first probe reports error-level
|
|
123
|
-
// console lines, re-probe once (the sticky session is now warm) and keep only
|
|
124
|
-
// the errors that recur; errors seen on a single probe are surfaced
|
|
125
|
-
// separately as transient noise.
|
|
126
|
-
let transientErrors = [];
|
|
127
|
-
if ((b.console || []).some(isErrorLine)) {
|
|
128
|
-
try {
|
|
129
|
-
const verify = await post(`/tools/browser/inspect`, inspectBody);
|
|
130
|
-
const recurring = new Set((verify.data.console || []).filter(isErrorLine));
|
|
131
|
-
transientErrors = (b.console || []).filter((l) => isErrorLine(l) && !recurring.has(l));
|
|
132
|
-
b.console = (b.console || []).filter((l) => !isErrorLine(l) || recurring.has(l));
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
// Re-probe failed (timeout / browser error) — report the first probe's
|
|
136
|
-
// console as-is rather than hiding anything.
|
|
137
|
-
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
138
101
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
102
|
+
};
|
|
103
|
+
let resourceEchoesToDrop = (b.failedResources || []).length;
|
|
104
|
+
if (resourceEchoesToDrop > 0) {
|
|
105
|
+
const isHttpStatusResourceError = (l) => /^error:\s*Failed to load resource: the server responded with a status of \d{3}/i.test(l);
|
|
106
|
+
b.console = (b.console || []).filter((l) => {
|
|
107
|
+
if (resourceEchoesToDrop > 0 && isHttpStatusResourceError(l)) {
|
|
108
|
+
resourceEchoesToDrop--;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// The injected analytics SDK POSTs to `/log/{traffic,error}` on the Gipity
|
|
115
|
+
// host; a failure there is platform infrastructure, not the app's, so strip it
|
|
116
|
+
// from the failed-resource list entirely (its console echo is already gone,
|
|
117
|
+
// dropped above with every other attributed failure).
|
|
118
|
+
b.failedResources = (b.failedResources || []).filter((r) => !isPlatformLog(r));
|
|
119
|
+
// Pull message-less cross-origin "Script error." lines out first. They carry
|
|
120
|
+
// no source/stack, so they're never actionable as app-code defects, and on a
|
|
121
|
+
// Gipity-deployed page the platform's own injected SDK is itself a
|
|
122
|
+
// cross-origin script — so these are reported separately (not as app console
|
|
123
|
+
// errors, and not folded into the re-probe count) instead of misleading the
|
|
124
|
+
// agent into chasing its own code.
|
|
125
|
+
const crossOriginErrors = (b.console || []).filter(isMessagelessCrossOrigin);
|
|
126
|
+
b.console = (b.console || []).filter((l) => !isMessagelessCrossOrigin(l));
|
|
127
|
+
// Self-verify the remaining console errors before flagging them. A
|
|
128
|
+
// freshly-deployed page's first hit can throw a one-time, non-reproducible
|
|
129
|
+
// error from an asset still propagating — and reporting it as a real defect
|
|
130
|
+
// sends agents chasing a phantom. So when the first probe reports error-level
|
|
131
|
+
// console lines, re-probe once (the sticky session is now warm) and keep only
|
|
132
|
+
// the errors that recur; errors seen on a single probe are surfaced
|
|
133
|
+
// separately as transient noise.
|
|
134
|
+
let transientErrors = [];
|
|
135
|
+
if (opts.reprobe !== false && (b.console || []).some(isErrorLine)) {
|
|
136
|
+
try {
|
|
137
|
+
const verify = await post(`/tools/browser/inspect`, inspectBody);
|
|
138
|
+
const recurring = new Set((verify.data.console || []).filter(isErrorLine));
|
|
139
|
+
transientErrors = (b.console || []).filter((l) => isErrorLine(l) && !recurring.has(l));
|
|
140
|
+
b.console = (b.console || []).filter((l) => !isErrorLine(l) || recurring.has(l));
|
|
146
141
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (b.navigationIncomplete) {
|
|
151
|
-
console.log(`${warning('⚠ Navigation incomplete:')} ${b.note || 'page did not reach full load'}`);
|
|
142
|
+
catch {
|
|
143
|
+
// Re-probe failed (timeout / browser error) — report the first probe's
|
|
144
|
+
// console as-is rather than hiding anything.
|
|
152
145
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
console.log(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
146
|
+
}
|
|
147
|
+
if (opts.json) {
|
|
148
|
+
console.log(JSON.stringify({
|
|
149
|
+
...b,
|
|
150
|
+
...(transientErrors.length ? { transientConsole: transientErrors } : {}),
|
|
151
|
+
...(crossOriginErrors.length ? { crossOriginConsole: crossOriginErrors } : {}),
|
|
152
|
+
}));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const timing = b.timing || { ttfb: 0, domReady: 0, load: 0 };
|
|
156
|
+
// ── Page Info ──
|
|
157
|
+
console.log(`${brand('Inspecting')} ${bold(b.url || url)}`);
|
|
158
|
+
if (b.navigationIncomplete) {
|
|
159
|
+
console.log(`${warning('⚠ Navigation incomplete:')} ${b.note || 'page did not reach full load'}`);
|
|
160
|
+
}
|
|
161
|
+
// Auth state: without this line an agent can't distinguish "signed-in render"
|
|
162
|
+
// from "--auth silently no-op'd and I'm looking at the anonymous page".
|
|
163
|
+
if (b.auth?.requested) {
|
|
164
|
+
const who = getAuth()?.email;
|
|
165
|
+
console.log(b.auth.established
|
|
166
|
+
? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
|
|
167
|
+
: `${warning('Auth: session NOT established')}${b.auth.detail ? ` — ${b.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
|
|
168
|
+
}
|
|
169
|
+
console.log(`${muted('Title:')} ${b.title || '(none)'}`);
|
|
170
|
+
console.log(`${muted('Elements:')} ${b.elementCount || 0}`);
|
|
171
|
+
console.log(`${muted('Page weight:')} ${info(formatSize(b.totalBytes || 0))}`);
|
|
172
|
+
// ── Timing ──
|
|
173
|
+
console.log(`\n${bold('Timing:')}`);
|
|
174
|
+
console.log(`${muted('TTFB:')} ${timing.ttfb}ms`);
|
|
175
|
+
console.log(`${muted('DOM ready:')} ${timing.domReady}ms`);
|
|
176
|
+
console.log(`${muted('Load:')} ${timing.load}ms`);
|
|
177
|
+
if (showAll && b.lcp) {
|
|
178
|
+
console.log(`LCP: ${b.lcp.time}ms (${b.lcp.element}${b.lcp.url ? ' ' + shortUrl(b.lcp.url, truncate) : ''})`);
|
|
179
|
+
}
|
|
180
|
+
// ── Console ──
|
|
181
|
+
if (b.console?.length > 0) {
|
|
182
|
+
console.log(`\n${bold('Console')} ${muted(`(${b.console.length})`)}:`);
|
|
183
|
+
for (const line of b.console) {
|
|
184
|
+
console.log(`${warning(line)}`);
|
|
163
185
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
console.log(`\n${bold('Console:')} ${muted('(clean)')}`);
|
|
189
|
+
}
|
|
190
|
+
// ── Transient console errors (seen on first probe, gone on re-probe) ──
|
|
191
|
+
if (transientErrors.length > 0) {
|
|
192
|
+
console.log(`\n${bold('Transient console errors')} ${muted(`(${transientErrors.length}, not reproduced on re-probe)`)}:`);
|
|
193
|
+
for (const line of transientErrors) {
|
|
194
|
+
console.log(muted(line));
|
|
170
195
|
}
|
|
171
|
-
|
|
172
|
-
|
|
196
|
+
console.log(muted('One-time cold-load artifact (first hit of freshly-deployed assets) — not reproducible, not in your app code. Ignore unless it recurs.'));
|
|
197
|
+
}
|
|
198
|
+
// ── Cross-origin console errors (message-less; source hidden by the browser) ──
|
|
199
|
+
if (crossOriginErrors.length > 0) {
|
|
200
|
+
console.log(`\n${bold('Cross-origin console errors')} ${muted(`(${crossOriginErrors.length}, source hidden by the browser)`)}:`);
|
|
201
|
+
console.log(muted("Message-less — the throwing <script> lacks CORS, so the browser hides its source and there's no own-code stack to chase. Gipity's injected SDK is itself cross-origin, so if your app loads no third-party CDN scripts these are platform noise — ignore them. If your app DOES load a third-party <script>, add crossorigin=\"anonymous\" to that tag to surface the real error."));
|
|
202
|
+
}
|
|
203
|
+
// ── Failed Resources ──
|
|
204
|
+
// Browsers auto-request /favicon.ico at the site root for every page, so a
|
|
205
|
+
// 404 there isn't a resource the page actually links — it's noise on any
|
|
206
|
+
// app served under a subpath. Split that implicit request out of the failure
|
|
207
|
+
// list into a harmless note rather than flagging it as an error.
|
|
208
|
+
const isImplicitFavicon = (entry) => {
|
|
209
|
+
const urlPart = entry.replace(/\s*\([^)]*\)\s*$/, '');
|
|
210
|
+
try {
|
|
211
|
+
return new URL(urlPart).pathname === '/favicon.ico';
|
|
173
212
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
console.log(`\n${bold('Transient console errors')} ${muted(`(${transientErrors.length}, not reproduced on re-probe)`)}:`);
|
|
177
|
-
for (const line of transientErrors) {
|
|
178
|
-
console.log(muted(line));
|
|
179
|
-
}
|
|
180
|
-
console.log(muted('One-time cold-load artifact (first hit of freshly-deployed assets) — not reproducible, not in your app code. Ignore unless it recurs.'));
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
181
215
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
216
|
+
};
|
|
217
|
+
const failed = (b.failedResources || []).filter((r) => !isImplicitFavicon(r));
|
|
218
|
+
const rootFaviconMissing = (b.failedResources || []).some(isImplicitFavicon);
|
|
219
|
+
if (failed.length > 0) {
|
|
220
|
+
console.log(`\n${clrError(`Failed resources (${failed.length}):`)}`);
|
|
221
|
+
for (const r of failed) {
|
|
222
|
+
console.log(`${clrError(r)}`);
|
|
186
223
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
224
|
+
}
|
|
225
|
+
if (rootFaviconMissing) {
|
|
226
|
+
console.log(`\n${muted('No root /favicon.ico (browsers request this automatically; harmless for app pages served under a subpath)')}`);
|
|
227
|
+
}
|
|
228
|
+
// ── Layout (horizontal overflow) ──
|
|
229
|
+
if (b.overflow) {
|
|
230
|
+
if (b.overflow.overflowX) {
|
|
231
|
+
console.log(`\n${clrError(`Horizontal overflow: +${b.overflow.amount}px`)} ${muted(`(content ${b.overflow.scrollWidth}px vs viewport ${b.overflow.clientWidth}px)`)}`);
|
|
232
|
+
if (showAll && b.overflow.culprits.length > 0) {
|
|
233
|
+
console.log(`${muted('Overflowing elements:')}`);
|
|
234
|
+
for (const c of b.overflow.culprits) {
|
|
235
|
+
const sel = c.cls ? `${c.tag}.${c.cls.split(/\s+/)[0]}` : c.tag;
|
|
236
|
+
console.log(`${sel} ${muted(`(right ${c.right}px, width ${c.width}px)`)}`);
|
|
237
|
+
}
|
|
199
238
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const rootFaviconMissing = (b.failedResources || []).some(isImplicitFavicon);
|
|
203
|
-
if (failed.length > 0) {
|
|
204
|
-
console.log(`\n${clrError(`Failed resources (${failed.length}):`)}`);
|
|
205
|
-
for (const r of failed) {
|
|
206
|
-
console.log(`${clrError(r)}`);
|
|
239
|
+
else if (b.overflow.culprits.length > 0) {
|
|
240
|
+
console.log(`${muted(`${b.overflow.culprits.length} overflowing element(s) - use --all to list`)}`);
|
|
207
241
|
}
|
|
208
242
|
}
|
|
209
|
-
|
|
210
|
-
console.log(`\n${
|
|
243
|
+
else {
|
|
244
|
+
console.log(`\n${bold('Layout:')} ${muted('no horizontal overflow')}`);
|
|
211
245
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const sel = c.cls ? `${c.tag}.${c.cls.split(/\s+/)[0]}` : c.tag;
|
|
220
|
-
console.log(`${sel} ${muted(`(right ${c.right}px, width ${c.width}px)`)}`);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
else if (b.overflow.culprits.length > 0) {
|
|
224
|
-
console.log(`${muted(`${b.overflow.culprits.length} overflowing element(s) - use --all to list`)}`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
else {
|
|
228
|
-
console.log(`\n${bold('Layout:')} ${muted('no horizontal overflow')}`);
|
|
246
|
+
}
|
|
247
|
+
if (showAll) {
|
|
248
|
+
// ── Render Blocking ──
|
|
249
|
+
if (b.renderBlocking?.length > 0) {
|
|
250
|
+
console.log(`\n${warning(`Render-blocking (${b.renderBlocking.length}):`)}`);
|
|
251
|
+
for (const r of b.renderBlocking) {
|
|
252
|
+
console.log(`${shortUrl(r, truncate)}`);
|
|
229
253
|
}
|
|
230
254
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
console.log(`${shortUrl(r, truncate)}`);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
// ── Large Resources ──
|
|
240
|
-
if (b.largeResources?.length > 0) {
|
|
241
|
-
console.log(`\n${warning(`Large resources >100KB (${b.largeResources.length}):`)}`);
|
|
242
|
-
for (const r of b.largeResources) {
|
|
243
|
-
console.log(`${info(formatSize(r.size).padEnd(10))} ${muted(r.type.padEnd(8))} ${shortUrl(r.url, truncate)}`);
|
|
244
|
-
}
|
|
255
|
+
// ── Large Resources ──
|
|
256
|
+
if (b.largeResources?.length > 0) {
|
|
257
|
+
console.log(`\n${warning(`Large resources >100KB (${b.largeResources.length}):`)}`);
|
|
258
|
+
for (const r of b.largeResources) {
|
|
259
|
+
console.log(`${info(formatSize(r.size).padEnd(10))} ${muted(r.type.padEnd(8))} ${shortUrl(r.url, truncate)}`);
|
|
245
260
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
261
|
+
}
|
|
262
|
+
// ── Oversized Images ──
|
|
263
|
+
if (b.oversizedImages?.length > 0) {
|
|
264
|
+
console.log(`\n${warning(`Oversized images (${b.oversizedImages.length}):`)}`);
|
|
265
|
+
for (const img of b.oversizedImages) {
|
|
266
|
+
console.log(`${img.natural} served, ${img.displayed} displayed - ${shortUrl(img.src, truncate)}`);
|
|
252
267
|
}
|
|
253
268
|
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
256
271
|
//# sourceMappingURL=page-inspect.js.map
|
|
@@ -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/push.js
CHANGED
|
@@ -3,13 +3,13 @@ import { resolve } from 'path';
|
|
|
3
3
|
import { pushFile } from '../sync.js';
|
|
4
4
|
import { error as clrError, success } from '../colors.js';
|
|
5
5
|
export const pushCommand = new Command('push')
|
|
6
|
-
.description('Push
|
|
7
|
-
.argument('<
|
|
6
|
+
.description('Push one or more files')
|
|
7
|
+
.argument('<files...>', 'File path(s) to push')
|
|
8
8
|
.option('--quiet', 'Suppress output')
|
|
9
9
|
.option('--background', 'Fork and exit immediately')
|
|
10
|
-
.action(async (
|
|
10
|
+
.action(async (files, opts) => {
|
|
11
11
|
try {
|
|
12
|
-
const
|
|
12
|
+
const fullPaths = files.map(f => resolve(f));
|
|
13
13
|
if (opts.background) {
|
|
14
14
|
// Detach a background `gipity push` and exit immediately. Goes through
|
|
15
15
|
// spawnCommand (Node binary running our own entry script - no IPC
|
|
@@ -17,16 +17,21 @@ export const pushCommand = new Command('push')
|
|
|
17
17
|
// `windowsHide: true` so the detached child never flashes a console
|
|
18
18
|
// window on Windows.
|
|
19
19
|
const { spawnCommand } = await import('../platform.js');
|
|
20
|
-
const child = spawnCommand(process.execPath, [process.argv[1], 'push',
|
|
20
|
+
const child = spawnCommand(process.execPath, [process.argv[1], 'push', ...fullPaths, '--quiet'], {
|
|
21
21
|
detached: true,
|
|
22
22
|
stdio: 'ignore',
|
|
23
23
|
});
|
|
24
24
|
child.unref();
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
// Sequential on purpose: pushFile takes the per-project sync lock, so
|
|
28
|
+
// parallel pushes would just contend on it. One process pushing N files
|
|
29
|
+
// still beats the old N processes × N lock acquisitions.
|
|
30
|
+
for (const fullPath of fullPaths) {
|
|
31
|
+
await pushFile(fullPath);
|
|
32
|
+
}
|
|
28
33
|
if (!opts.quiet) {
|
|
29
|
-
console.log(success(`Pushed ${
|
|
34
|
+
console.log(success(files.length === 1 ? `Pushed ${files[0]}` : `Pushed ${files.length} files`));
|
|
30
35
|
}
|
|
31
36
|
}
|
|
32
37
|
catch (err) {
|