specpi 0.11.2 → 0.13.0
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/CHANGELOG.md +21 -1
- package/NPM_RELEASE.md +3 -1
- package/README.md +41 -29
- package/SECURITY_MODEL.md +36 -0
- package/THIRD_PARTY.md +18 -2
- package/docs/browser-testing.md +76 -0
- package/docs/delegation/README.md +264 -0
- package/docs/delegation/design-protocol.md +382 -0
- package/docs/delegation/design.md +525 -0
- package/docs/delegation/evaluation.md +307 -0
- package/docs/delegation/protocol.md +271 -0
- package/docs/delegation/research.md +216 -0
- package/extensions/browser/core.d.mts +64 -0
- package/extensions/browser/diagnostics.ts +275 -0
- package/extensions/browser/index.ts +349 -57
- package/extensions/browser/interactions.ts +118 -0
- package/extensions/browser/lifecycle.ts +28 -0
- package/extensions/command-guard/index.ts +118 -33
- package/extensions/delegation/core.mjs +772 -0
- package/extensions/delegation/errors.mjs +8 -0
- package/extensions/delegation/extension.mjs +475 -0
- package/extensions/delegation/index.ts +9 -0
- package/extensions/delegation/managed-files.mjs +13 -0
- package/extensions/delegation/native.mjs +155 -0
- package/extensions/delegation/presentation.mjs +315 -0
- package/extensions/delegation/protocol.mjs +296 -0
- package/extensions/delegation/provider.mjs +689 -0
- package/extensions/delegation/snapshot.mjs +532 -0
- package/extensions/delegation/worker.mjs +218 -0
- package/extensions/tool-wishlist/verification.mjs +1 -0
- package/extensions/workflow-controls/index.ts +5 -1
- package/package.json +17 -4
- package/scripts/check-package.mjs +29 -3
- package/scripts/check-pi-package.mjs +5 -0
- package/scripts/check-syntax.mjs +61 -0
- package/scripts/run-browser-tests.mjs +61 -0
- package/scripts/setup-browser-tests.mjs +38 -0
- package/scripts/site-browser.mjs +272 -0
- package/scripts/specpi.mjs +24 -0
- package/site/logo.svg +1 -9
- package/templates/AGENTS.md +1 -0
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Browser, BrowserContext, Page } from "playwright";
|
|
6
|
+
import { Check } from "typebox/value";
|
|
7
|
+
import { BrowserDiagnostics, DIAGNOSTIC_CATEGORIES } from "./diagnostics.ts";
|
|
8
|
+
import { BrowserCleanupError, settleBrowserCleanup } from "./lifecycle.ts";
|
|
9
|
+
import {
|
|
10
|
+
PressParams,
|
|
11
|
+
SelectionParams,
|
|
12
|
+
WaitParams,
|
|
13
|
+
interactionTimeout,
|
|
14
|
+
selectionOptions,
|
|
15
|
+
validateKey,
|
|
16
|
+
validateWait,
|
|
17
|
+
waitForCondition,
|
|
18
|
+
} from "./interactions.ts";
|
|
5
19
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
6
|
-
import { Type } from "typebox";
|
|
20
|
+
import { Type, type TSchema } from "typebox";
|
|
7
21
|
import {
|
|
8
22
|
MAX_CAPTURE_DIMENSION,
|
|
9
23
|
MAX_CAPTURE_PIXELS,
|
|
@@ -75,10 +89,25 @@ const CompareParams = Type.Object({
|
|
|
75
89
|
maxDiffPixelRatio: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
|
|
76
90
|
});
|
|
77
91
|
|
|
92
|
+
const DiagnosticsParams = Type.Object(
|
|
93
|
+
{
|
|
94
|
+
maxEntries: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
95
|
+
maxChars: Type.Optional(Type.Integer({ minimum: 1000, maximum: 30000 })),
|
|
96
|
+
category: Type.Optional(StringEnum(DIAGNOSTIC_CATEGORIES)),
|
|
97
|
+
cursor: Type.Optional(Type.String({ maxLength: 80 })),
|
|
98
|
+
clear: Type.Optional(
|
|
99
|
+
Type.Boolean({
|
|
100
|
+
description: "Atomically read then clear ALL retained records, including filtered/unreturned records.",
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
},
|
|
104
|
+
{ additionalProperties: false },
|
|
105
|
+
);
|
|
106
|
+
|
|
78
107
|
type BrowserState = {
|
|
79
|
-
browser?:
|
|
80
|
-
context?:
|
|
81
|
-
page?:
|
|
108
|
+
browser?: Browser;
|
|
109
|
+
context?: BrowserContext;
|
|
110
|
+
page?: Page;
|
|
82
111
|
runtime?: Awaited<ReturnType<typeof loadBrowserRuntime>>;
|
|
83
112
|
acceptedRefs: Set<string>;
|
|
84
113
|
assignedRefs: Set<string>;
|
|
@@ -105,6 +134,12 @@ function imageContent(file: string, data: Buffer, note: string) {
|
|
|
105
134
|
export default function browserExtension(pi: ExtensionAPI) {
|
|
106
135
|
const state: BrowserState = { acceptedRefs: new Set(), assignedRefs: new Set() };
|
|
107
136
|
let operationTail: Promise<unknown> = Promise.resolve();
|
|
137
|
+
const diagnostics = new BrowserDiagnostics();
|
|
138
|
+
let detachPageListeners: (() => void) | undefined;
|
|
139
|
+
let shutdownTask: Promise<void> | undefined;
|
|
140
|
+
let cleanupFailure: BrowserCleanupError | undefined;
|
|
141
|
+
const sessionAbort = new AbortController();
|
|
142
|
+
const lateDiscards = new Set<() => Promise<unknown>>();
|
|
108
143
|
|
|
109
144
|
function serialized<T>(operation: () => Promise<T>): Promise<T> {
|
|
110
145
|
const result = operationTail.then(operation, operation);
|
|
@@ -113,15 +148,51 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
113
148
|
return result;
|
|
114
149
|
}
|
|
115
150
|
|
|
116
|
-
|
|
151
|
+
function shutdownNow(): Promise<void> {
|
|
152
|
+
if (shutdownTask) {
|
|
153
|
+
return shutdownTask;
|
|
154
|
+
}
|
|
155
|
+
|
|
117
156
|
const context = state.context;
|
|
118
157
|
const browser = state.browser;
|
|
158
|
+
detachPageListeners?.();
|
|
159
|
+
detachPageListeners = undefined;
|
|
160
|
+
diagnostics.reset();
|
|
119
161
|
state.page = undefined;
|
|
120
162
|
state.context = undefined;
|
|
121
163
|
state.browser = undefined;
|
|
122
164
|
state.acceptedRefs.clear();
|
|
123
165
|
state.assignedRefs.clear();
|
|
124
|
-
|
|
166
|
+
// Defer one microtask so all nested abort handlers can register pending launch disposal.
|
|
167
|
+
const task = Promise.resolve().then(async () => {
|
|
168
|
+
const discards = [...lateDiscards];
|
|
169
|
+
try {
|
|
170
|
+
// Closing the browser closes its contexts too; concurrent closes race in Chromium.
|
|
171
|
+
await settleBrowserCleanup([
|
|
172
|
+
...(browser ? [browser.close()] : context ? [context.close()] : []),
|
|
173
|
+
...discards.map((discard) => discard()),
|
|
174
|
+
]);
|
|
175
|
+
for (const discard of discards) {
|
|
176
|
+
lateDiscards.delete(discard);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
cleanupFailure = undefined;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
cleanupFailure = error instanceof BrowserCleanupError ? error : new BrowserCleanupError();
|
|
182
|
+
// Retain only teardown handles for an explicit close retry, including late launch handles.
|
|
183
|
+
state.context = context;
|
|
184
|
+
state.browser = browser;
|
|
185
|
+
throw cleanupFailure;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
shutdownTask = task;
|
|
189
|
+
void task
|
|
190
|
+
.finally(() => {
|
|
191
|
+
shutdownTask = undefined;
|
|
192
|
+
})
|
|
193
|
+
.catch(() => {});
|
|
194
|
+
|
|
195
|
+
return task;
|
|
125
196
|
}
|
|
126
197
|
|
|
127
198
|
async function cancellable<T>(
|
|
@@ -130,6 +201,7 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
130
201
|
discard?: (value: T) => Promise<unknown>,
|
|
131
202
|
): Promise<T> {
|
|
132
203
|
if (signal?.aborted) {
|
|
204
|
+
await shutdownNow();
|
|
133
205
|
throw abortedError();
|
|
134
206
|
}
|
|
135
207
|
|
|
@@ -148,7 +220,8 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
148
220
|
|
|
149
221
|
settled = true;
|
|
150
222
|
if (discard && operation) {
|
|
151
|
-
|
|
223
|
+
const pending = operation;
|
|
224
|
+
lateDiscards.add(() => pending.then(discard, () => undefined));
|
|
152
225
|
}
|
|
153
226
|
|
|
154
227
|
void shutdownNow().then(() => reject(abortedError()), reject);
|
|
@@ -195,8 +268,17 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
195
268
|
});
|
|
196
269
|
}
|
|
197
270
|
|
|
198
|
-
async function ensurePage(signal?: AbortSignal) {
|
|
271
|
+
async function ensurePage(signal?: AbortSignal): Promise<Page> {
|
|
272
|
+
if (shutdownTask) {
|
|
273
|
+
await shutdownTask;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (cleanupFailure) {
|
|
277
|
+
throw cleanupFailure;
|
|
278
|
+
}
|
|
279
|
+
|
|
199
280
|
if (signal?.aborted) {
|
|
281
|
+
await shutdownNow();
|
|
200
282
|
throw abortedError();
|
|
201
283
|
}
|
|
202
284
|
|
|
@@ -204,17 +286,17 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
204
286
|
state.runtime = await cancellable(() => loadBrowserRuntime(runtimeDir), signal);
|
|
205
287
|
}
|
|
206
288
|
|
|
207
|
-
if (!state.browser?.isConnected()) {
|
|
289
|
+
if (!state.browser?.isConnected() || !state.page || state.page.isClosed()) {
|
|
208
290
|
await shutdownNow();
|
|
209
291
|
try {
|
|
210
292
|
state.browser = await cancellable(
|
|
211
293
|
() => state.runtime!.playwright.chromium.launch({ headless: true }),
|
|
212
294
|
signal,
|
|
213
|
-
(browser
|
|
295
|
+
(browser) => browser.close(),
|
|
214
296
|
);
|
|
215
297
|
state.context = await cancellable(
|
|
216
298
|
() =>
|
|
217
|
-
state.browser
|
|
299
|
+
state.browser!.newContext({
|
|
218
300
|
viewport: resolveViewport({ preset: "desktop" }),
|
|
219
301
|
reducedMotion: "reduce",
|
|
220
302
|
colorScheme: "light",
|
|
@@ -222,22 +304,37 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
222
304
|
}),
|
|
223
305
|
signal,
|
|
224
306
|
);
|
|
225
|
-
state.page = await cancellable(() => state.context
|
|
307
|
+
state.page = await cancellable(() => state.context!.newPage(), signal);
|
|
308
|
+
const page = state.page;
|
|
309
|
+
const detachDiagnostics = diagnostics.attach(page);
|
|
310
|
+
const navigated = (frame: import("playwright").Frame) => {
|
|
311
|
+
if (frame === page.mainFrame()) {
|
|
312
|
+
state.acceptedRefs.clear();
|
|
313
|
+
state.assignedRefs.clear();
|
|
314
|
+
diagnostics.navigated();
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
page.on("framenavigated", navigated);
|
|
319
|
+
detachPageListeners = () => {
|
|
320
|
+
detachDiagnostics();
|
|
321
|
+
page.off("framenavigated", navigated);
|
|
322
|
+
};
|
|
226
323
|
} catch (error) {
|
|
227
324
|
await shutdownNow();
|
|
228
325
|
throw error;
|
|
229
326
|
}
|
|
230
327
|
}
|
|
231
328
|
|
|
232
|
-
return state.page
|
|
329
|
+
return state.page!;
|
|
233
330
|
}
|
|
234
331
|
|
|
235
|
-
async function stabilize(page:
|
|
332
|
+
async function stabilize(page: Page, signal?: AbortSignal) {
|
|
236
333
|
await cancellable(() => page.addStyleTag({ content: deterministicStyle }), signal);
|
|
237
334
|
await cancellable(() => page.evaluate(() => document.fonts?.ready), signal);
|
|
238
335
|
}
|
|
239
336
|
|
|
240
|
-
async function assertCaptureBounds(page:
|
|
337
|
+
async function assertCaptureBounds(page: Page, fullPage: boolean, signal?: AbortSignal) {
|
|
241
338
|
const dimensions = fullPage
|
|
242
339
|
? await cancellable(
|
|
243
340
|
() =>
|
|
@@ -293,7 +390,7 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
293
390
|
: makeArtifactPath(agentDir, process.env.PI_SESSION_ID, kind);
|
|
294
391
|
}
|
|
295
392
|
|
|
296
|
-
function targetLocator(page:
|
|
393
|
+
function targetLocator(page: Page, target: string) {
|
|
297
394
|
const value = target.trim();
|
|
298
395
|
if (value.startsWith("@spec-")) {
|
|
299
396
|
if (!state.acceptedRefs.has(value)) {
|
|
@@ -310,15 +407,105 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
310
407
|
return page.locator(value);
|
|
311
408
|
}
|
|
312
409
|
|
|
313
|
-
const register = (definition:
|
|
410
|
+
const register = <T extends TSchema>(definition: ToolDefinition<T, unknown>) => {
|
|
314
411
|
const execute = definition.execute;
|
|
315
412
|
pi.registerTool({
|
|
316
413
|
...definition,
|
|
317
414
|
executionMode: "sequential",
|
|
318
|
-
execute
|
|
415
|
+
async execute(...args) {
|
|
416
|
+
if (!Check(definition.parameters, args[1])) {
|
|
417
|
+
throw new Error("Invalid browser tool parameters.");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const bounded = ["browser_press", "browser_select_option", "browser_wait_for"].includes(
|
|
421
|
+
definition.name,
|
|
422
|
+
);
|
|
423
|
+
const controller = new AbortController();
|
|
424
|
+
const originalSignal = args[2];
|
|
425
|
+
const signal = AbortSignal.any([
|
|
426
|
+
sessionAbort.signal,
|
|
427
|
+
controller.signal,
|
|
428
|
+
...(originalSignal ? [originalSignal] : []),
|
|
429
|
+
]);
|
|
430
|
+
const timer = bounded
|
|
431
|
+
? setTimeout(
|
|
432
|
+
() => controller.abort(),
|
|
433
|
+
interactionTimeout((args[1] as { timeoutMs?: number }).timeoutMs),
|
|
434
|
+
)
|
|
435
|
+
: undefined;
|
|
436
|
+
try {
|
|
437
|
+
return await cancellable(
|
|
438
|
+
() =>
|
|
439
|
+
serialized(() => {
|
|
440
|
+
if (signal.aborted) {
|
|
441
|
+
throw abortedError();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return execute(args[0], args[1], signal, args[3], args[4]);
|
|
445
|
+
}),
|
|
446
|
+
signal,
|
|
447
|
+
);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
if (error instanceof BrowserCleanupError) {
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (controller.signal.aborted && !originalSignal?.aborted) {
|
|
454
|
+
throw new Error("Browser condition/action timed out before its deadline.");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
throw error;
|
|
458
|
+
} finally {
|
|
459
|
+
clearTimeout(timer);
|
|
460
|
+
}
|
|
461
|
+
},
|
|
319
462
|
});
|
|
320
463
|
};
|
|
321
464
|
|
|
465
|
+
async function interaction<T>(
|
|
466
|
+
timeout: number,
|
|
467
|
+
signal: AbortSignal | undefined,
|
|
468
|
+
operation: (page: Page, signal: AbortSignal) => Promise<T>,
|
|
469
|
+
): Promise<T> {
|
|
470
|
+
const controller = new AbortController();
|
|
471
|
+
let expired = false;
|
|
472
|
+
const abort = () => controller.abort();
|
|
473
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
474
|
+
if (signal?.aborted) {
|
|
475
|
+
controller.abort();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const timer = setTimeout(() => {
|
|
479
|
+
expired = true;
|
|
480
|
+
controller.abort();
|
|
481
|
+
}, timeout);
|
|
482
|
+
try {
|
|
483
|
+
const page = await ensurePage(controller.signal);
|
|
484
|
+
|
|
485
|
+
return await cancellable(() => operation(page, controller.signal), controller.signal);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (error instanceof BrowserCleanupError) {
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Never expose Playwright call logs: these can echo input values, selectors, or URLs.
|
|
492
|
+
if (expired || (error instanceof Error && error.name === "TimeoutError")) {
|
|
493
|
+
throw new Error("Browser condition/action timed out before its deadline.");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (signal?.aborted) {
|
|
497
|
+
throw abortedError();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
throw new Error(
|
|
501
|
+
"Browser action failed. Check the target, state, key/options, and runtime setup; refresh the snapshot if refs are stale.",
|
|
502
|
+
);
|
|
503
|
+
} finally {
|
|
504
|
+
clearTimeout(timer);
|
|
505
|
+
signal?.removeEventListener("abort", abort);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
322
509
|
register({
|
|
323
510
|
name: "browser_open",
|
|
324
511
|
label: "Browser Open",
|
|
@@ -327,13 +514,13 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
327
514
|
promptSnippet: "Open local or remote web pages in an isolated browser for rendered validation",
|
|
328
515
|
promptGuidelines: ["Use browser_open and browser_snapshot before claiming a web UI renders correctly."],
|
|
329
516
|
parameters: OpenParams,
|
|
330
|
-
async execute(_id
|
|
517
|
+
async execute(_id, params, signal) {
|
|
331
518
|
const page = await ensurePage(signal);
|
|
519
|
+
state.acceptedRefs.clear();
|
|
332
520
|
const response = await cancellable(
|
|
333
521
|
() => page.goto(normalizeBrowserUrl(params.url), { waitUntil: params.waitUntil ?? "domcontentloaded" }),
|
|
334
522
|
signal,
|
|
335
523
|
);
|
|
336
|
-
state.acceptedRefs.clear();
|
|
337
524
|
const title = await cancellable(() => page.title(), signal);
|
|
338
525
|
|
|
339
526
|
return {
|
|
@@ -353,11 +540,11 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
353
540
|
label: "Browser Viewport",
|
|
354
541
|
description: "Set the active browser viewport to desktop, tablet, mobile, or bounded explicit dimensions.",
|
|
355
542
|
parameters: ViewportParams,
|
|
356
|
-
async execute(_id
|
|
543
|
+
async execute(_id, params, signal) {
|
|
357
544
|
const page = await ensurePage(signal);
|
|
358
545
|
const viewport = resolveViewport(params);
|
|
359
|
-
await cancellable(() => page.setViewportSize(viewport), signal);
|
|
360
546
|
state.acceptedRefs.clear();
|
|
547
|
+
await cancellable(() => page.setViewportSize(viewport), signal);
|
|
361
548
|
|
|
362
549
|
return {
|
|
363
550
|
content: [{ type: "text", text: `Viewport set to ${viewport.width}x${viewport.height}.` }],
|
|
@@ -370,10 +557,10 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
370
557
|
name: "browser_snapshot",
|
|
371
558
|
label: "Browser Snapshot",
|
|
372
559
|
description:
|
|
373
|
-
"Inspect bounded rendered page text and interactive elements. Returns namespaced refs usable by
|
|
560
|
+
"Inspect bounded rendered page text and interactive elements. Returns namespaced refs usable by click, fill, press, select_option, and wait_for browser tools.",
|
|
374
561
|
promptSnippet: "Inspect rendered DOM text and interactive controls",
|
|
375
562
|
parameters: SnapshotParams,
|
|
376
|
-
async execute(_id
|
|
563
|
+
async execute(_id, params, signal) {
|
|
377
564
|
const page = await ensurePage(signal);
|
|
378
565
|
const maxChars = params.maxChars ?? 30000;
|
|
379
566
|
const namespace = crypto.randomUUID();
|
|
@@ -381,10 +568,10 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
381
568
|
const snapshot = await cancellable(
|
|
382
569
|
() =>
|
|
383
570
|
page.evaluate(
|
|
384
|
-
({ namespace, bodyLimit, priorRefs }
|
|
571
|
+
({ namespace, bodyLimit, priorRefs }) => {
|
|
385
572
|
const prior = new Set(priorRefs);
|
|
386
573
|
document.querySelectorAll("[data-specpi-ref]").forEach((element) => {
|
|
387
|
-
if (prior.has(element.getAttribute("data-specpi-ref"))) {
|
|
574
|
+
if (prior.has(element.getAttribute("data-specpi-ref") ?? "")) {
|
|
388
575
|
element.removeAttribute("data-specpi-ref");
|
|
389
576
|
}
|
|
390
577
|
});
|
|
@@ -500,8 +687,8 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
500
687
|
snapshot.controls.pop();
|
|
501
688
|
}
|
|
502
689
|
|
|
503
|
-
state.assignedRefs = new Set(snapshot.controls.map((control
|
|
504
|
-
state.acceptedRefs = new Set(snapshot.controls.map((control
|
|
690
|
+
state.assignedRefs = new Set(snapshot.controls.map((control) => control.ref.slice(1)));
|
|
691
|
+
state.acceptedRefs = new Set(snapshot.controls.map((control) => control.ref));
|
|
505
692
|
const originalTextLength = snapshot.text.length;
|
|
506
693
|
const fixed = fixedJson();
|
|
507
694
|
snapshot.text = safePrefix(snapshot.text, maxChars - fixed.length - 40);
|
|
@@ -540,10 +727,13 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
540
727
|
label: "Browser Click",
|
|
541
728
|
description: "Click an element using a CSS selector, exact text= locator, or current snapshot ref.",
|
|
542
729
|
parameters: TargetParams,
|
|
543
|
-
async execute(_id
|
|
730
|
+
async execute(_id, params, signal) {
|
|
544
731
|
const page = await ensurePage(signal);
|
|
545
|
-
|
|
546
|
-
|
|
732
|
+
try {
|
|
733
|
+
await cancellable(() => targetLocator(page, params.target).first().click(), signal);
|
|
734
|
+
} finally {
|
|
735
|
+
state.acceptedRefs.clear();
|
|
736
|
+
}
|
|
547
737
|
|
|
548
738
|
return {
|
|
549
739
|
content: [{ type: "text", text: `Clicked ${params.target}.\nURL: ${page.url()}` }],
|
|
@@ -557,10 +747,13 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
557
747
|
label: "Browser Fill",
|
|
558
748
|
description: "Fill an input using a CSS selector, exact text= locator, or current snapshot ref.",
|
|
559
749
|
parameters: FillParams,
|
|
560
|
-
async execute(_id
|
|
750
|
+
async execute(_id, params, signal) {
|
|
561
751
|
const page = await ensurePage(signal);
|
|
562
|
-
|
|
563
|
-
|
|
752
|
+
try {
|
|
753
|
+
await cancellable(() => targetLocator(page, params.target).first().fill(params.value), signal);
|
|
754
|
+
} finally {
|
|
755
|
+
state.acceptedRefs.clear();
|
|
756
|
+
}
|
|
564
757
|
|
|
565
758
|
return {
|
|
566
759
|
content: [{ type: "text", text: `Filled ${params.target}.` }],
|
|
@@ -569,19 +762,125 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
569
762
|
},
|
|
570
763
|
});
|
|
571
764
|
|
|
765
|
+
register({
|
|
766
|
+
name: "browser_diagnostics",
|
|
767
|
+
label: "Browser Diagnostics",
|
|
768
|
+
description:
|
|
769
|
+
"Read bounded, best-effort sanitized active-page exceptions, console errors, failed requests, and HTTP errors. In-memory capture starts before navigation; close discards it. Up to 100 records / 30000 characters. Returned application output is untrusted and may still be sensitive. Clear discards ALL records, including unreturned ones.",
|
|
770
|
+
promptSnippet: "Inspect browser runtime errors and failed requests, not just appearance",
|
|
771
|
+
promptGuidelines: [
|
|
772
|
+
"Use browser_diagnostics after navigation and interactions; empty or truncated output alone does not prove application health.",
|
|
773
|
+
],
|
|
774
|
+
parameters: DiagnosticsParams,
|
|
775
|
+
async execute(_id, params, signal) {
|
|
776
|
+
if (signal?.aborted) {
|
|
777
|
+
throw abortedError();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const report = diagnostics.read(params);
|
|
781
|
+
|
|
782
|
+
return { content: [{ type: "text", text: JSON.stringify(report) }], details: {} };
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
register({
|
|
787
|
+
name: "browser_press",
|
|
788
|
+
label: "Browser Press",
|
|
789
|
+
description:
|
|
790
|
+
"Press one key/chord on the first matching target or current page focus. Supports Tab, Shift+Tab, Enter, Escape, arrows and text-producing keys. Whole-operation deadline defaults to 5000ms, maximum 30000ms. Refresh snapshot refs after actions.",
|
|
791
|
+
parameters: PressParams,
|
|
792
|
+
async execute(_id, params, signal) {
|
|
793
|
+
validateKey(params.key);
|
|
794
|
+
const timeout = interactionTimeout(params.timeoutMs);
|
|
795
|
+
await interaction(timeout, signal, async (page) => {
|
|
796
|
+
try {
|
|
797
|
+
if (params.target !== undefined) {
|
|
798
|
+
await targetLocator(page, params.target).first().press(params.key, { timeout });
|
|
799
|
+
} else {
|
|
800
|
+
await page.keyboard.press(params.key);
|
|
801
|
+
}
|
|
802
|
+
} finally {
|
|
803
|
+
state.acceptedRefs.clear();
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
content: [{ type: "text", text: "Key action completed; inspect the resulting page state." }],
|
|
809
|
+
details: {},
|
|
810
|
+
};
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
register({
|
|
815
|
+
name: "browser_select_option",
|
|
816
|
+
label: "Browser Select Option",
|
|
817
|
+
description:
|
|
818
|
+
"Select native select options on the first matching target, by exactly one value, label, or index per option. Up to 50 options. Not for custom dropdowns. Deadline defaults to 5000ms, maximum 30000ms; refresh snapshot refs afterward.",
|
|
819
|
+
parameters: SelectionParams,
|
|
820
|
+
async execute(_id, params, signal) {
|
|
821
|
+
const options = selectionOptions(params);
|
|
822
|
+
const timeout = interactionTimeout(params.timeoutMs);
|
|
823
|
+
const count = await interaction(timeout, signal, async (page) => {
|
|
824
|
+
try {
|
|
825
|
+
const target = targetLocator(page, params.target).first();
|
|
826
|
+
if (options.length > 1) {
|
|
827
|
+
const multiple = await target.evaluate(
|
|
828
|
+
(element) => element instanceof HTMLSelectElement && element.multiple,
|
|
829
|
+
undefined,
|
|
830
|
+
{ timeout },
|
|
831
|
+
);
|
|
832
|
+
if (!multiple) {
|
|
833
|
+
throw new Error("Multiple options require a native multiple select.");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const selected = await target.selectOption(options, { timeout });
|
|
838
|
+
|
|
839
|
+
return selected.length;
|
|
840
|
+
} finally {
|
|
841
|
+
state.acceptedRefs.clear();
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
content: [{ type: "text", text: `Selected ${count} option(s); inspect the resulting page state.` }],
|
|
847
|
+
details: { count },
|
|
848
|
+
};
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
register({
|
|
853
|
+
name: "browser_wait_for",
|
|
854
|
+
label: "Browser Wait For",
|
|
855
|
+
description:
|
|
856
|
+
"Wait for one explicit state: first matching target attached/detached/visible/hidden, exact target text, or exact normalized HTTP(S) URL. No scripts or sleep mode. Whole-operation deadline defaults to 5000ms, maximum 30000ms. Timeout is not success.",
|
|
857
|
+
parameters: WaitParams,
|
|
858
|
+
async execute(_id, params, signal) {
|
|
859
|
+
validateWait(params);
|
|
860
|
+
const timeout = interactionTimeout(params.timeoutMs);
|
|
861
|
+
await interaction(timeout, signal, (page) =>
|
|
862
|
+
waitForCondition(
|
|
863
|
+
page,
|
|
864
|
+
params.target ? targetLocator(page, params.target).first() : undefined,
|
|
865
|
+
params,
|
|
866
|
+
timeout,
|
|
867
|
+
),
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
content: [{ type: "text", text: `Observed requested ${params.condition} condition.` }],
|
|
872
|
+
details: { condition: params.condition },
|
|
873
|
+
};
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
|
|
572
877
|
register({
|
|
573
878
|
name: "browser_screenshot",
|
|
574
879
|
label: "Browser Screenshot",
|
|
575
880
|
description: "Capture a bounded rendered PNG and return it inline when conservatively sized.",
|
|
576
881
|
promptSnippet: "Capture rendered desktop, tablet, or mobile screenshots for visual QA",
|
|
577
882
|
parameters: ScreenshotParams,
|
|
578
|
-
async execute(
|
|
579
|
-
_id: string,
|
|
580
|
-
params: any,
|
|
581
|
-
signal: AbortSignal | undefined,
|
|
582
|
-
_update: unknown,
|
|
583
|
-
ctx: ExtensionContext,
|
|
584
|
-
) {
|
|
883
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
585
884
|
const shot = await captureMemory(signal, params.fullPage ?? false);
|
|
586
885
|
const file = outputPath(ctx, params.path, "screenshot");
|
|
587
886
|
await publishBuffer(file, shot.data, { overwrite: params.overwrite === true, signal });
|
|
@@ -605,13 +904,7 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
605
904
|
description:
|
|
606
905
|
"Explicitly create a visual-regression baseline PNG. Existing baselines are replaced only when overwrite=true.",
|
|
607
906
|
parameters: BaselineParams,
|
|
608
|
-
async execute(
|
|
609
|
-
_id: string,
|
|
610
|
-
params: any,
|
|
611
|
-
signal: AbortSignal | undefined,
|
|
612
|
-
_update: unknown,
|
|
613
|
-
ctx: ExtensionContext,
|
|
614
|
-
) {
|
|
907
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
615
908
|
const file = resolveUserPath(ctx.cwd, params.path, "baseline path");
|
|
616
909
|
const shot = await captureMemory(signal, params.fullPage ?? false);
|
|
617
910
|
await publishBuffer(file, shot.data, { overwrite: params.overwrite === true, signal });
|
|
@@ -630,13 +923,7 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
630
923
|
"Capture the current page and compare it with an explicit baseline PNG without changing the baseline.",
|
|
631
924
|
promptSnippet: "Compare rendered output against an explicit PNG baseline with a pixel threshold",
|
|
632
925
|
parameters: CompareParams,
|
|
633
|
-
async execute(
|
|
634
|
-
_id: string,
|
|
635
|
-
params: any,
|
|
636
|
-
signal: AbortSignal | undefined,
|
|
637
|
-
_update: unknown,
|
|
638
|
-
ctx: ExtensionContext,
|
|
639
|
-
) {
|
|
926
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
640
927
|
const baselinePath = resolveUserPath(ctx.cwd, params.baselinePath, "baseline path");
|
|
641
928
|
const currentPath = outputPath(ctx, params.currentPath, "current");
|
|
642
929
|
const diffPath = outputPath(ctx, params.diffPath, "diff");
|
|
@@ -719,5 +1006,10 @@ export default function browserExtension(pi: ExtensionAPI) {
|
|
|
719
1006
|
},
|
|
720
1007
|
});
|
|
721
1008
|
|
|
722
|
-
pi.on("session_shutdown", () =>
|
|
1009
|
+
pi.on("session_shutdown", () => {
|
|
1010
|
+
// Teardown preempts queued/active work, including waits without their own Playwright timeout.
|
|
1011
|
+
sessionAbort.abort();
|
|
1012
|
+
|
|
1013
|
+
return shutdownNow();
|
|
1014
|
+
});
|
|
723
1015
|
}
|