@aexol/spectral 0.9.19 → 0.9.20
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/extensions/browser/browser-service.d.ts +19 -28
- package/dist/extensions/browser/browser-service.d.ts.map +1 -1
- package/dist/extensions/browser/browser-service.js +117 -46
- package/dist/extensions/browser/config.d.ts +5 -10
- package/dist/extensions/browser/config.d.ts.map +1 -1
- package/dist/extensions/browser/config.js +2 -3
- package/dist/extensions/browser/index.d.ts.map +1 -1
- package/dist/extensions/browser/index.js +3 -0
- package/dist/extensions/browser/record/record-tool.d.ts +4 -0
- package/dist/extensions/browser/record/record-tool.d.ts.map +1 -0
- package/dist/extensions/browser/record/record-tool.js +134 -0
- package/package.json +1 -1
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BrowserService — singleton managing Playwright browser lifecycle.
|
|
3
|
-
*
|
|
4
|
-
* One browser instance, one persistent context, multiple pages (tabs).
|
|
5
|
-
* Collects console messages and network requests automatically.
|
|
6
|
-
* Follows the same singleton pattern as fetcher-mcp's BrowserService.
|
|
7
|
-
*
|
|
8
|
-
* Playwright is loaded lazily on first use (not at import time). This allows
|
|
9
|
-
* the module to load in standalone Bun binaries where playwright is unavailable.
|
|
10
|
-
* Tools will report a clear "Browser unavailable" error instead of crashing.
|
|
11
|
-
*/
|
|
12
1
|
import type { Browser, BrowserContext, Page } from "playwright";
|
|
13
2
|
import type { BrowserConfig } from "./config.js";
|
|
14
3
|
export interface NetworkRequest {
|
|
@@ -33,39 +22,29 @@ export declare class BrowserService {
|
|
|
33
22
|
private networkBuffer;
|
|
34
23
|
private nextNetworkIndex;
|
|
35
24
|
private pendingDialogs;
|
|
25
|
+
private _recording;
|
|
36
26
|
constructor(config?: Partial<BrowserConfig>);
|
|
37
|
-
/** Get or create the singleton instance. */
|
|
38
27
|
static getInstance(config?: Partial<BrowserConfig>): BrowserService;
|
|
39
|
-
/** Reset singleton (for testing). */
|
|
40
28
|
static resetInstance(): void;
|
|
41
|
-
/** Check whether Playwright is installed and loadable (static, does NOT start a browser). */
|
|
42
29
|
static isAvailable(): boolean;
|
|
43
|
-
/**
|
|
44
|
-
* Human-readable message explaining why the browser is unavailable.
|
|
45
|
-
* Returns null when the browser is available.
|
|
46
|
-
*/
|
|
47
30
|
static unavailabilityReason(): string | null;
|
|
48
|
-
/** Ensure browser, context, and at least one page are ready. */
|
|
49
31
|
ensureBrowser(): Promise<{
|
|
50
32
|
browser: Browser;
|
|
51
33
|
context: BrowserContext;
|
|
52
34
|
page: Page;
|
|
53
35
|
}>;
|
|
54
|
-
|
|
36
|
+
getBrowser(): Browser;
|
|
37
|
+
getActiveContext(): BrowserContext;
|
|
55
38
|
getActivePage(): Page;
|
|
56
|
-
/** Navigate to a URL on the active page. */
|
|
57
39
|
navigate(url: string): Promise<{
|
|
58
|
-
title: string;
|
|
59
40
|
url: string;
|
|
41
|
+
title: string;
|
|
60
42
|
}>;
|
|
61
|
-
/** Navigate back. */
|
|
62
43
|
navigateBack(): Promise<{
|
|
63
|
-
title: string;
|
|
64
44
|
url: string;
|
|
45
|
+
title: string;
|
|
65
46
|
}>;
|
|
66
|
-
/** Close the active page. If last page, create a blank one. */
|
|
67
47
|
closePage(): Promise<void>;
|
|
68
|
-
/** Resize the viewport. */
|
|
69
48
|
resize(width: number, height: number): Promise<void>;
|
|
70
49
|
listTabs(): Array<{
|
|
71
50
|
index: number;
|
|
@@ -95,9 +74,21 @@ export declare class BrowserService {
|
|
|
95
74
|
getNetworkRequests(): NetworkRequest[];
|
|
96
75
|
getNetworkRequest(index: number): NetworkRequest | undefined;
|
|
97
76
|
clearNetworkRequests(): void;
|
|
98
|
-
/** Close browser, context, and all pages. */
|
|
99
77
|
close(): Promise<void>;
|
|
100
|
-
|
|
78
|
+
startRecording(outputDir?: string): Promise<{
|
|
79
|
+
outputPath: string;
|
|
80
|
+
startedAt: number;
|
|
81
|
+
}>;
|
|
82
|
+
stopRecording(): Promise<{
|
|
83
|
+
outputPath: string;
|
|
84
|
+
durationSeconds: number;
|
|
85
|
+
}>;
|
|
86
|
+
getRecordStatus(): {
|
|
87
|
+
active: boolean;
|
|
88
|
+
outputPath?: string;
|
|
89
|
+
startedAt?: number;
|
|
90
|
+
elapsedSeconds?: number;
|
|
91
|
+
};
|
|
101
92
|
resetBuffers(): void;
|
|
102
93
|
getConfig(): BrowserConfig;
|
|
103
94
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser-service.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/browser-service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"browser-service.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/browser-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BrowserService — singleton managing Playwright browser lifecycle.
|
|
3
|
-
*
|
|
4
|
-
* One browser instance, one persistent context, multiple pages (tabs).
|
|
5
|
-
* Collects console messages and network requests automatically.
|
|
6
|
-
* Follows the same singleton pattern as fetcher-mcp's BrowserService.
|
|
7
|
-
*
|
|
8
|
-
* Playwright is loaded lazily on first use (not at import time). This allows
|
|
9
|
-
* the module to load in standalone Bun binaries where playwright is unavailable.
|
|
10
|
-
* Tools will report a clear "Browser unavailable" error instead of crashing.
|
|
11
|
-
*/
|
|
12
1
|
import { createRequire } from "node:module";
|
|
13
2
|
import { getBrowserConfig } from "./config.js";
|
|
14
3
|
const _require = createRequire(import.meta.url);
|
|
@@ -40,21 +29,19 @@ export class BrowserService {
|
|
|
40
29
|
networkBuffer = new Map();
|
|
41
30
|
nextNetworkIndex = 0;
|
|
42
31
|
pendingDialogs = [];
|
|
32
|
+
_recording = null;
|
|
43
33
|
constructor(config) {
|
|
44
34
|
this.config = { ...getBrowserConfig(), ...config };
|
|
45
35
|
}
|
|
46
|
-
/** Get or create the singleton instance. */
|
|
47
36
|
static getInstance(config) {
|
|
48
37
|
if (!instance) {
|
|
49
38
|
instance = new BrowserService(config);
|
|
50
39
|
}
|
|
51
40
|
return instance;
|
|
52
41
|
}
|
|
53
|
-
/** Reset singleton (for testing). */
|
|
54
42
|
static resetInstance() {
|
|
55
43
|
instance = null;
|
|
56
44
|
}
|
|
57
|
-
/** Check whether Playwright is installed and loadable (static, does NOT start a browser). */
|
|
58
45
|
static isAvailable() {
|
|
59
46
|
try {
|
|
60
47
|
loadPlaywright();
|
|
@@ -64,10 +51,6 @@ export class BrowserService {
|
|
|
64
51
|
return false;
|
|
65
52
|
}
|
|
66
53
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Human-readable message explaining why the browser is unavailable.
|
|
69
|
-
* Returns null when the browser is available.
|
|
70
|
-
*/
|
|
71
54
|
static unavailabilityReason() {
|
|
72
55
|
if (_pwError)
|
|
73
56
|
return _pwError;
|
|
@@ -81,7 +64,6 @@ export class BrowserService {
|
|
|
81
64
|
return err instanceof Error ? err.message : String(err);
|
|
82
65
|
}
|
|
83
66
|
}
|
|
84
|
-
/** Ensure browser, context, and at least one page are ready. */
|
|
85
67
|
async ensureBrowser() {
|
|
86
68
|
if (this.browser?.isConnected() && this.context && this.pages.length > 0) {
|
|
87
69
|
const page = this.pages[this.activePageIndex];
|
|
@@ -110,7 +92,6 @@ export class BrowserService {
|
|
|
110
92
|
this.context = await this.browser.newContext({
|
|
111
93
|
viewport: this.config.viewport,
|
|
112
94
|
});
|
|
113
|
-
// Auto-collect console messages and network requests
|
|
114
95
|
this.context.on("page", (page) => {
|
|
115
96
|
page.on("console", (msg) => {
|
|
116
97
|
this.consoleBuffer.push({
|
|
@@ -129,34 +110,38 @@ export class BrowserService {
|
|
|
129
110
|
this.activePageIndex = 0;
|
|
130
111
|
return { browser: this.browser, context: this.context, page };
|
|
131
112
|
}
|
|
132
|
-
|
|
113
|
+
getBrowser() {
|
|
114
|
+
if (!this.browser?.isConnected())
|
|
115
|
+
throw new Error("Browser is not connected.");
|
|
116
|
+
return this.browser;
|
|
117
|
+
}
|
|
118
|
+
getActiveContext() {
|
|
119
|
+
if (!this.context)
|
|
120
|
+
throw new Error("No active context.");
|
|
121
|
+
return this.context;
|
|
122
|
+
}
|
|
133
123
|
getActivePage() {
|
|
134
|
-
if (this.pages.length === 0)
|
|
135
|
-
throw new Error("No
|
|
136
|
-
|
|
124
|
+
if (this.pages.length === 0)
|
|
125
|
+
throw new Error("No active page.");
|
|
126
|
+
if (this.activePageIndex >= this.pages.length)
|
|
127
|
+
this.activePageIndex = 0;
|
|
137
128
|
return this.pages[this.activePageIndex];
|
|
138
129
|
}
|
|
139
|
-
/** Navigate to a URL on the active page. */
|
|
140
130
|
async navigate(url) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
await page.goto(normalizedUrl, {
|
|
145
|
-
timeout: this.config.navigationTimeout,
|
|
131
|
+
const page = this.getActivePage();
|
|
132
|
+
const start = Date.now();
|
|
133
|
+
await page.goto(url, {
|
|
146
134
|
waitUntil: "domcontentloaded",
|
|
135
|
+
timeout: this.config.navigationTimeout,
|
|
147
136
|
});
|
|
148
|
-
return {
|
|
137
|
+
return { url: page.url(), title: await page.title() };
|
|
149
138
|
}
|
|
150
|
-
/** Navigate back. */
|
|
151
139
|
async navigateBack() {
|
|
152
140
|
const page = this.getActivePage();
|
|
153
141
|
await page.goBack({ timeout: this.config.navigationTimeout });
|
|
154
|
-
return {
|
|
142
|
+
return { url: page.url(), title: await page.title() };
|
|
155
143
|
}
|
|
156
|
-
/** Close the active page. If last page, create a blank one. */
|
|
157
144
|
async closePage() {
|
|
158
|
-
if (this.pages.length === 0)
|
|
159
|
-
return;
|
|
160
145
|
const page = this.pages[this.activePageIndex];
|
|
161
146
|
await page.close();
|
|
162
147
|
this.pages.splice(this.activePageIndex, 1);
|
|
@@ -169,13 +154,11 @@ export class BrowserService {
|
|
|
169
154
|
this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
|
|
170
155
|
}
|
|
171
156
|
}
|
|
172
|
-
/** Resize the viewport. */
|
|
173
157
|
async resize(width, height) {
|
|
174
158
|
const page = this.getActivePage();
|
|
175
159
|
await page.setViewportSize({ width, height });
|
|
176
160
|
this.config.viewport = { width, height };
|
|
177
161
|
}
|
|
178
|
-
// --- Tab management ---
|
|
179
162
|
listTabs() {
|
|
180
163
|
return this.pages.map((p, i) => ({
|
|
181
164
|
index: i,
|
|
@@ -214,10 +197,8 @@ export class BrowserService {
|
|
|
214
197
|
await page.bringToFront();
|
|
215
198
|
return { index };
|
|
216
199
|
}
|
|
217
|
-
// --- Dialog interception ---
|
|
218
200
|
installDialogInterceptor(page) {
|
|
219
201
|
page.on("dialog", (dialog) => {
|
|
220
|
-
// Hold the dialog until agent handles it via browser_handle_dialog
|
|
221
202
|
this.pendingDialogs.push({
|
|
222
203
|
message: dialog.message(),
|
|
223
204
|
type: dialog.type(),
|
|
@@ -246,7 +227,6 @@ export class BrowserService {
|
|
|
246
227
|
}
|
|
247
228
|
return dialog.message;
|
|
248
229
|
}
|
|
249
|
-
// --- Console messages ---
|
|
250
230
|
getConsoleMessages(level, all = false) {
|
|
251
231
|
const msgs = all ? [...this.consoleBuffer] : [...this.consoleBuffer];
|
|
252
232
|
if (level) {
|
|
@@ -259,7 +239,6 @@ export class BrowserService {
|
|
|
259
239
|
clearConsoleMessages() {
|
|
260
240
|
this.consoleBuffer = [];
|
|
261
241
|
}
|
|
262
|
-
// --- Network requests ---
|
|
263
242
|
trackNetworkRequest(page) {
|
|
264
243
|
page.on("request", (request) => {
|
|
265
244
|
const index = this.nextNetworkIndex++;
|
|
@@ -282,7 +261,6 @@ export class BrowserService {
|
|
|
282
261
|
entry.statusText = response.statusText();
|
|
283
262
|
entry.responseHeaders = response.headers();
|
|
284
263
|
}).catch(() => {
|
|
285
|
-
// Request may fail or be aborted
|
|
286
264
|
});
|
|
287
265
|
});
|
|
288
266
|
}
|
|
@@ -296,8 +274,6 @@ export class BrowserService {
|
|
|
296
274
|
this.networkBuffer.clear();
|
|
297
275
|
this.nextNetworkIndex = 0;
|
|
298
276
|
}
|
|
299
|
-
// --- Lifecycle ---
|
|
300
|
-
/** Close browser, context, and all pages. */
|
|
301
277
|
async close() {
|
|
302
278
|
for (const page of this.pages) {
|
|
303
279
|
try {
|
|
@@ -321,8 +297,103 @@ export class BrowserService {
|
|
|
321
297
|
catch { /* ignore */ }
|
|
322
298
|
this.browser = null;
|
|
323
299
|
}
|
|
300
|
+
this._recording = null;
|
|
301
|
+
}
|
|
302
|
+
// ── Recording ──────────────────────────────────────────────
|
|
303
|
+
async startRecording(outputDir) {
|
|
304
|
+
if (this._recording) {
|
|
305
|
+
throw new Error("Recording is already active. Use stopRecording() first.");
|
|
306
|
+
}
|
|
307
|
+
const dir = outputDir ?? this.config.recordOutputDir;
|
|
308
|
+
const { mkdirSync } = await import("node:fs");
|
|
309
|
+
mkdirSync(dir, { recursive: true });
|
|
310
|
+
// Save current URL before tearing down
|
|
311
|
+
let currentUrl = "about:blank";
|
|
312
|
+
if (this.pages.length > 0 && this.activePageIndex < this.pages.length) {
|
|
313
|
+
try {
|
|
314
|
+
currentUrl = this.pages[this.activePageIndex].url() || currentUrl;
|
|
315
|
+
}
|
|
316
|
+
catch { /* ignore */ }
|
|
317
|
+
}
|
|
318
|
+
// Close existing context (finalizes any prior video)
|
|
319
|
+
if (this.context) {
|
|
320
|
+
await Promise.allSettled(this.pages.map((p) => p.close().catch(() => { })));
|
|
321
|
+
this.pages = [];
|
|
322
|
+
await this.context.close().catch(() => { });
|
|
323
|
+
this.context = null;
|
|
324
|
+
}
|
|
325
|
+
// Create new browser+context with recording enabled
|
|
326
|
+
const pw = loadPlaywright();
|
|
327
|
+
const launchOpts = {
|
|
328
|
+
headless: this.config.headless,
|
|
329
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
|
330
|
+
};
|
|
331
|
+
if (!this.browser?.isConnected()) {
|
|
332
|
+
switch (this.config.browserName) {
|
|
333
|
+
case "firefox":
|
|
334
|
+
this.browser = await pw.firefox.launch(launchOpts);
|
|
335
|
+
break;
|
|
336
|
+
case "webkit":
|
|
337
|
+
this.browser = await pw.webkit.launch(launchOpts);
|
|
338
|
+
break;
|
|
339
|
+
default:
|
|
340
|
+
this.browser = await pw.chromium.launch(launchOpts);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
345
|
+
const outputPath = `${dir}/recording-${timestamp}.webm`;
|
|
346
|
+
this.context = await this.browser.newContext({
|
|
347
|
+
viewport: this.config.recordViewport ?? this.config.viewport,
|
|
348
|
+
recordVideo: {
|
|
349
|
+
dir,
|
|
350
|
+
size: this.config.recordViewport ?? this.config.viewport,
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
this.context.on("page", (page) => {
|
|
354
|
+
page.on("console", (msg) => {
|
|
355
|
+
this.consoleBuffer.push({ type: msg.type(), text: msg.text(), pageUrl: page.url() });
|
|
356
|
+
});
|
|
357
|
+
this.trackNetworkRequest(page);
|
|
358
|
+
this.installDialogInterceptor(page);
|
|
359
|
+
});
|
|
360
|
+
const page = await this.context.newPage();
|
|
361
|
+
this.trackNetworkRequest(page);
|
|
362
|
+
this.installDialogInterceptor(page);
|
|
363
|
+
this.pages = [page];
|
|
364
|
+
this.activePageIndex = 0;
|
|
365
|
+
await page.goto(currentUrl, { waitUntil: "domcontentloaded", timeout: this.config.navigationTimeout }).catch(() => { });
|
|
366
|
+
this._recording = { dir, startedAt: Date.now(), outputPath };
|
|
367
|
+
return { outputPath, startedAt: this._recording.startedAt };
|
|
368
|
+
}
|
|
369
|
+
async stopRecording() {
|
|
370
|
+
if (!this._recording) {
|
|
371
|
+
throw new Error("No active recording.");
|
|
372
|
+
}
|
|
373
|
+
const { outputPath, startedAt } = this._recording;
|
|
374
|
+
const durationSeconds = Math.round((Date.now() - startedAt) / 1000);
|
|
375
|
+
// Close the recording context — Playwright finalizes the .webm on context.close()
|
|
376
|
+
if (this.context) {
|
|
377
|
+
await Promise.allSettled(this.pages.map((p) => p.close().catch(() => { })));
|
|
378
|
+
this.pages = [];
|
|
379
|
+
await this.context.close().catch(() => { });
|
|
380
|
+
this.context = null;
|
|
381
|
+
}
|
|
382
|
+
this._recording = null;
|
|
383
|
+
// Create a fresh non-recording context for continued use
|
|
384
|
+
await this.ensureBrowser();
|
|
385
|
+
return { outputPath, durationSeconds };
|
|
386
|
+
}
|
|
387
|
+
getRecordStatus() {
|
|
388
|
+
if (!this._recording)
|
|
389
|
+
return { active: false };
|
|
390
|
+
return {
|
|
391
|
+
active: true,
|
|
392
|
+
outputPath: this._recording.outputPath,
|
|
393
|
+
startedAt: this._recording.startedAt,
|
|
394
|
+
elapsedSeconds: Math.round((Date.now() - this._recording.startedAt) / 1000),
|
|
395
|
+
};
|
|
324
396
|
}
|
|
325
|
-
/** Reset console/network buffers. */
|
|
326
397
|
resetBuffers() {
|
|
327
398
|
this.clearConsoleMessages();
|
|
328
399
|
this.clearNetworkRequests();
|
|
@@ -1,24 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser extension configuration.
|
|
3
|
-
*/
|
|
4
1
|
export interface BrowserConfig {
|
|
5
|
-
/** Default browser name to launch. */
|
|
6
2
|
browserName: "chromium" | "firefox" | "webkit";
|
|
7
|
-
/** Whether to run headless. */
|
|
8
3
|
headless: boolean;
|
|
9
|
-
/** Default viewport size. */
|
|
10
4
|
viewport: {
|
|
11
5
|
width: number;
|
|
12
6
|
height: number;
|
|
13
7
|
};
|
|
14
|
-
/** Default navigation timeout (ms). */
|
|
15
8
|
navigationTimeout: number;
|
|
16
|
-
/** Default action timeout (ms). */
|
|
17
9
|
actionTimeout: number;
|
|
18
|
-
/** Directory to save screenshots and output files. */
|
|
19
10
|
outputDir: string;
|
|
20
|
-
/** Whether to enable debug mode (keep browser visible, verbose logging). */
|
|
21
11
|
debug: boolean;
|
|
12
|
+
recordOutputDir: string;
|
|
13
|
+
recordViewport?: {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
};
|
|
22
17
|
}
|
|
23
18
|
export declare function getBrowserConfig(): BrowserConfig;
|
|
24
19
|
//# sourceMappingURL=config.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC/C,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CACpD;AACD,wBAAgB,gBAAgB,IAAI,aAAa,CAehD"}
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser extension configuration.
|
|
3
|
-
*/
|
|
4
1
|
export function getBrowserConfig() {
|
|
5
2
|
return {
|
|
6
3
|
browserName: process.env.BROWSER_NAME ?? "chromium",
|
|
@@ -13,5 +10,7 @@ export function getBrowserConfig() {
|
|
|
13
10
|
actionTimeout: Number(process.env.BROWSER_ACTION_TIMEOUT) || 5_000,
|
|
14
11
|
outputDir: process.env.BROWSER_OUTPUT_DIR ?? ".spectral/browser-output",
|
|
15
12
|
debug: process.env.BROWSER_DEBUG === "true" ? true : false,
|
|
13
|
+
recordOutputDir: process.env.BROWSER_RECORD_DIR ?? ".spectral/browser-recordings",
|
|
14
|
+
recordViewport: undefined, // defaults to viewport via code
|
|
16
15
|
};
|
|
17
16
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAepE,wBAA8B,gBAAgB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA6D/E"}
|
|
@@ -42,6 +42,7 @@ import { registerDialogTools } from "./tools/dialog.js";
|
|
|
42
42
|
import { registerWaitTools } from "./tools/wait.js";
|
|
43
43
|
import { registerDropTools } from "./tools/drop.js";
|
|
44
44
|
import { registerStreamTools } from "./stream/stream-tool.js";
|
|
45
|
+
import { registerRecordTools } from "./record/record-tool.js";
|
|
45
46
|
export default async function browserExtension(ext) {
|
|
46
47
|
const available = BrowserService.isAvailable();
|
|
47
48
|
if (!available) {
|
|
@@ -77,6 +78,7 @@ export default async function browserExtension(ext) {
|
|
|
77
78
|
{ name: "wait", fn: () => registerWaitTools(ext, bs) },
|
|
78
79
|
{ name: "drop", fn: () => registerDropTools(ext, bs) },
|
|
79
80
|
{ name: "stream", fn: () => registerStreamTools(ext, bs) },
|
|
81
|
+
{ name: "record", fn: () => registerRecordTools(ext, bs) },
|
|
80
82
|
];
|
|
81
83
|
let totalRegistered = 0;
|
|
82
84
|
for (const registrar of registrars) {
|
|
@@ -113,6 +115,7 @@ function countRegisteredTools(ext) {
|
|
|
113
115
|
"browser_wait_for",
|
|
114
116
|
"browser_drop",
|
|
115
117
|
"browser_stream_start", "browser_stream_stop", "browser_stream_status",
|
|
118
|
+
"browser_record_start", "browser_record_stop", "browser_record_status",
|
|
116
119
|
];
|
|
117
120
|
for (const name of knownTools) {
|
|
118
121
|
if (ext.getToolDefinition(name))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"record-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/record/record-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CAwI/E"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export function registerRecordTools(ext, bs) {
|
|
2
|
+
const startTool = {
|
|
3
|
+
name: "browser_record_start",
|
|
4
|
+
label: "Start Browser Recording",
|
|
5
|
+
description: "Start recording the browser session as a .webm video. " +
|
|
6
|
+
"Uses Playwright's built-in video recording (actual page capture, not a canvas overlay). " +
|
|
7
|
+
"Recording continues across navigation, clicks, typing — everything the agent does in the browser. " +
|
|
8
|
+
"Use browser_record_stop to finalize and save the video.",
|
|
9
|
+
promptSnippet: "`browser_record_start { outputDir? }` — start recording browser as .webm video",
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
outputDir: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description: "Directory to save the recording. Default: .spectral/browser-recordings/ (configurable via BROWSER_RECORD_DIR env).",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
async execute(_toolCallId, params) {
|
|
20
|
+
const p = params;
|
|
21
|
+
try {
|
|
22
|
+
const { outputPath, startedAt } = await bs.startRecording(p.outputDir);
|
|
23
|
+
return {
|
|
24
|
+
content: [
|
|
25
|
+
{
|
|
26
|
+
type: "text",
|
|
27
|
+
text: [
|
|
28
|
+
"✓ Browser recording started",
|
|
29
|
+
`Output: ${outputPath}`,
|
|
30
|
+
`Started at: ${new Date(startedAt).toISOString()}`,
|
|
31
|
+
"",
|
|
32
|
+
"All browser actions (navigation, clicks, typing, etc.) are now being recorded.",
|
|
33
|
+
"Use browser_record_stop to finalize and save the video.",
|
|
34
|
+
].join("\n"),
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
details: { outputPath, startedAt },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "text", text: `Failed to start recording: ${msg}` }],
|
|
44
|
+
details: { isError: true },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const stopTool = {
|
|
50
|
+
name: "browser_record_stop",
|
|
51
|
+
label: "Stop Browser Recording",
|
|
52
|
+
description: "Stop the active recording, finalize the .webm video, and create a fresh non-recording browser context " +
|
|
53
|
+
"for continued use. Returns the video file path and recording duration.",
|
|
54
|
+
promptSnippet: "`browser_record_stop` — stop recording and save .webm video",
|
|
55
|
+
parameters: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {},
|
|
58
|
+
},
|
|
59
|
+
async execute() {
|
|
60
|
+
try {
|
|
61
|
+
const { outputPath, durationSeconds } = await bs.stopRecording();
|
|
62
|
+
return {
|
|
63
|
+
content: [
|
|
64
|
+
{
|
|
65
|
+
type: "text",
|
|
66
|
+
text: [
|
|
67
|
+
"✓ Recording stopped and saved",
|
|
68
|
+
`File: ${outputPath}`,
|
|
69
|
+
`Duration: ${durationSeconds}s (${Math.floor(durationSeconds / 60)}m ${durationSeconds % 60}s)`,
|
|
70
|
+
"",
|
|
71
|
+
"A fresh browser context has been created — you can continue browsing.",
|
|
72
|
+
].join("\n"),
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
details: { outputPath, durationSeconds },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: `Failed to stop recording: ${msg}` }],
|
|
82
|
+
details: { isError: true },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const statusTool = {
|
|
88
|
+
name: "browser_record_status",
|
|
89
|
+
label: "Browser Recording Status",
|
|
90
|
+
description: "Check whether a browser recording is currently active.",
|
|
91
|
+
promptSnippet: "`browser_record_status` — check recording status",
|
|
92
|
+
parameters: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {},
|
|
95
|
+
},
|
|
96
|
+
async execute() {
|
|
97
|
+
const status = bs.getRecordStatus();
|
|
98
|
+
if (!status.active) {
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: "text",
|
|
103
|
+
text: "No active recording. Use browser_record_start to begin.",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
details: {},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
content: [
|
|
111
|
+
{
|
|
112
|
+
type: "text",
|
|
113
|
+
text: [
|
|
114
|
+
"● Recording active",
|
|
115
|
+
`Output: ${status.outputPath}`,
|
|
116
|
+
`Started: ${status.startedAt ? new Date(status.startedAt).toISOString() : "?"}`,
|
|
117
|
+
`Elapsed: ${status.elapsedSeconds ?? 0}s`,
|
|
118
|
+
].join("\n"),
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
details: status,
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
for (const tool of [startTool, stopTool, statusTool]) {
|
|
126
|
+
try {
|
|
127
|
+
ext.registerTool(tool);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
131
|
+
process.stderr.write(`[browser-record] Failed to register ${tool.name}: ${msg}\n`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|