@surething/cockpit 1.0.216 → 1.0.218
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/.next-prod/BUILD_ID +1 -1
- package/.next-prod/app-path-routes-manifest.json +2 -2
- package/.next-prod/build-manifest.json +2 -2
- package/.next-prod/prerender-manifest.json +3 -3
- package/.next-prod/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next-prod/server/app/_global-error.html +1 -1
- package/.next-prod/server/app/_global-error.rsc +1 -1
- package/.next-prod/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next-prod/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next-prod/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next-prod/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next-prod/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next-prod/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next-prod/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next-prod/server/app/_not-found.html +1 -1
- package/.next-prod/server/app/_not-found.rsc +3 -3
- package/.next-prod/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next-prod/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next-prod/server/app/_not-found.segments/_index.segment.rsc +3 -3
- package/.next-prod/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next-prod/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next-prod/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next-prod/server/app/api/chat/deepseek/route.js +1 -1
- package/.next-prod/server/app/api/chat/route.js +1 -1
- package/.next-prod/server/app/api/extension/version/route.js.nft.json +1 -1
- package/.next-prod/server/app/api/projectGraph/file-functions/route.js +1 -1
- package/.next-prod/server/app/api/scheduled-tasks/route.js +1 -1
- package/.next-prod/server/app/api/terminal/bubble-order/route.js +1 -1
- package/.next-prod/server/app/page_client-reference-manifest.js +1 -1
- package/.next-prod/server/app/project/page_client-reference-manifest.js +1 -1
- package/.next-prod/server/app/review/[id]/page_client-reference-manifest.js +1 -1
- package/.next-prod/server/app-paths-manifest.json +2 -2
- package/.next-prod/server/chunks/2939.js +1 -1
- package/.next-prod/server/chunks/8916.js +1 -1
- package/.next-prod/server/chunks/9658.js +7 -7
- package/.next-prod/server/chunks/9877.js +1 -1
- package/.next-prod/server/functions-config-manifest.json +1 -0
- package/.next-prod/server/middleware-build-manifest.js +1 -1
- package/.next-prod/server/pages/404.html +1 -1
- package/.next-prod/server/pages/500.html +1 -1
- package/.next-prod/server/server-reference-manifest.json +1 -1
- package/.next-prod/static/chunks/6345-2637497e8b101740.js +14 -0
- package/.next-prod/static/chunks/6917-ed0e9c62a123d529.js +29 -0
- package/.next-prod/static/chunks/app/{layout-a0362651ba6e6e6f.js → layout-1659a95e6c4a6bb5.js} +1 -1
- package/.next-prod/static/chunks/app/{page-1b14cabf47df9ff7.js → page-afcbd897b4c3600f.js} +1 -1
- package/.next-prod/static/chunks/app/project/{page-1b14cabf47df9ff7.js → page-afcbd897b4c3600f.js} +1 -1
- package/.next-prod/static/css/f4a773117ca8af75.css +1 -0
- package/.next-prod/trace +13 -13
- package/.next-prod/trace-build +1 -1
- package/README.md +8 -7
- package/README.zh.md +8 -7
- package/bin/cock-browser.messages.mjs +176 -0
- package/bin/cock-browser.mjs +290 -18
- package/bin/cock-codegraph.mjs +21 -6
- package/bin/cock-connection.mjs +151 -0
- package/bin/cock.mjs +12 -1
- package/bin/setup-dev.mjs +15 -13
- package/chrome-extension/automation.js +684 -32
- package/chrome-extension/manifest.json +1 -1
- package/chrome-extension/messages.js +45 -0
- package/dist/{chunk-CZWJPTRO.mjs → chunk-GCYLMG43.mjs} +2486 -1047
- package/dist/chunk-O4P2J44N.mjs +314 -0
- package/dist/{chunk-KRTISG5I.mjs → chunk-WOM47O75.mjs} +245 -10
- package/dist/httpApi.mjs +140 -7
- package/dist/scheduledTasks.mjs +15 -1159
- package/dist/{server-OSOMFNXR.mjs → server-SNB4H35J.mjs} +8 -2
- package/dist/wsServer.mjs +27 -19
- package/package.json +3 -5
- package/server.mjs +5 -1
- package/.next-prod/static/chunks/5188-415582403ef0e29c.js +0 -29
- package/.next-prod/static/chunks/6345-e5ceeb2aeb698eb6.js +0 -14
- package/.next-prod/static/css/cc6d733cdf607b30.css +0 -1
- package/dist/chunk-ZJ6CC3MH.mjs +0 -223
- /package/.next-prod/static/{GAYKr2BmQpFqJgRJfvQ3D → bOkuiIr_nWzG5GjPLNqdN}/_buildManifest.js +0 -0
- /package/.next-prod/static/{GAYKr2BmQpFqJgRJfvQ3D → bOkuiIr_nWzG5GjPLNqdN}/_ssgManifest.js +0 -0
|
@@ -21,24 +21,71 @@ let refCounter = 0;
|
|
|
21
21
|
const refToElement = new Map();
|
|
22
22
|
const elementToRef = new WeakMap();
|
|
23
23
|
|
|
24
|
+
// Snapshot epoch: incremented by clearRefs() each time `snapshot` runs.
|
|
25
|
+
// Refs encode the epoch as `e<N>#v<E>`, so stale refs from a previous
|
|
26
|
+
// snapshot are detected explicitly rather than producing a misleading
|
|
27
|
+
// "disconnected" message.
|
|
28
|
+
let snapshotEpoch = 0;
|
|
29
|
+
|
|
24
30
|
function clearRefs() {
|
|
25
31
|
refCounter = 0;
|
|
26
32
|
refToElement.clear();
|
|
33
|
+
snapshotEpoch += 1;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
function assignRef(el) {
|
|
37
|
+
// WeakMap can't be cleared, so a previously-snapshotted element returns its
|
|
38
|
+
// OLD ref (carrying an old epoch). Detect that and re-assign at the current
|
|
39
|
+
// epoch so all refs in a single snapshot share one banner version.
|
|
30
40
|
const existing = elementToRef.get(el);
|
|
31
|
-
if (existing)
|
|
32
|
-
|
|
41
|
+
if (existing) {
|
|
42
|
+
const m = existing.match(/^e\d+#v(\d+)$/);
|
|
43
|
+
if (m && Number(m[1]) === snapshotEpoch) return existing;
|
|
44
|
+
// fall through — re-assign at the current epoch
|
|
45
|
+
}
|
|
46
|
+
const ref = `e${refCounter++}#v${snapshotEpoch}`;
|
|
33
47
|
refToElement.set(ref, el);
|
|
34
48
|
elementToRef.set(el, ref);
|
|
35
49
|
return ref;
|
|
36
50
|
}
|
|
37
51
|
|
|
52
|
+
// Inline stale-ref message — kept here (not in messages.js) to avoid
|
|
53
|
+
// runtime cross-module loading in the content-script context. Examples
|
|
54
|
+
// below are generic by policy; never inline business-specific selectors.
|
|
55
|
+
function staleRefMsg(ref, kind, currentEpoch) {
|
|
56
|
+
return (
|
|
57
|
+
`Element ref "${ref}" is stale (current snapshot v=${currentEpoch}; kind: ${kind}).\n` +
|
|
58
|
+
` Refs are valid only until the next snapshot / re-render / route change.\n` +
|
|
59
|
+
` Fix one of:\n` +
|
|
60
|
+
` 1. Re-run \`snapshot\` to get fresh refs (banner shows v=${currentEpoch}).\n` +
|
|
61
|
+
` 2. Use a CSS selector or visible text directly:\n` +
|
|
62
|
+
` cockpit browser <id> click --text "Sign in"\n` +
|
|
63
|
+
` cockpit browser <id> click --selector 'button[type="submit"]'\n` +
|
|
64
|
+
` 3. Drop to evaluate:\n` +
|
|
65
|
+
` cockpit browser <id> evaluate "(() => document.querySelector('button[aria-label=\\"Save\\"]').click())()"`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
function findByRef(ref) {
|
|
70
|
+
if (typeof ref !== 'string' || !ref) {
|
|
71
|
+
throw new Error(staleRefMsg(String(ref), 'missing', snapshotEpoch));
|
|
72
|
+
}
|
|
73
|
+
const m = ref.match(/^e(\d+)(?:#v(\d+))?$/);
|
|
74
|
+
if (!m) {
|
|
75
|
+
throw new Error(staleRefMsg(ref, 'malformed', snapshotEpoch));
|
|
76
|
+
}
|
|
77
|
+
if (m[2] === undefined) {
|
|
78
|
+
// Legacy `eN` form (no epoch). Per spec there is no backwards compat;
|
|
79
|
+
// reject explicitly so AI knows to re-snapshot rather than guessing.
|
|
80
|
+
throw new Error(staleRefMsg(ref, 'no-epoch (use eN#vM)', snapshotEpoch));
|
|
81
|
+
}
|
|
82
|
+
const refEpoch = Number(m[2]);
|
|
83
|
+
if (refEpoch !== snapshotEpoch) {
|
|
84
|
+
throw new Error(staleRefMsg(ref, `from v=${refEpoch}`, snapshotEpoch));
|
|
85
|
+
}
|
|
39
86
|
const el = refToElement.get(ref);
|
|
40
87
|
if (!el || !el.isConnected) {
|
|
41
|
-
throw new Error(
|
|
88
|
+
throw new Error(staleRefMsg(ref, 'disconnected', snapshotEpoch));
|
|
42
89
|
}
|
|
43
90
|
return el;
|
|
44
91
|
}
|
|
@@ -137,9 +184,16 @@ function getName(el) {
|
|
|
137
184
|
return el.title || '';
|
|
138
185
|
}
|
|
139
186
|
|
|
140
|
-
function buildA11yTree(root = document.body,
|
|
187
|
+
function buildA11yTree(root = document.body, opts = {}) {
|
|
188
|
+
const {
|
|
189
|
+
maxDepth = 12,
|
|
190
|
+
filter = null, // string regex source; lines not matching are dropped
|
|
191
|
+
includeHiddenText = false, // surface innerText for container/anonymous nodes
|
|
192
|
+
} = opts;
|
|
193
|
+
|
|
141
194
|
clearRefs();
|
|
142
195
|
const lines = [];
|
|
196
|
+
const filterRe = filter ? safeRegex(filter) : null;
|
|
143
197
|
|
|
144
198
|
function walk(el, depth) {
|
|
145
199
|
if (depth > maxDepth) return;
|
|
@@ -160,6 +214,15 @@ function buildA11yTree(root = document.body, maxDepth = 12) {
|
|
|
160
214
|
if (name) {
|
|
161
215
|
const displayName = name.length > 80 ? name.slice(0, 77) + '...' : name;
|
|
162
216
|
line += ` "${displayName}"`;
|
|
217
|
+
} else if (includeHiddenText) {
|
|
218
|
+
// Surface collapsed text on otherwise-nameless nodes (e.g. <summary>,
|
|
219
|
+
// <details>, headings with emoji + text). Useful when grep-ing on
|
|
220
|
+
// user-visible content rather than role+aria-label.
|
|
221
|
+
const inner = (el.innerText || '').trim().replace(/\s+/g, ' ');
|
|
222
|
+
if (inner) {
|
|
223
|
+
const snippet = inner.length > 60 ? inner.slice(0, 57) + '...' : inner;
|
|
224
|
+
line += ` …"${snippet}"`;
|
|
225
|
+
}
|
|
163
226
|
}
|
|
164
227
|
|
|
165
228
|
const extras = [];
|
|
@@ -177,6 +240,17 @@ function buildA11yTree(root = document.body, maxDepth = 12) {
|
|
|
177
240
|
if (extras.length) line += ` [${extras.join(', ')}]`;
|
|
178
241
|
line += ` [${ref}]`;
|
|
179
242
|
lines.push(line);
|
|
243
|
+
} else if (includeHiddenText) {
|
|
244
|
+
// Even for "container" passthrough, emit a thin marker line if it has
|
|
245
|
+
// visible text — so grep can still find content collapsed by the
|
|
246
|
+
// container heuristic. We don't assign a ref here (container is just
|
|
247
|
+
// structure, not addressable).
|
|
248
|
+
const inner = (el.innerText || '').trim().replace(/\s+/g, ' ');
|
|
249
|
+
if (inner) {
|
|
250
|
+
const indent = ' '.repeat(depth);
|
|
251
|
+
const snippet = inner.length > 60 ? inner.slice(0, 57) + '...' : inner;
|
|
252
|
+
lines.push(`${indent}${el.tagName.toLowerCase()} …"${snippet}"`);
|
|
253
|
+
}
|
|
180
254
|
}
|
|
181
255
|
|
|
182
256
|
for (const child of el.children) {
|
|
@@ -185,7 +259,28 @@ function buildA11yTree(root = document.body, maxDepth = 12) {
|
|
|
185
259
|
}
|
|
186
260
|
|
|
187
261
|
walk(root || document.body, 0);
|
|
188
|
-
|
|
262
|
+
|
|
263
|
+
let kept = lines;
|
|
264
|
+
if (filterRe) kept = lines.filter(l => filterRe.test(l));
|
|
265
|
+
|
|
266
|
+
// Banner is always emitted first. AI parses v=<N> to detect epoch changes
|
|
267
|
+
// (any cached refs from a different epoch are stale per findByRef).
|
|
268
|
+
const banner = (
|
|
269
|
+
`# a11y tree v=${snapshotEpoch} — refs valid until next snapshot\n` +
|
|
270
|
+
`# Text inside <details>/<summary> and unnamed container <div>/<section> is collapsed.\n` +
|
|
271
|
+
`# Grep on role / aria-label, NOT on user-visible emoji / text.\n` +
|
|
272
|
+
`# Tips: --include-hidden-text surfaces collapsed innerText; --filter <regex> reduces output.\n` +
|
|
273
|
+
(filterRe ? `# filter: ${filter} (${kept.length}/${lines.length} lines kept)\n` : '') +
|
|
274
|
+
(includeHiddenText ? `# include-hidden-text: on\n` : '')
|
|
275
|
+
);
|
|
276
|
+
return banner + kept.join('\n');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Safe regex compile — falls back to a literal-match regex if the pattern
|
|
280
|
+
// is malformed, so a stray `?` or `(` from AI doesn't crash the snapshot.
|
|
281
|
+
function safeRegex(src) {
|
|
282
|
+
try { return new RegExp(src); }
|
|
283
|
+
catch { return new RegExp(src.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); }
|
|
189
284
|
}
|
|
190
285
|
|
|
191
286
|
// ============================================================================
|
|
@@ -283,7 +378,8 @@ const handlers = {
|
|
|
283
378
|
back: async () => { history.back(); return { ok: true }; },
|
|
284
379
|
forward: async () => { history.forward(); return { ok: true }; },
|
|
285
380
|
|
|
286
|
-
snapshot: async () =>
|
|
381
|
+
snapshot: async ({ filter, includeHiddenText, maxDepth } = {}) =>
|
|
382
|
+
buildA11yTree(document.body, { filter, includeHiddenText, maxDepth }),
|
|
287
383
|
|
|
288
384
|
screenshot: async () => {
|
|
289
385
|
// 1) 通知父页面(项目 iframe):切到 console view + 切到本项目 + 返回 iframe bounds
|
|
@@ -326,11 +422,33 @@ const handlers = {
|
|
|
326
422
|
return { image: result, format: 'png' };
|
|
327
423
|
},
|
|
328
424
|
|
|
329
|
-
click: async ({ ref }) => {
|
|
330
|
-
|
|
425
|
+
click: async ({ ref, text, selector, exact = false, nth = 0 }) => {
|
|
426
|
+
let el;
|
|
427
|
+
if (ref) {
|
|
428
|
+
el = findByRef(ref);
|
|
429
|
+
} else if (selector) {
|
|
430
|
+
const all = document.querySelectorAll(selector);
|
|
431
|
+
if (!all.length) throw new Error(`No element matching selector "${selector}"`);
|
|
432
|
+
if (nth >= all.length) throw new Error(`Only ${all.length} matches for selector "${selector}", nth=${nth} out of range`);
|
|
433
|
+
el = all[nth];
|
|
434
|
+
} else if (text) {
|
|
435
|
+
const candidates = Array.from(document.querySelectorAll(
|
|
436
|
+
'button, a, [role="button"], [role="link"], [role="tab"], [role="menuitem"], input[type="submit"], input[type="button"]'
|
|
437
|
+
));
|
|
438
|
+
const matches = candidates.filter(c => {
|
|
439
|
+
const t = (c.textContent || '').trim();
|
|
440
|
+
const aria = c.getAttribute('aria-label') || '';
|
|
441
|
+
return exact ? (t === text || aria === text) : (t.includes(text) || aria.includes(text));
|
|
442
|
+
});
|
|
443
|
+
if (!matches.length) throw new Error(`No clickable element with text "${text}" (searched button / a / role=button|link|tab|menuitem / input[type=submit|button])`);
|
|
444
|
+
if (nth >= matches.length) throw new Error(`Only ${matches.length} matches for text "${text}", nth=${nth} out of range`);
|
|
445
|
+
el = matches[nth];
|
|
446
|
+
} else {
|
|
447
|
+
throw new Error('click requires one of: ref (positional or --ref), --text, --selector');
|
|
448
|
+
}
|
|
331
449
|
el.scrollIntoView({ block: 'nearest' });
|
|
332
450
|
el.click();
|
|
333
|
-
return { clicked: ref };
|
|
451
|
+
return { clicked: ref || (text ? `text:${text}` : `selector:${selector}`), nth };
|
|
334
452
|
},
|
|
335
453
|
|
|
336
454
|
type: async ({ ref, text, clear }) => {
|
|
@@ -356,13 +474,24 @@ const handlers = {
|
|
|
356
474
|
return { typed: text, ref };
|
|
357
475
|
},
|
|
358
476
|
|
|
359
|
-
fill: async ({ ref, value }) => {
|
|
360
|
-
|
|
477
|
+
fill: async ({ ref, selector, value }) => {
|
|
478
|
+
let el;
|
|
479
|
+
let target;
|
|
480
|
+
if (ref) {
|
|
481
|
+
el = findByRef(ref);
|
|
482
|
+
target = ref;
|
|
483
|
+
} else if (selector) {
|
|
484
|
+
el = document.querySelector(selector);
|
|
485
|
+
if (!el) throw new Error(`No element matching selector "${selector}"`);
|
|
486
|
+
target = `selector:${selector}`;
|
|
487
|
+
} else {
|
|
488
|
+
throw new Error('fill requires one of: ref (positional or --ref), --selector');
|
|
489
|
+
}
|
|
361
490
|
el.focus();
|
|
362
491
|
if (el.tagName === 'SELECT') {
|
|
363
492
|
el.value = value;
|
|
364
493
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
365
|
-
return { filled:
|
|
494
|
+
return { filled: target, value };
|
|
366
495
|
}
|
|
367
496
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
|
368
497
|
|| Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
@@ -370,7 +499,7 @@ const handlers = {
|
|
|
370
499
|
else el.value = value;
|
|
371
500
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
372
501
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
373
|
-
return { filled:
|
|
502
|
+
return { filled: target, value };
|
|
374
503
|
},
|
|
375
504
|
|
|
376
505
|
hover: async ({ ref }) => {
|
|
@@ -422,24 +551,111 @@ const handlers = {
|
|
|
422
551
|
return { dispatched: event, ref };
|
|
423
552
|
},
|
|
424
553
|
|
|
425
|
-
wait: async ({
|
|
554
|
+
wait: async ({
|
|
555
|
+
text,
|
|
556
|
+
ref: waitRef,
|
|
557
|
+
url: waitUrl,
|
|
558
|
+
time,
|
|
559
|
+
selector,
|
|
560
|
+
state = 'visible',
|
|
561
|
+
networkIdle,
|
|
562
|
+
domStable,
|
|
563
|
+
quietMs = 500,
|
|
564
|
+
maxRequestAgeMs = 30000,
|
|
565
|
+
timeout = 10000,
|
|
566
|
+
}) => {
|
|
426
567
|
const start = Date.now();
|
|
427
568
|
const poll = (check) => new Promise((resolve, reject) => {
|
|
428
569
|
const interval = setInterval(() => {
|
|
429
|
-
|
|
430
|
-
|
|
570
|
+
try {
|
|
571
|
+
if (check()) { clearInterval(interval); resolve(true); return; }
|
|
572
|
+
} catch (e) { clearInterval(interval); reject(e); return; }
|
|
573
|
+
if (Date.now() - start > timeout) {
|
|
574
|
+
clearInterval(interval);
|
|
575
|
+
reject(new Error(`Wait timeout after ${timeout}ms`));
|
|
576
|
+
}
|
|
431
577
|
}, 100);
|
|
432
578
|
});
|
|
433
579
|
|
|
434
580
|
if (time) { await new Promise(r => setTimeout(r, time)); return { waited: `${time}ms` }; }
|
|
435
|
-
|
|
436
|
-
if (
|
|
581
|
+
|
|
582
|
+
if (text) {
|
|
583
|
+
await poll(() => document.body.textContent.includes(text));
|
|
584
|
+
return { waited: `text "${text}"`, elapsedMs: Date.now() - start };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (waitRef) {
|
|
588
|
+
await poll(() => refToElement.has(waitRef) && refToElement.get(waitRef).isConnected);
|
|
589
|
+
return { waited: `ref ${waitRef}`, elapsedMs: Date.now() - start };
|
|
590
|
+
}
|
|
591
|
+
|
|
437
592
|
if (waitUrl) {
|
|
438
593
|
const pat = waitUrl.includes('*') ? new RegExp('^' + waitUrl.replace(/\*/g, '.*') + '$') : null;
|
|
439
594
|
await poll(() => pat ? pat.test(location.href) : location.href.includes(waitUrl));
|
|
440
|
-
return { waited: `url "${waitUrl}"
|
|
595
|
+
return { waited: `url "${waitUrl}"`, elapsedMs: Date.now() - start };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// F2.2 — wait for an element matching CSS selector to reach a state.
|
|
599
|
+
if (selector) {
|
|
600
|
+
const validStates = ['visible', 'hidden', 'attached', 'detached'];
|
|
601
|
+
if (!validStates.includes(state)) {
|
|
602
|
+
throw new Error(`Invalid --state "${state}"; expected one of: ${validStates.join(', ')}`);
|
|
603
|
+
}
|
|
604
|
+
await poll(() => {
|
|
605
|
+
const el = document.querySelector(selector);
|
|
606
|
+
if (state === 'attached') return !!el;
|
|
607
|
+
if (state === 'detached') return !el;
|
|
608
|
+
if (!el) return false;
|
|
609
|
+
const vis = isVisible(el);
|
|
610
|
+
return state === 'visible' ? vis : !vis;
|
|
611
|
+
});
|
|
612
|
+
return { waited: `selector "${selector}" → ${state}`, elapsedMs: Date.now() - start };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// F2.1 — wait for the page network to settle: 0 in-flight requests for
|
|
616
|
+
// `quietMs` consecutive ms. Requests older than `maxRequestAgeMs` are
|
|
617
|
+
// ignored (covers SSE / long-poll). Counts XHR + fetch from networkBuffer.
|
|
618
|
+
if (networkIdle) {
|
|
619
|
+
let quietSince = null;
|
|
620
|
+
await poll(() => {
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
const inflight = networkBuffer.filter(e =>
|
|
623
|
+
e.status == null &&
|
|
624
|
+
e.startTime &&
|
|
625
|
+
(now - e.startTime) < maxRequestAgeMs
|
|
626
|
+
).length;
|
|
627
|
+
if (inflight === 0) {
|
|
628
|
+
if (quietSince == null) quietSince = now;
|
|
629
|
+
return (now - quietSince) >= quietMs;
|
|
630
|
+
}
|
|
631
|
+
quietSince = null;
|
|
632
|
+
return false;
|
|
633
|
+
});
|
|
634
|
+
return { waited: `network-idle (quiet=${quietMs}ms)`, elapsedMs: Date.now() - start };
|
|
441
635
|
}
|
|
442
|
-
|
|
636
|
+
|
|
637
|
+
// F3.3 — wait until DOM mutations stop for `quietMs` consecutive ms.
|
|
638
|
+
if (domStable) {
|
|
639
|
+
return await new Promise((resolve, reject) => {
|
|
640
|
+
let lastMutation = Date.now();
|
|
641
|
+
const obs = new MutationObserver(() => { lastMutation = Date.now(); });
|
|
642
|
+
obs.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
643
|
+
const tick = setInterval(() => {
|
|
644
|
+
const now = Date.now();
|
|
645
|
+
if (now - lastMutation >= quietMs) {
|
|
646
|
+
clearInterval(tick); obs.disconnect();
|
|
647
|
+
resolve({ waited: `dom-stable (quiet=${quietMs}ms)`, elapsedMs: now - start });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (now - start > timeout) {
|
|
651
|
+
clearInterval(tick); obs.disconnect();
|
|
652
|
+
reject(new Error(`Wait timeout after ${timeout}ms (dom still mutating)`));
|
|
653
|
+
}
|
|
654
|
+
}, 50);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
throw new Error('wait requires one of: --text, --ref, --url, --time, --selector, --network-idle, --dom-stable');
|
|
443
659
|
},
|
|
444
660
|
|
|
445
661
|
evaluate: async ({ js, allFrames }) => {
|
|
@@ -643,22 +859,158 @@ const handlers = {
|
|
|
643
859
|
|
|
644
860
|
assert: async (params) => {
|
|
645
861
|
const failures = [];
|
|
646
|
-
|
|
647
|
-
|
|
862
|
+
|
|
863
|
+
// F2.3 — selector-based element resolution. Either --ref OR --selector
|
|
864
|
+
// can locate the element; if both given, --selector wins (more stable
|
|
865
|
+
// across re-renders, more typical for E2E).
|
|
866
|
+
const resolveEl = () => {
|
|
867
|
+
if (params.selector) {
|
|
868
|
+
const el = document.querySelector(params.selector);
|
|
869
|
+
if (!el) throw new Error(`No element matching selector "${params.selector}"`);
|
|
870
|
+
return el;
|
|
871
|
+
}
|
|
872
|
+
if (params.ref) return findByRef(params.ref);
|
|
873
|
+
throw new Error('assert requires --ref or --selector for element-level checks');
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const needsElement = params.visible !== undefined || params.text !== undefined ||
|
|
877
|
+
params.checked !== undefined || params.disabled !== undefined ||
|
|
878
|
+
params.attr !== undefined;
|
|
879
|
+
let el = null;
|
|
880
|
+
if (needsElement) {
|
|
881
|
+
try { el = resolveEl(); }
|
|
882
|
+
catch (e) { failures.push(e.message); }
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (el && params.visible !== undefined) {
|
|
648
886
|
const vis = isVisible(el);
|
|
649
|
-
if (params.visible && !vis) failures.push(`Element ${params.ref}
|
|
650
|
-
if (!params.visible && vis) failures.push(`Element ${params.ref}
|
|
887
|
+
if (params.visible && !vis) failures.push(`Element is not visible (${params.selector || params.ref})`);
|
|
888
|
+
if (!params.visible && vis) failures.push(`Element is visible (${params.selector || params.ref})`);
|
|
651
889
|
}
|
|
652
|
-
if (params.text !== undefined) {
|
|
653
|
-
const el = findByRef(params.ref);
|
|
890
|
+
if (el && params.text !== undefined) {
|
|
654
891
|
const actual = el.textContent?.trim() || '';
|
|
655
892
|
if (!actual.includes(params.text)) failures.push(`Expected text "${params.text}", got "${actual.slice(0, 100)}"`);
|
|
656
893
|
}
|
|
657
|
-
if (
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (
|
|
661
|
-
|
|
894
|
+
if (el && params.checked !== undefined) {
|
|
895
|
+
if (el.checked !== params.checked) failures.push(`Expected checked=${params.checked}, got ${el.checked}`);
|
|
896
|
+
}
|
|
897
|
+
if (el && params.disabled !== undefined) {
|
|
898
|
+
const d = el.disabled || el.getAttribute('aria-disabled') === 'true';
|
|
899
|
+
if (d !== params.disabled) failures.push(`Expected disabled=${params.disabled}, got ${d}`);
|
|
900
|
+
}
|
|
901
|
+
if (el && params.attr !== undefined) {
|
|
902
|
+
// --attr "key=value" — split on first '='
|
|
903
|
+
const eq = params.attr.indexOf('=');
|
|
904
|
+
if (eq < 0) failures.push(`--attr must be "key=value" form, got "${params.attr}"`);
|
|
905
|
+
else {
|
|
906
|
+
const k = params.attr.slice(0, eq).trim();
|
|
907
|
+
const v = params.attr.slice(eq + 1);
|
|
908
|
+
const got = el.getAttribute(k);
|
|
909
|
+
if (got !== v) failures.push(`Expected attr ${k}="${v}", got ${got === null ? 'null' : `"${got}"`}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (params.url) {
|
|
914
|
+
const pat = params.url.includes('*') ? new RegExp('^' + params.url.replace(/\*/g, '.*') + '$') : null;
|
|
915
|
+
const m = pat ? pat.test(location.href) : location.href.includes(params.url);
|
|
916
|
+
if (!m) failures.push(`URL "${location.href}" does not match "${params.url}"`);
|
|
917
|
+
}
|
|
918
|
+
if (params.title) {
|
|
919
|
+
if (!document.title.includes(params.title)) failures.push(`Title "${document.title}" does not match "${params.title}"`);
|
|
920
|
+
}
|
|
921
|
+
if (params.consoleNoErrors) {
|
|
922
|
+
const errs = consoleBuffer.filter(m => m.level === 'error');
|
|
923
|
+
if (errs.length) failures.push(`Found ${errs.length} console errors: ${errs.map(e => e.text).join('; ').slice(0, 200)}`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// F2.4 — assert a matching request occurred in networkBuffer.
|
|
927
|
+
// --network --method M --url U --status S [--since <epoch ms>]
|
|
928
|
+
// `--url` matches as a substring (or with `*` glob).
|
|
929
|
+
// `--status` may be a literal int, "2xx", "4xx", etc.
|
|
930
|
+
// `--since` filters to entries with startTime >= since.
|
|
931
|
+
if (params.network) {
|
|
932
|
+
const sinceMs = params.since ? Number(params.since) : 0;
|
|
933
|
+
const urlPat = params.url
|
|
934
|
+
? (params.url.includes('*')
|
|
935
|
+
? new RegExp('^' + params.url.replace(/\*/g, '.*') + '$')
|
|
936
|
+
: null)
|
|
937
|
+
: null;
|
|
938
|
+
const methodU = params.method ? params.method.toUpperCase() : null;
|
|
939
|
+
const statusCheck = (s) => {
|
|
940
|
+
if (params.status == null) return true;
|
|
941
|
+
const want = String(params.status);
|
|
942
|
+
if (/^\d+$/.test(want)) return s === Number(want);
|
|
943
|
+
if (/^\dxx$/.test(want)) return s >= Number(want[0]) * 100 && s < (Number(want[0]) + 1) * 100;
|
|
944
|
+
return false;
|
|
945
|
+
};
|
|
946
|
+
const matches = networkBuffer.filter(e =>
|
|
947
|
+
(!sinceMs || (e.startTime || 0) >= sinceMs) &&
|
|
948
|
+
(!methodU || e.method === methodU) &&
|
|
949
|
+
(!params.url || (urlPat ? urlPat.test(e.url) : (e.url || '').includes(params.url))) &&
|
|
950
|
+
statusCheck(e.status)
|
|
951
|
+
);
|
|
952
|
+
if (!matches.length) {
|
|
953
|
+
const desc = [
|
|
954
|
+
params.method ? `method=${params.method}` : null,
|
|
955
|
+
params.url ? `url=${params.url}` : null,
|
|
956
|
+
params.status != null ? `status=${params.status}` : null,
|
|
957
|
+
sinceMs ? `since=${sinceMs}` : null,
|
|
958
|
+
].filter(Boolean).join(' ');
|
|
959
|
+
failures.push(`No matching request in networkBuffer (${desc}); buffer has ${networkBuffer.length} entries`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// F2.5 — assert backend by fetch + jsonpath compare.
|
|
964
|
+
// --fetch <url> [--method M] [--body B] [--fetch-status N]
|
|
965
|
+
// [--jsonpath P --equals V | --contains V | --not-contains V]
|
|
966
|
+
if (params.fetch) {
|
|
967
|
+
const init = { method: params.fetchMethod || 'GET', credentials: 'same-origin' };
|
|
968
|
+
init.headers = { 'Accept': 'application/json, text/plain, */*' };
|
|
969
|
+
if (params.body != null) {
|
|
970
|
+
init.body = typeof params.body === 'string' ? params.body : JSON.stringify(params.body);
|
|
971
|
+
init.headers['Content-Type'] = 'application/json';
|
|
972
|
+
}
|
|
973
|
+
let r, data, parseErr;
|
|
974
|
+
try {
|
|
975
|
+
r = await fetch(params.fetch, init);
|
|
976
|
+
const ct = r.headers.get('content-type') || '';
|
|
977
|
+
data = ct.includes('json') ? await r.json().catch(() => r.text()) : await r.text();
|
|
978
|
+
} catch (e) { parseErr = e.message; }
|
|
979
|
+
if (parseErr) {
|
|
980
|
+
failures.push(`assert --fetch ${params.fetch}: ${parseErr}`);
|
|
981
|
+
} else {
|
|
982
|
+
if (params.fetchStatus != null && r.status !== Number(params.fetchStatus)) {
|
|
983
|
+
failures.push(`assert --fetch ${params.fetch}: expected status ${params.fetchStatus}, got ${r.status}`);
|
|
984
|
+
}
|
|
985
|
+
if (params.jsonpath) {
|
|
986
|
+
let value;
|
|
987
|
+
try { value = simpleJsonPath(data, params.jsonpath); }
|
|
988
|
+
catch (e) { failures.push(`assert --fetch jsonpath ${params.jsonpath}: ${e.message}`); }
|
|
989
|
+
if (params.equals !== undefined) {
|
|
990
|
+
const want = params.equals;
|
|
991
|
+
const eq = Array.isArray(value)
|
|
992
|
+
? JSON.stringify(value) === JSON.stringify(want)
|
|
993
|
+
: value == want; // intentional loose equal for "5" == 5
|
|
994
|
+
if (!eq) failures.push(`assert --fetch jsonpath ${params.jsonpath}: expected ${JSON.stringify(want)}, got ${JSON.stringify(value)?.slice(0, 200)}`);
|
|
995
|
+
}
|
|
996
|
+
if (params.contains !== undefined) {
|
|
997
|
+
const want = params.contains;
|
|
998
|
+
const has = Array.isArray(value)
|
|
999
|
+
? value.some(v => v == want || (typeof v === 'string' && v.includes(String(want))))
|
|
1000
|
+
: (typeof value === 'string' ? value.includes(String(want)) : false);
|
|
1001
|
+
if (!has) failures.push(`assert --fetch jsonpath ${params.jsonpath}: expected to contain ${JSON.stringify(want)}, got ${JSON.stringify(value)?.slice(0, 200)}`);
|
|
1002
|
+
}
|
|
1003
|
+
if (params.notContains !== undefined) {
|
|
1004
|
+
const dont = params.notContains;
|
|
1005
|
+
const has = Array.isArray(value)
|
|
1006
|
+
? value.some(v => v == dont || (typeof v === 'string' && v.includes(String(dont))))
|
|
1007
|
+
: (typeof value === 'string' ? value.includes(String(dont)) : false);
|
|
1008
|
+
if (has) failures.push(`assert --fetch jsonpath ${params.jsonpath}: expected NOT to contain ${JSON.stringify(dont)}, got ${JSON.stringify(value)?.slice(0, 200)}`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
662
1014
|
return failures.length ? { pass: false, failures } : { pass: true };
|
|
663
1015
|
},
|
|
664
1016
|
|
|
@@ -681,8 +1033,271 @@ const handlers = {
|
|
|
681
1033
|
}
|
|
682
1034
|
return { error: `Unknown metric: ${metric}` };
|
|
683
1035
|
},
|
|
1036
|
+
|
|
1037
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1038
|
+
// F1.6 — fetch shortcut: GET/POST/… using the page's auth session.
|
|
1039
|
+
// Returns parsed JSON when Content-Type matches, otherwise text.
|
|
1040
|
+
// Optional --json '<path>' extracts a value via a tiny JSONPath subset:
|
|
1041
|
+
// $ whole document
|
|
1042
|
+
// $.key object property
|
|
1043
|
+
// $.a.b chained property
|
|
1044
|
+
// $[N] array index
|
|
1045
|
+
// $.a[0].b mixed
|
|
1046
|
+
// $[*].key map over array → array of values
|
|
1047
|
+
// No filters / wildcards beyond the above.
|
|
1048
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1049
|
+
fetch: async ({ url, method = 'GET', body = null, headers = null, json = null }) => {
|
|
1050
|
+
if (!url) throw new Error('fetch requires --url (or positional URL)');
|
|
1051
|
+
const init = { method, credentials: 'same-origin' };
|
|
1052
|
+
init.headers = { 'Accept': 'application/json, text/plain, */*' };
|
|
1053
|
+
if (headers && typeof headers === 'object') Object.assign(init.headers, headers);
|
|
1054
|
+
if (body != null) {
|
|
1055
|
+
if (typeof body === 'string') {
|
|
1056
|
+
init.body = body;
|
|
1057
|
+
if (!('Content-Type' in init.headers)) init.headers['Content-Type'] = 'application/json';
|
|
1058
|
+
} else {
|
|
1059
|
+
init.body = JSON.stringify(body);
|
|
1060
|
+
init.headers['Content-Type'] = 'application/json';
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
const r = await fetch(url, init);
|
|
1064
|
+
const ct = r.headers.get('content-type') || '';
|
|
1065
|
+
let data;
|
|
1066
|
+
if (ct.includes('json')) {
|
|
1067
|
+
try { data = await r.json(); }
|
|
1068
|
+
catch { data = await r.text(); }
|
|
1069
|
+
} else {
|
|
1070
|
+
data = await r.text();
|
|
1071
|
+
}
|
|
1072
|
+
const result = { status: r.status, ok: r.ok, contentType: ct };
|
|
1073
|
+
if (json) {
|
|
1074
|
+
result.jsonpath = json;
|
|
1075
|
+
result.value = simpleJsonPath(data, json);
|
|
1076
|
+
} else {
|
|
1077
|
+
result.data = data;
|
|
1078
|
+
}
|
|
1079
|
+
return result;
|
|
1080
|
+
},
|
|
1081
|
+
|
|
1082
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1083
|
+
// F2.6 — submit: form.requestSubmit() works on React-controlled forms
|
|
1084
|
+
// where dispatching a synthetic Enter key event is ignored.
|
|
1085
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1086
|
+
submit: async ({ formSelector } = {}) => {
|
|
1087
|
+
let form;
|
|
1088
|
+
if (formSelector) {
|
|
1089
|
+
form = document.querySelector(formSelector);
|
|
1090
|
+
if (!form) throw new Error(`No element matching form selector "${formSelector}"`);
|
|
1091
|
+
if (form.tagName !== 'FORM') {
|
|
1092
|
+
// allow passing a selector to a child — climb to nearest form ancestor.
|
|
1093
|
+
form = form.closest('form');
|
|
1094
|
+
if (!form) throw new Error(`Element "${formSelector}" is not a <form> and has no <form> ancestor`);
|
|
1095
|
+
}
|
|
1096
|
+
} else {
|
|
1097
|
+
const active = document.activeElement;
|
|
1098
|
+
form = active && active.closest ? active.closest('form') : null;
|
|
1099
|
+
if (!form) throw new Error('submit: no --form-selector given and document.activeElement is not inside a <form>');
|
|
1100
|
+
}
|
|
1101
|
+
if (typeof form.requestSubmit === 'function') form.requestSubmit();
|
|
1102
|
+
else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
1103
|
+
return { submitted: form.getAttribute('id') || form.getAttribute('name') || form.tagName, action: form.action || null };
|
|
1104
|
+
},
|
|
1105
|
+
|
|
1106
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1107
|
+
// F1.8 — deep health: probes the page itself. The cheap (non-blocking)
|
|
1108
|
+
// server-side health lives in src/lib/httpApi.ts and never round-trips
|
|
1109
|
+
// here. Use this only with --deep.
|
|
1110
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1111
|
+
health_deep: async () => ({
|
|
1112
|
+
extension: 'alive',
|
|
1113
|
+
page: {
|
|
1114
|
+
ts: Date.now(),
|
|
1115
|
+
readyState: document.readyState,
|
|
1116
|
+
url: window.location.href,
|
|
1117
|
+
title: document.title,
|
|
1118
|
+
pendingMicrotasks: 0, // best-effort; not directly observable
|
|
1119
|
+
snapshotEpoch,
|
|
1120
|
+
},
|
|
1121
|
+
}),
|
|
1122
|
+
|
|
1123
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1124
|
+
// F3.1 — reset: atomic test-isolation helper.
|
|
1125
|
+
// --cookies expire every cookie visible to JS for the current host
|
|
1126
|
+
// --storage clear localStorage + sessionStorage
|
|
1127
|
+
// --cache drop everything in the Cache Storage API
|
|
1128
|
+
// --reload reload the page after clearing (force-bypass cache)
|
|
1129
|
+
// Returns which steps actually ran (so AI can confirm).
|
|
1130
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1131
|
+
reset: async ({ cookies, storage, cache, reload } = {}) => {
|
|
1132
|
+
const cleared = [];
|
|
1133
|
+
const errors = [];
|
|
1134
|
+
if (cookies) {
|
|
1135
|
+
try {
|
|
1136
|
+
const host = location.hostname;
|
|
1137
|
+
const parents = [host];
|
|
1138
|
+
// Also try the eTLD+1 parent (e.g. "sub.example.com" → ".example.com")
|
|
1139
|
+
const parts = host.split('.');
|
|
1140
|
+
if (parts.length > 2) parents.push('.' + parts.slice(-2).join('.'));
|
|
1141
|
+
for (const c of document.cookie.split(';')) {
|
|
1142
|
+
const eq = c.indexOf('=');
|
|
1143
|
+
const name = (eq > -1 ? c.slice(0, eq) : c).trim();
|
|
1144
|
+
if (!name) continue;
|
|
1145
|
+
const dead = 'expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
1146
|
+
document.cookie = `${name}=; ${dead}; path=/`;
|
|
1147
|
+
for (const d of parents) {
|
|
1148
|
+
document.cookie = `${name}=; ${dead}; path=/; domain=${d}`;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
cleared.push('cookies');
|
|
1152
|
+
} catch (e) { errors.push(`cookies: ${e.message}`); }
|
|
1153
|
+
}
|
|
1154
|
+
if (storage) {
|
|
1155
|
+
try { localStorage.clear(); sessionStorage.clear(); cleared.push('storage'); }
|
|
1156
|
+
catch (e) { errors.push(`storage: ${e.message}`); }
|
|
1157
|
+
}
|
|
1158
|
+
if (cache) {
|
|
1159
|
+
try {
|
|
1160
|
+
if (typeof caches !== 'undefined' && caches.keys) {
|
|
1161
|
+
const keys = await caches.keys();
|
|
1162
|
+
await Promise.all(keys.map(k => caches.delete(k)));
|
|
1163
|
+
cleared.push(`cache (${keys.length} stores)`);
|
|
1164
|
+
} else {
|
|
1165
|
+
errors.push('cache: Cache Storage API unavailable');
|
|
1166
|
+
}
|
|
1167
|
+
} catch (e) { errors.push(`cache: ${e.message}`); }
|
|
1168
|
+
}
|
|
1169
|
+
if (reload) {
|
|
1170
|
+
// Defer the reload so the result can ship back before navigation tears
|
|
1171
|
+
// down the iframe runtime.
|
|
1172
|
+
setTimeout(() => location.reload(), 50);
|
|
1173
|
+
cleared.push('reload (scheduled)');
|
|
1174
|
+
}
|
|
1175
|
+
if (!cleared.length && !errors.length) {
|
|
1176
|
+
throw new Error('reset requires at least one of: --cookies, --storage, --cache, --reload');
|
|
1177
|
+
}
|
|
1178
|
+
return errors.length ? { cleared, errors } : { cleared };
|
|
1179
|
+
},
|
|
1180
|
+
|
|
1181
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1182
|
+
// F3.2 — status: one-stop "where am I" summary for AI orientation.
|
|
1183
|
+
// url, title, readyState, last console error, last failed request, top
|
|
1184
|
+
// visible actions. Keep it cheap; this is what AI runs after a long gap.
|
|
1185
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1186
|
+
status: async () => {
|
|
1187
|
+
const now = Date.now();
|
|
1188
|
+
const lastErr = consoleBuffer.filter(m => m.level === 'error').slice(-1)[0] || null;
|
|
1189
|
+
const lastFailed = networkBuffer.filter(r => typeof r.status === 'number' && r.status >= 400).slice(-1)[0] || null;
|
|
1190
|
+
const buttons = Array.from(document.querySelectorAll('button, [role="button"], a[role="button"], input[type="submit"]'))
|
|
1191
|
+
.filter(b => isVisible(b))
|
|
1192
|
+
.slice(0, 8);
|
|
1193
|
+
const topActions = buttons.map(b => {
|
|
1194
|
+
const t = (b.textContent || '').trim().replace(/\s+/g, ' ');
|
|
1195
|
+
const aria = b.getAttribute('aria-label');
|
|
1196
|
+
const label = aria || (t.length > 32 ? t.slice(0, 29) + '...' : t);
|
|
1197
|
+
return label;
|
|
1198
|
+
}).filter(Boolean);
|
|
1199
|
+
return {
|
|
1200
|
+
url: location.href,
|
|
1201
|
+
title: document.title,
|
|
1202
|
+
readyState: document.readyState,
|
|
1203
|
+
lastConsoleError: lastErr ? {
|
|
1204
|
+
text: (lastErr.text || '').slice(0, 200),
|
|
1205
|
+
ageSec: Math.round((now - lastErr.timestamp) / 1000),
|
|
1206
|
+
} : null,
|
|
1207
|
+
lastFailedRequest: lastFailed ? {
|
|
1208
|
+
method: lastFailed.method,
|
|
1209
|
+
url: lastFailed.url,
|
|
1210
|
+
status: lastFailed.status,
|
|
1211
|
+
ageSec: Math.round((now - (lastFailed.startTime || now)) / 1000),
|
|
1212
|
+
} : null,
|
|
1213
|
+
topActions,
|
|
1214
|
+
};
|
|
1215
|
+
},
|
|
1216
|
+
|
|
1217
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1218
|
+
// F3.4 — set: unified writer for cookie / localStorage / sessionStorage.
|
|
1219
|
+
// (Cookies are JS-visible only; HttpOnly cannot be set from page context.)
|
|
1220
|
+
// --type cookie + --name --value [--domain --path --secure
|
|
1221
|
+
// --samesite --expires]
|
|
1222
|
+
// --type local-storage + --name --value
|
|
1223
|
+
// --type session-storage + --name --value
|
|
1224
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1225
|
+
set: async ({ type, name, value, domain, path, secure, sameSite, expires } = {}) => {
|
|
1226
|
+
if (!type) throw new Error('set requires --type (one of: cookie, local-storage, session-storage)');
|
|
1227
|
+
if (!name) throw new Error('set requires --name');
|
|
1228
|
+
if (value === undefined) throw new Error('set requires --value');
|
|
1229
|
+
const v = typeof value === 'string' ? value : JSON.stringify(value);
|
|
1230
|
+
if (type === 'cookie') {
|
|
1231
|
+
let str = `${name}=${encodeURIComponent(v)}`;
|
|
1232
|
+
str += `; path=${path || '/'}`;
|
|
1233
|
+
if (domain) str += `; domain=${domain}`;
|
|
1234
|
+
if (secure) str += `; secure`;
|
|
1235
|
+
if (sameSite) str += `; samesite=${sameSite}`;
|
|
1236
|
+
if (expires) str += `; expires=${expires}`;
|
|
1237
|
+
document.cookie = str;
|
|
1238
|
+
// Verify (cookies set against a different domain are silently dropped)
|
|
1239
|
+
const verify = document.cookie.split(';').some(c => c.trim().startsWith(`${name}=`));
|
|
1240
|
+
return { set: 'cookie', name, verified: verify };
|
|
1241
|
+
}
|
|
1242
|
+
if (type === 'local-storage') { localStorage.setItem(name, v); return { set: 'local-storage', name, length: v.length }; }
|
|
1243
|
+
if (type === 'session-storage') { sessionStorage.setItem(name, v); return { set: 'session-storage', name, length: v.length }; }
|
|
1244
|
+
throw new Error(`set --type must be one of: cookie, local-storage, session-storage (got "${type}")`);
|
|
1245
|
+
},
|
|
1246
|
+
|
|
1247
|
+
// Internal — used by CLI-side post-verify (F1.7). Cheap state probe.
|
|
1248
|
+
probe_state: async () => {
|
|
1249
|
+
// domHash: cheap-ish CRC of the visible body innerHTML length + a small sample
|
|
1250
|
+
const html = document.body ? document.body.innerHTML : '';
|
|
1251
|
+
const sample = html.length > 4000
|
|
1252
|
+
? html.slice(0, 1000) + html.slice(html.length / 2, html.length / 2 + 1000) + html.slice(-1000)
|
|
1253
|
+
: html;
|
|
1254
|
+
let hash = 0;
|
|
1255
|
+
for (let i = 0; i < sample.length; i++) hash = ((hash << 5) - hash + sample.charCodeAt(i)) | 0;
|
|
1256
|
+
return {
|
|
1257
|
+
url: window.location.href,
|
|
1258
|
+
domLen: html.length,
|
|
1259
|
+
domHash: hash,
|
|
1260
|
+
lastNetworkId: networkBuffer.length ? networkBuffer[networkBuffer.length - 1].id : 0,
|
|
1261
|
+
ts: Date.now(),
|
|
1262
|
+
};
|
|
1263
|
+
},
|
|
684
1264
|
};
|
|
685
1265
|
|
|
1266
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1267
|
+
// Simple JSONPath extractor — supports only: $, .key, [N], [*]
|
|
1268
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1269
|
+
function simpleJsonPath(data, path) {
|
|
1270
|
+
if (typeof path !== 'string' || !path.startsWith('$')) {
|
|
1271
|
+
throw new Error(`Invalid jsonpath "${path}" — must start with $`);
|
|
1272
|
+
}
|
|
1273
|
+
let rest = path.slice(1);
|
|
1274
|
+
let current = [data];
|
|
1275
|
+
while (rest.length) {
|
|
1276
|
+
if (rest.startsWith('.')) {
|
|
1277
|
+
const m = rest.match(/^\.([A-Za-z_$][\w$]*)/);
|
|
1278
|
+
if (!m) throw new Error(`Invalid jsonpath segment near "${rest}"`);
|
|
1279
|
+
const key = m[1];
|
|
1280
|
+
current = current.flatMap(v => (v != null && typeof v === 'object' && key in v) ? [v[key]] : []);
|
|
1281
|
+
rest = rest.slice(m[0].length);
|
|
1282
|
+
} else if (rest.startsWith('[')) {
|
|
1283
|
+
const m = rest.match(/^\[(\*|\d+)\]/);
|
|
1284
|
+
if (!m) throw new Error(`Invalid jsonpath bracket near "${rest}"`);
|
|
1285
|
+
if (m[1] === '*') {
|
|
1286
|
+
current = current.flatMap(v => Array.isArray(v) ? v : (v != null && typeof v === 'object' ? Object.values(v) : []));
|
|
1287
|
+
} else {
|
|
1288
|
+
const idx = Number(m[1]);
|
|
1289
|
+
current = current.flatMap(v => Array.isArray(v) && idx < v.length ? [v[idx]] : []);
|
|
1290
|
+
}
|
|
1291
|
+
rest = rest.slice(m[0].length);
|
|
1292
|
+
} else {
|
|
1293
|
+
throw new Error(`Invalid jsonpath suffix "${rest}"`);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
// If the path involved no wildcard, return scalar; otherwise array.
|
|
1297
|
+
if (path.includes('[*]')) return current;
|
|
1298
|
+
return current.length ? current[0] : undefined;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
686
1301
|
// ============================================================================
|
|
687
1302
|
// 命令分发
|
|
688
1303
|
// ============================================================================
|
|
@@ -695,7 +1310,17 @@ function handleCommand(event) {
|
|
|
695
1310
|
const handler = handlers[action];
|
|
696
1311
|
|
|
697
1312
|
if (!handler) {
|
|
698
|
-
|
|
1313
|
+
// Filter internal-use actions from "Did you mean" suggestions.
|
|
1314
|
+
const INTERNAL_ACTIONS = new Set(['probe_state', 'evaluate_chunk']);
|
|
1315
|
+
const known = Object.keys(handlers).filter(k => !k.startsWith('_') && !INTERNAL_ACTIONS.has(k));
|
|
1316
|
+
const suggestions = fuzzyTopK(action, known, 3);
|
|
1317
|
+
const hint = suggestions.length ? `\n Did you mean: ${suggestions.join(', ')}?` : '';
|
|
1318
|
+
_realParent.postMessage({
|
|
1319
|
+
type: 'cockpit:cmd-result',
|
|
1320
|
+
reqId,
|
|
1321
|
+
ok: false,
|
|
1322
|
+
error: `Unknown action "${action}".${hint}\n Run: cockpit browser --help-all`,
|
|
1323
|
+
}, '*');
|
|
699
1324
|
return;
|
|
700
1325
|
}
|
|
701
1326
|
|
|
@@ -704,6 +1329,33 @@ function handleCommand(event) {
|
|
|
704
1329
|
.catch(err => _realParent.postMessage({ type: 'cockpit:cmd-result', reqId, ok: false, error: err.message || String(err) }, '*'));
|
|
705
1330
|
}
|
|
706
1331
|
|
|
1332
|
+
// Fuzzy ranking — surfaces "evluate" → "evaluate" while filtering unrelated
|
|
1333
|
+
// candidates. Uses character-bag overlap ratio (multiset intersection /
|
|
1334
|
+
// max length); substring matches get a bonus. Threshold 0.6.
|
|
1335
|
+
function fuzzyTopK(input, candidates, k) {
|
|
1336
|
+
if (!input) return [];
|
|
1337
|
+
const inputLow = input.toLowerCase();
|
|
1338
|
+
const bag = bagCounts(inputLow);
|
|
1339
|
+
const scored = candidates.map(c => {
|
|
1340
|
+
const cl = c.toLowerCase();
|
|
1341
|
+
const cBag = bagCounts(cl);
|
|
1342
|
+
let inter = 0;
|
|
1343
|
+
for (const [ch, n] of bag) inter += Math.min(n, cBag.get(ch) || 0);
|
|
1344
|
+
const ratio = inter / Math.max(inputLow.length, cl.length);
|
|
1345
|
+
const startsWith = cl.startsWith(inputLow) ? 0.5 : 0;
|
|
1346
|
+
const contains = cl.includes(inputLow) ? 0.3 : 0;
|
|
1347
|
+
return { c, score: ratio + startsWith + contains };
|
|
1348
|
+
});
|
|
1349
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1350
|
+
return scored.slice(0, k).filter(s => s.score >= 0.6).map(s => s.c);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function bagCounts(s) {
|
|
1354
|
+
const m = new Map();
|
|
1355
|
+
for (const ch of s) m.set(ch, (m.get(ch) || 0) + 1);
|
|
1356
|
+
return m;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
707
1359
|
// ============================================================================
|
|
708
1360
|
// 导出初始化函数
|
|
709
1361
|
// ============================================================================
|