@anweat/dsh-browser 0.1.13-alpha.1 → 0.1.14-alpha.2

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.
@@ -30,66 +30,66 @@ import { configuredBrowserTools } from "./freedom.js";
30
30
  import { filterOpencliCatalog, parseOpencliCatalog } from "./opencli-catalog.js";
31
31
  import { UsageGovernor } from "./usage-policy.js";
32
32
  /** Rules-aware content extractor (runs in the page). */
33
- const EXTRACTOR_FN = `(ruleList) => {
34
- const doc = document
35
- const title = doc.title ? doc.title.trim() : ''
36
- let host = ''
37
- try { host = location.hostname } catch (e) {}
38
- const norm = (h) => { const x = h.toLowerCase(); return x.startsWith('www.') ? x.slice(4) : x }
39
- let rule = null
40
- for (const r of ruleList) {
41
- const rh = norm(r.hostname)
42
- if (host === rh || host.endsWith('.' + rh)) rule = r
43
- }
44
- const pick = (selectors) => {
45
- for (const sel of selectors) {
46
- try {
47
- const el = doc.querySelector(sel)
48
- if (el && (el.textContent || '').trim().length > 40) return el
49
- } catch (e) {}
50
- }
51
- return null
52
- }
53
- const content = rule ? pick(rule.contentSelectors) : (pick(['article', 'main', '[role="main"]']) || doc.body)
54
- if (content && rule && rule.removeSelectors) {
55
- for (const sel of rule.removeSelectors) {
56
- try { content.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) {}
57
- }
58
- }
59
- const text = content ? (content.innerText || content.textContent || '') : ''
60
- return { title, text, html: doc.documentElement.outerHTML.slice(0, 2000000), usedRule: rule ? rule.hostname : null }
33
+ const EXTRACTOR_FN = `(ruleList) => {
34
+ const doc = document
35
+ const title = doc.title ? doc.title.trim() : ''
36
+ let host = ''
37
+ try { host = location.hostname } catch (e) {}
38
+ const norm = (h) => { const x = h.toLowerCase(); return x.startsWith('www.') ? x.slice(4) : x }
39
+ let rule = null
40
+ for (const r of ruleList) {
41
+ const rh = norm(r.hostname)
42
+ if (host === rh || host.endsWith('.' + rh)) rule = r
43
+ }
44
+ const pick = (selectors) => {
45
+ for (const sel of selectors) {
46
+ try {
47
+ const el = doc.querySelector(sel)
48
+ if (el && (el.textContent || '').trim().length > 40) return el
49
+ } catch (e) {}
50
+ }
51
+ return null
52
+ }
53
+ const content = rule ? pick(rule.contentSelectors) : (pick(['article', 'main', '[role="main"]']) || doc.body)
54
+ if (content && rule && rule.removeSelectors) {
55
+ for (const sel of rule.removeSelectors) {
56
+ try { content.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) {}
57
+ }
58
+ }
59
+ const text = content ? (content.innerText || content.textContent || '') : ''
60
+ return { title, text, html: doc.documentElement.outerHTML.slice(0, 2000000), usedRule: rule ? rule.hostname : null }
61
61
  }`;
62
62
  /** Platform search-page list extractor (runs in the page). */
63
- const LIST_EXTRACTOR = `(spec) => {
64
- const items = []
65
- const nodes = document.querySelectorAll(spec.item)
66
- for (let i = 0; i < nodes.length && items.length < 20; i++) {
67
- const el = nodes[i]
68
- const titleEl = spec.title ? el.querySelector(spec.title) : null
69
- const linkEl = spec.link ? el.querySelector(spec.link) : null
70
- const textEl = spec.text ? el.querySelector(spec.text) : null
71
- const title = (titleEl ? titleEl.textContent : el.textContent || '').trim().replace(/\\s+/g, ' ')
72
- let url = linkEl ? (linkEl.href || linkEl.getAttribute('href') || '') : ''
73
- if (url && url.startsWith('/')) url = location.origin + url
74
- if (!url && linkEl === null && titleEl) { const a = titleEl.closest ? titleEl.closest('a') : null; if (a) url = a.href }
75
- const snippet = textEl ? (textEl.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 300) : ''
76
- if (!url || !title || title.length < 2) continue
77
- items.push({ url, title, snippet })
78
- }
79
- return items
63
+ const LIST_EXTRACTOR = `(spec) => {
64
+ const items = []
65
+ const nodes = document.querySelectorAll(spec.item)
66
+ for (let i = 0; i < nodes.length && items.length < 20; i++) {
67
+ const el = nodes[i]
68
+ const titleEl = spec.title ? el.querySelector(spec.title) : null
69
+ const linkEl = spec.link ? el.querySelector(spec.link) : null
70
+ const textEl = spec.text ? el.querySelector(spec.text) : null
71
+ const title = (titleEl ? titleEl.textContent : el.textContent || '').trim().replace(/\\s+/g, ' ')
72
+ let url = linkEl ? (linkEl.href || linkEl.getAttribute('href') || '') : ''
73
+ if (url && url.startsWith('/')) url = location.origin + url
74
+ if (!url && linkEl === null && titleEl) { const a = titleEl.closest ? titleEl.closest('a') : null; if (a) url = a.href }
75
+ const snippet = textEl ? (textEl.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 300) : ''
76
+ if (!url || !title || title.length < 2) continue
77
+ items.push({ url, title, snippet })
78
+ }
79
+ return items
80
80
  }`;
81
- const CRAWL_EXTRACTOR = `(maxChars) => {
82
- const root = document.querySelector('article, main, [role="main"]') || document.body
83
- const text = ((root && (root.innerText || root.textContent)) || '').replace(/\\n{3,}/g, '\\n\\n').trim().slice(0, maxChars)
84
- const links = []
85
- for (const anchor of document.querySelectorAll('a[href]')) {
86
- try {
87
- const url = new URL(anchor.href, location.href)
88
- if ((url.protocol === 'http:' || url.protocol === 'https:') && !links.includes(url.href)) links.push(url.href)
89
- if (links.length >= 500) break
90
- } catch (e) {}
91
- }
92
- return { title: document.title || '', text, links }
81
+ const CRAWL_EXTRACTOR = `(maxChars) => {
82
+ const root = document.querySelector('article, main, [role="main"]') || document.body
83
+ const text = ((root && (root.innerText || root.textContent)) || '').replace(/\\n{3,}/g, '\\n\\n').trim().slice(0, maxChars)
84
+ const links = []
85
+ for (const anchor of document.querySelectorAll('a[href]')) {
86
+ try {
87
+ const url = new URL(anchor.href, location.href)
88
+ if ((url.protocol === 'http:' || url.protocol === 'https:') && !links.includes(url.href)) links.push(url.href)
89
+ if (links.length >= 500) break
90
+ } catch (e) {}
91
+ }
92
+ return { title: document.title || '', text, links }
93
93
  }`;
94
94
  function uid() {
95
95
  return crypto.randomUUID();
@@ -117,6 +117,35 @@ function storageStateOptions(statePath, label, allowMissing = false) {
117
117
  throw new Error(`dsh-browser: ${label} storageState path is not a file: ${statePath}`);
118
118
  return { storageState: statePath };
119
119
  }
120
+ function boundedString(value, label, max) {
121
+ if (!value || value.length > max)
122
+ throw new Error(`${label} must contain 1 to ${max} characters`);
123
+ return value;
124
+ }
125
+ function boundedTimeout(value, label) {
126
+ const resolved = value ?? 15_000;
127
+ if (!Number.isFinite(resolved) || resolved < 1 || resolved > 30_000)
128
+ throw new Error(`${label} must be between 1 and 30,000 ms`);
129
+ return resolved;
130
+ }
131
+ function redactCaptureText(value, max = 2_000) {
132
+ return value
133
+ .replace(/\b(authorization|cookie|set-cookie|password|passwd|secret|token|api[-_]?key|session[-_]?id)\b\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
134
+ .slice(0, max);
135
+ }
136
+ function redactCaptureUrl(value) {
137
+ try {
138
+ const parsed = new URL(value);
139
+ for (const key of [...parsed.searchParams.keys()]) {
140
+ if (/token|key|auth|session|cookie|password|secret/i.test(key))
141
+ parsed.searchParams.set(key, '[redacted]');
142
+ }
143
+ return parsed.toString().slice(0, 2_000);
144
+ }
145
+ catch {
146
+ return redactCaptureText(value);
147
+ }
148
+ }
120
149
  export class BrowserService {
121
150
  config;
122
151
  browser;
@@ -128,6 +157,10 @@ export class BrowserService {
128
157
  authProfiles;
129
158
  usageGovernor;
130
159
  opencliCatalogCache;
160
+ captureConsoleEnabled = false;
161
+ captureNetworkEnabled = false;
162
+ capturedConsole = [];
163
+ capturedRequests = [];
131
164
  constructor(config) {
132
165
  this.config = config;
133
166
  this.authProfiles = new AuthProfileStore(config.authProfiles);
@@ -140,10 +173,35 @@ export class BrowserService {
140
173
  if (!this.config.enabled)
141
174
  throw new Error('dsh-browser: browser service is disabled');
142
175
  }
176
+ browserConnected(browser = this.browser) {
177
+ return !!browser && (typeof browser.isConnected !== 'function' || browser.isConnected());
178
+ }
179
+ clearActiveState() {
180
+ this.activeContext = undefined;
181
+ this.activePage = undefined;
182
+ this.activeProfile = undefined;
183
+ this.activeRulePack = undefined;
184
+ this.resetCapture();
185
+ }
186
+ handleBrowserDisconnected(browser) {
187
+ // A late event from an older process must not invalidate its replacement.
188
+ if (this.browser !== browser)
189
+ return;
190
+ this.browser = undefined;
191
+ this.launching = undefined;
192
+ this.clearActiveState();
193
+ }
194
+ trackBrowser(browser) {
195
+ this.browser = browser;
196
+ browser.on?.('disconnected', () => this.handleBrowserDisconnected(browser));
197
+ return browser;
198
+ }
143
199
  async ensure() {
144
200
  this.assertEnabled();
145
- if (this.browser)
201
+ if (this.browserConnected())
146
202
  return this.browser;
203
+ if (this.browser)
204
+ this.handleBrowserDisconnected(this.browser);
147
205
  if (!this.launching) {
148
206
  this.launching = (async () => {
149
207
  const pw = loadBrowserRuntime(this.config.browserRuntime);
@@ -153,14 +211,14 @@ export class BrowserService {
153
211
  if (this.config.executablePath)
154
212
  launchOptions.executablePath = this.config.executablePath;
155
213
  try {
156
- this.browser = await pw.chromium.launch(launchOptions);
214
+ return this.trackBrowser(await pw.chromium.launch(launchOptions));
157
215
  }
158
216
  catch (error) {
159
217
  const msg = String(error);
160
218
  if (/Executable doesn't exist|playwright install|not found/i.test(msg)) {
161
219
  if (this.config.autoInstall) {
162
220
  await this.installChromium();
163
- this.browser = await pw.chromium.launch(launchOptions);
221
+ return this.trackBrowser(await pw.chromium.launch(launchOptions));
164
222
  }
165
223
  else {
166
224
  throw new Error('dsh-browser: chromium is not installed for ' + this.config.browserRuntime + '. Run the browser_install tool, or: node "' + browserRuntimeCliPath(this.config.browserRuntime) + '" install chromium');
@@ -170,13 +228,16 @@ export class BrowserService {
170
228
  throw error;
171
229
  }
172
230
  }
173
- return this.browser;
174
- })().catch((error) => {
231
+ })();
232
+ }
233
+ const launching = this.launching;
234
+ try {
235
+ return await launching;
236
+ }
237
+ finally {
238
+ if (this.launching === launching)
175
239
  this.launching = undefined;
176
- throw error;
177
- });
178
240
  }
179
- return this.launching;
180
241
  }
181
242
  /** Run `playwright install chromium` from the bundled playwright CLI. */
182
243
  installChromium() {
@@ -203,14 +264,27 @@ export class BrowserService {
203
264
  return response;
204
265
  }
205
266
  async transientContext(url, opts = {}) {
206
- const browser = await this.ensure();
207
267
  const profileId = opts.anonymous ? undefined : (opts.authProfile ?? this.config.defaultAuthProfile);
208
268
  const profile = profileId ? this.authProfiles.resolve(profileId, url) : undefined;
209
269
  const rulePack = resolveRulePack(this.config.rulePacks, opts.rulePack, url);
210
270
  const stateOptions = profile
211
271
  ? storageStateOptions(profile.storageStatePath, `auth profile ${profile.id}`, profile.persistState)
212
272
  : (!opts.anonymous ? storageStateOptions(this.config.storageStatePath, 'global') : {});
213
- const context = await browser.newContext(stateOptions);
273
+ let context;
274
+ for (let attempt = 0; attempt < 2; attempt++) {
275
+ const browser = await this.ensure();
276
+ try {
277
+ context = await browser.newContext(stateOptions);
278
+ break;
279
+ }
280
+ catch (error) {
281
+ if (attempt > 0 || this.browserConnected(browser))
282
+ throw error;
283
+ this.handleBrowserDisconnected(browser);
284
+ }
285
+ }
286
+ if (!context)
287
+ throw new Error('dsh-browser: browser context could not be created after reconnecting');
214
288
  try {
215
289
  if (rulePack?.initScriptPath)
216
290
  await context.addInitScript({ path: rulePack.initScriptPath });
@@ -224,7 +298,17 @@ export class BrowserService {
224
298
  async persistAndClose(session) {
225
299
  try {
226
300
  if (session.profile?.persistState) {
227
- const state = await session.context.storageState();
301
+ let state;
302
+ try {
303
+ state = await session.context.storageState();
304
+ }
305
+ catch (error) {
306
+ // A crashed browser cannot provide state. Filesystem failures below
307
+ // still propagate so failed persistence is never reported as success.
308
+ if (/target page, context or browser has been closed|browser has been closed|browser disconnected/i.test(String(error)))
309
+ return;
310
+ throw error;
311
+ }
228
312
  fs.mkdirSync(path.dirname(session.profile.storageStatePath), { recursive: true });
229
313
  const temporary = session.profile.storageStatePath + '.tmp-' + uid().slice(0, 8);
230
314
  fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 });
@@ -543,6 +627,8 @@ export class BrowserService {
543
627
  }
544
628
  // ── interactive surface (one persistent context + page) ──────────────────
545
629
  async ensureActivePage(targetUrl, opts = {}) {
630
+ if (this.activePage && (this.activePage.isClosed() || !this.browserConnected()))
631
+ await this.closePage();
546
632
  if (this.activePage && !this.activePage.isClosed()) {
547
633
  if (!targetUrl)
548
634
  return this.activePage;
@@ -566,12 +652,150 @@ export class BrowserService {
566
652
  this.activeContext = await browser.newContext(storageStateOptions(this.config.storageStatePath, 'global'));
567
653
  }
568
654
  this.activePage = await this.activeContext.newPage();
655
+ this.attachCapture(this.activePage);
569
656
  return this.activePage;
570
657
  }
571
- async captureScreenshot(page) {
658
+ attachCapture(page) {
659
+ const release = () => {
660
+ if (this.activePage !== page)
661
+ return;
662
+ const context = this.activeContext;
663
+ const profile = this.activeProfile;
664
+ this.clearActiveState();
665
+ if (context)
666
+ void this.persistAndClose({ context, ...profile ? { profile } : {} }).catch(() => { });
667
+ };
668
+ page.on('close', release);
669
+ page.on('crash', release);
670
+ page.on('console', (message) => {
671
+ if (!this.captureConsoleEnabled)
672
+ return;
673
+ const location = message.location?.();
674
+ this.capturedConsole.push({
675
+ type: String(message.type?.() ?? 'log').slice(0, 40),
676
+ text: redactCaptureText(String(message.text?.() ?? '')),
677
+ ...location?.url ? { url: redactCaptureUrl(location.url) } : {},
678
+ timestamp: new Date().toISOString(),
679
+ });
680
+ if (this.capturedConsole.length > 200)
681
+ this.capturedConsole.splice(0, this.capturedConsole.length - 200);
682
+ });
683
+ page.on('response', (response) => {
684
+ if (!this.captureNetworkEnabled)
685
+ return;
686
+ const status = Number(response.status?.() ?? 0);
687
+ if (status < 400)
688
+ return;
689
+ const request = response.request?.();
690
+ this.capturedRequests.push({
691
+ method: String(request?.method?.() ?? 'GET').slice(0, 20),
692
+ url: redactCaptureUrl(String(response.url?.() ?? '')),
693
+ status,
694
+ timestamp: new Date().toISOString(),
695
+ });
696
+ if (this.capturedRequests.length > 200)
697
+ this.capturedRequests.splice(0, this.capturedRequests.length - 200);
698
+ });
699
+ page.on('requestfailed', (request) => {
700
+ if (!this.captureNetworkEnabled)
701
+ return;
702
+ this.capturedRequests.push({
703
+ method: String(request.method?.() ?? 'GET').slice(0, 20),
704
+ url: redactCaptureUrl(String(request.url?.() ?? '')),
705
+ failure: redactCaptureText(String(request.failure?.()?.errorText ?? 'request failed'), 500),
706
+ timestamp: new Date().toISOString(),
707
+ });
708
+ if (this.capturedRequests.length > 200)
709
+ this.capturedRequests.splice(0, this.capturedRequests.length - 200);
710
+ });
711
+ }
712
+ resetCapture(capture = []) {
713
+ this.captureConsoleEnabled = capture.includes('console');
714
+ this.captureNetworkEnabled = capture.includes('network');
715
+ this.capturedConsole = [];
716
+ this.capturedRequests = [];
717
+ }
718
+ resolveTarget(page, target) {
719
+ if (!target || (typeof target !== 'string' && typeof target !== 'object'))
720
+ throw new Error('browser target must be a selector string or locator object');
721
+ const spec = typeof target === 'string' ? { selector: target } : target;
722
+ const modes = [spec.selector, spec.role, spec.text, spec.label].filter(value => value !== undefined);
723
+ if (modes.length !== 1)
724
+ throw new Error('browser target requires exactly one of selector, role, text, or label');
725
+ if (spec.name !== undefined && spec.role === undefined)
726
+ throw new Error('browser target name is only valid with role');
727
+ let root = page;
728
+ if (spec.frame) {
729
+ const frameModes = [spec.frame.selector, spec.frame.name, spec.frame.url].filter(value => value !== undefined);
730
+ if (frameModes.length !== 1)
731
+ throw new Error('browser frame requires exactly one of selector, name, or url');
732
+ if (spec.frame.selector)
733
+ root = page.frameLocator(boundedString(spec.frame.selector, 'frame selector', 500));
734
+ else {
735
+ const frame = page.frame(spec.frame.name
736
+ ? { name: boundedString(spec.frame.name, 'frame name', 500) }
737
+ : { url: boundedString(spec.frame.url, 'frame url', 2_000) });
738
+ if (!frame)
739
+ throw new Error('browser target frame was not found');
740
+ root = frame;
741
+ }
742
+ }
743
+ if (spec.selector)
744
+ return root.locator(boundedString(spec.selector, 'selector', 500));
745
+ if (spec.role)
746
+ return root.getByRole(boundedString(spec.role, 'role', 100), {
747
+ ...spec.name !== undefined ? { name: boundedString(spec.name, 'role name', 2_000) } : {},
748
+ exact: spec.exact ?? false,
749
+ });
750
+ if (spec.text)
751
+ return root.getByText(boundedString(spec.text, 'text locator', 2_000), { exact: spec.exact ?? false });
752
+ return root.getByLabel(boundedString(spec.label, 'label locator', 2_000), { exact: spec.exact ?? false });
753
+ }
754
+ screenshotFile(options) {
755
+ const filename = options.filename ?? `shot-${Date.now()}-${uid().slice(0, 8)}.${options.format === 'jpeg' ? 'jpg' : 'png'}`;
756
+ if (filename !== path.basename(filename) || !/^[\w.() -]{1,160}$/.test(filename)) {
757
+ throw new Error('browser_screenshot filename must be a plain file name inside snapshotDir');
758
+ }
759
+ const extension = path.extname(filename).toLowerCase();
760
+ const inferred = extension === '.jpg' || extension === '.jpeg' ? 'jpeg' : extension === '.png' ? 'png' : undefined;
761
+ const format = options.format ?? inferred ?? 'png';
762
+ if (inferred && inferred !== format)
763
+ throw new Error('browser_screenshot filename extension does not match format');
764
+ if (!inferred)
765
+ throw new Error('browser_screenshot filename must end in .png, .jpg, or .jpeg');
766
+ return { file: path.join(this.config.snapshotDir, filename), format };
767
+ }
768
+ async captureScreenshot(page, options = {}) {
572
769
  fs.mkdirSync(this.config.snapshotDir, { recursive: true });
573
- const file = path.join(this.config.snapshotDir, 'shot-' + Date.now() + '-' + uid().slice(0, 8) + '.png');
574
- await page.screenshot({ path: file, fullPage: true });
770
+ if (options.target && options.clip)
771
+ throw new Error('browser_screenshot cannot combine target and clip');
772
+ if (options.target && options.fullPage)
773
+ throw new Error('browser_screenshot cannot combine target and fullPage');
774
+ if (options.quality !== undefined && (!Number.isInteger(options.quality) || options.quality < 0 || options.quality > 100)) {
775
+ throw new Error('browser_screenshot quality must be an integer from 0 to 100');
776
+ }
777
+ const { file, format } = this.screenshotFile(options);
778
+ if (format === 'png' && options.quality !== undefined)
779
+ throw new Error('browser_screenshot quality is only supported for jpeg');
780
+ const screenshotOptions = {
781
+ path: file,
782
+ type: format,
783
+ ...options.quality !== undefined ? { quality: options.quality } : {},
784
+ };
785
+ if (options.clip) {
786
+ const { x, y, width, height } = options.clip;
787
+ if (![x, y, width, height].every(Number.isFinite) || x < 0 || y < 0 || width <= 0 || height <= 0 || width > 20_000 || height > 20_000) {
788
+ throw new Error('browser_screenshot clip must use finite non-negative coordinates and dimensions from 1 to 20,000');
789
+ }
790
+ screenshotOptions.clip = options.clip;
791
+ }
792
+ else if (!options.target) {
793
+ screenshotOptions.fullPage = options.fullPage ?? true;
794
+ }
795
+ if (options.target)
796
+ await this.resolveTarget(page, options.target).screenshot(screenshotOptions);
797
+ else
798
+ await page.screenshot(screenshotOptions);
575
799
  return file;
576
800
  }
577
801
  async readState(page, includeScreenshot) {
@@ -587,6 +811,7 @@ export class BrowserService {
587
811
  }
588
812
  async open(url, opts = {}) {
589
813
  const page = await this.ensureActivePage(url, opts);
814
+ this.resetCapture(opts.capture);
590
815
  page.setDefaultTimeout(30_000);
591
816
  await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
592
817
  await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
@@ -595,32 +820,75 @@ export class BrowserService {
595
820
  await page.waitForTimeout(opts.waitMs);
596
821
  return this.readState(page, true);
597
822
  }
598
- async click(selector, opts = {}) {
823
+ async click(target, opts = {}) {
599
824
  const page = await this.ensureActivePage();
600
- await page.waitForSelector(selector, { timeout: opts.timeoutMs ?? 15_000 });
601
- await page.click(selector);
825
+ await this.resolveTarget(page, target).click({ timeout: boundedTimeout(opts.timeoutMs, 'browser_click timeoutMs') });
602
826
  if (opts.waitMs !== undefined)
603
827
  await page.waitForTimeout(opts.waitMs);
604
828
  else
605
829
  await page.waitForTimeout(500);
606
830
  return this.readState(page, true);
607
831
  }
608
- async type(selector, text, opts = {}) {
832
+ async type(target, text, opts = {}) {
833
+ const page = await this.ensureActivePage();
834
+ await this.resolveTarget(page, target).fill(text, { timeout: boundedTimeout(opts.timeoutMs, 'browser_type timeoutMs') });
835
+ return this.readState(page, false);
836
+ }
837
+ async wait(target, opts = {}) {
609
838
  const page = await this.ensureActivePage();
610
- await page.waitForSelector(selector, { timeout: opts.timeoutMs ?? 15_000 });
611
- await page.fill(selector, text);
839
+ const modes = [target !== undefined, opts.urlPattern !== undefined, opts.networkIdle === true, opts.timeMs !== undefined].filter(Boolean);
840
+ if (modes.length !== 1)
841
+ throw new Error('browser_wait requires exactly one target, urlPattern, networkIdle=true, or timeMs');
842
+ const timeout = boundedTimeout(opts.timeoutMs, 'browser_wait timeoutMs');
843
+ if (target !== undefined)
844
+ await this.resolveTarget(page, target).waitFor({ state: opts.state ?? 'visible', timeout });
845
+ else if (opts.urlPattern !== undefined)
846
+ await page.waitForURL(boundedString(opts.urlPattern, 'urlPattern', 2_000), { timeout });
847
+ else if (opts.networkIdle)
848
+ await page.waitForLoadState('networkidle', { timeout });
849
+ else {
850
+ const timeMs = opts.timeMs;
851
+ if (!Number.isFinite(timeMs) || timeMs < 0 || timeMs > 10_000)
852
+ throw new Error('browser_wait timeMs must be between 0 and 10,000');
853
+ await page.waitForTimeout(timeMs);
854
+ }
612
855
  return this.readState(page, false);
613
856
  }
614
- async hover(selector, opts = {}) {
857
+ async press(target, key, opts = {}) {
615
858
  const page = await this.ensureActivePage();
616
- const timeoutMs = opts.timeoutMs ?? 15_000;
859
+ const value = boundedString(key, 'browser_press key', 100);
860
+ if (target !== undefined)
861
+ await this.resolveTarget(page, target).press(value, { timeout: boundedTimeout(opts.timeoutMs, 'browser_press timeoutMs') });
862
+ else
863
+ await page.keyboard.press(value);
864
+ return this.readState(page, false);
865
+ }
866
+ async select(target, values, opts = {}) {
867
+ if (values.length < 1 || values.length > 20)
868
+ throw new Error('browser_select requires 1 to 20 values');
869
+ values.forEach(value => boundedString(value, 'browser_select value', 2_000));
870
+ const page = await this.ensureActivePage();
871
+ await this.resolveTarget(page, target).selectOption([...values], { timeout: boundedTimeout(opts.timeoutMs, 'browser_select timeoutMs') });
872
+ return this.readState(page, false);
873
+ }
874
+ async check(target, checked = true, opts = {}) {
875
+ const page = await this.ensureActivePage();
876
+ const locator = this.resolveTarget(page, target);
877
+ if (checked)
878
+ await locator.check({ timeout: boundedTimeout(opts.timeoutMs, 'browser_check timeoutMs') });
879
+ else
880
+ await locator.uncheck({ timeout: boundedTimeout(opts.timeoutMs, 'browser_check timeoutMs') });
881
+ return this.readState(page, false);
882
+ }
883
+ async hover(target, opts = {}) {
884
+ const page = await this.ensureActivePage();
885
+ const timeoutMs = boundedTimeout(opts.timeoutMs, 'browser_hover timeoutMs');
617
886
  const waitMs = Math.min(Math.max(opts.waitMs ?? 300, 0), timeoutMs);
618
- await page.waitForSelector(selector, { timeout: timeoutMs });
619
- await page.hover(selector);
887
+ await this.resolveTarget(page, target).hover({ timeout: timeoutMs });
620
888
  await page.waitForTimeout(waitMs);
621
889
  return this.readState(page, true);
622
890
  }
623
- async setFiles(selector, files, opts = {}) {
891
+ async setFiles(target, files, opts = {}) {
624
892
  if (files.length === 0 || files.length > 20)
625
893
  throw new Error('browser_set_files requires 1 to 20 files');
626
894
  const resolved = files.map(file => {
@@ -635,8 +903,7 @@ export class BrowserService {
635
903
  if (totalBytes > 512 * 1024 * 1024)
636
904
  throw new Error('browser_set_files total upload size exceeds 512 MiB');
637
905
  const page = await this.ensureActivePage();
638
- await page.waitForSelector(selector, { timeout: opts.timeoutMs ?? 15_000 });
639
- await page.setInputFiles(selector, resolved);
906
+ await this.resolveTarget(page, target).setInputFiles(resolved, { timeout: boundedTimeout(opts.timeoutMs, 'browser_set_files timeoutMs') });
640
907
  return { ...await this.readState(page, true), files: resolved.map(file => path.basename(file)) };
641
908
  }
642
909
  async evaluate(expression, opts = {}) {
@@ -648,15 +915,38 @@ export class BrowserService {
648
915
  const page = await this.ensureActivePage();
649
916
  const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? 15_000, 1_000), 30_000);
650
917
  let timer;
918
+ let timedOut = false;
651
919
  const timeout = new Promise((_resolve, reject) => {
652
920
  timer = setTimeout(() => {
653
- void this.closePage();
654
- reject(new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; the active page was closed'));
921
+ timedOut = true;
922
+ void (async () => {
923
+ try {
924
+ const session = await page.context().newCDPSession(page);
925
+ try {
926
+ await session.send('Runtime.terminateExecution');
927
+ }
928
+ finally {
929
+ await session.detach().catch(() => { });
930
+ }
931
+ reject(new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open'));
932
+ }
933
+ catch (error) {
934
+ reject(new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; unable to terminate page JavaScript without closing the active page: ' + String(error).slice(0, 200)));
935
+ }
936
+ })();
655
937
  }, timeoutMs);
656
938
  });
657
939
  try {
658
940
  const script = `(async () => {\nconst value = await (${source}\n);\nconst json = JSON.stringify(value);\nif (json === undefined) throw new Error('expression result is not JSON-serializable');\nreturn { resultJson: json.slice(0, 100000), truncated: json.length > 100000 };\n})()`;
659
- const result = await Promise.race([page.evaluate(script), timeout]);
941
+ let result;
942
+ try {
943
+ result = await Promise.race([page.evaluate(script), timeout]);
944
+ }
945
+ catch (error) {
946
+ if (timedOut)
947
+ throw new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open');
948
+ throw error;
949
+ }
660
950
  return {
661
951
  url: page.url(),
662
952
  resultJson: result.resultJson,
@@ -684,9 +974,30 @@ export class BrowserService {
684
974
  const page = await this.ensureActivePage();
685
975
  return this.readState(page, false);
686
976
  }
687
- async screenshot() {
977
+ consoleMessages(opts = {}) {
978
+ const severities = ['debug', 'log', 'info', 'warning', 'error'];
979
+ const threshold = opts.level ? severities.indexOf(opts.level) : 0;
980
+ if (opts.level && threshold < 0)
981
+ throw new Error('browser_console level must be debug, log, info, warning, or error');
982
+ const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
983
+ const records = this.capturedConsole.filter(record => {
984
+ const index = severities.indexOf(record.type === 'warn' ? 'warning' : record.type);
985
+ return index < 0 || index >= threshold;
986
+ }).slice(-limit);
987
+ if (opts.clear)
988
+ this.capturedConsole = [];
989
+ return { enabled: this.captureConsoleEnabled, records };
990
+ }
991
+ networkRequests(opts = {}) {
992
+ const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
993
+ const records = this.capturedRequests.slice(-limit);
994
+ if (opts.clear)
995
+ this.capturedRequests = [];
996
+ return { enabled: this.captureNetworkEnabled, records };
997
+ }
998
+ async screenshot(options = {}) {
688
999
  const page = await this.ensureActivePage();
689
- return { path: await this.captureScreenshot(page) };
1000
+ return { path: await this.captureScreenshot(page, options) };
690
1001
  }
691
1002
  async recipe(steps, opts = {}) {
692
1003
  if (!opts.url && (!this.activePage || this.activePage.isClosed()))
@@ -714,15 +1025,14 @@ export class BrowserService {
714
1025
  }
715
1026
  }
716
1027
  async closePage() {
717
- if (this.activePage) {
718
- await this.activePage.close().catch(() => { });
719
- }
720
- this.activePage = undefined;
721
- if (this.activeContext)
722
- await this.persistAndClose({ context: this.activeContext, ...this.activeProfile ? { profile: this.activeProfile } : {} });
723
- this.activeContext = undefined;
724
- this.activeProfile = undefined;
725
- this.activeRulePack = undefined;
1028
+ const page = this.activePage;
1029
+ const context = this.activeContext;
1030
+ const profile = this.activeProfile;
1031
+ this.clearActiveState();
1032
+ if (page)
1033
+ await page.close().catch(() => { });
1034
+ if (context)
1035
+ await this.persistAndClose({ context, ...profile ? { profile } : {} });
726
1036
  }
727
1037
  async status() {
728
1038
  let chromiumInstalled = false;
@@ -786,8 +1096,8 @@ export class BrowserService {
786
1096
  builtinScripts: BUILTIN_SCRIPTS.map(script => script.id),
787
1097
  externalUserscriptsRequireApproval: ['standard', 'autonomous'].includes(this.config.automationMode),
788
1098
  mutatingRecipesRequireApproval: this.config.automationMode === 'standard',
789
- ...(this.activePage && !this.activePage.isClosed() ? { activeUrl: this.activePage.url() } : {}),
790
- ...this.activeProfile ? { activeAuthProfile: this.activeProfile.id } : {},
1099
+ ...(this.browserConnected() && this.activePage && !this.activePage.isClosed() ? { activeUrl: this.activePage.url() } : {}),
1100
+ ...(this.browserConnected() && this.activePage && !this.activePage.isClosed() && this.activeProfile ? { activeAuthProfile: this.activeProfile.id } : {}),
791
1101
  };
792
1102
  }
793
1103
  async close() {