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

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);
@@ -566,12 +599,139 @@ export class BrowserService {
566
599
  this.activeContext = await browser.newContext(storageStateOptions(this.config.storageStatePath, 'global'));
567
600
  }
568
601
  this.activePage = await this.activeContext.newPage();
602
+ this.attachCapture(this.activePage);
569
603
  return this.activePage;
570
604
  }
571
- async captureScreenshot(page) {
605
+ attachCapture(page) {
606
+ page.on('console', (message) => {
607
+ if (!this.captureConsoleEnabled)
608
+ return;
609
+ const location = message.location?.();
610
+ this.capturedConsole.push({
611
+ type: String(message.type?.() ?? 'log').slice(0, 40),
612
+ text: redactCaptureText(String(message.text?.() ?? '')),
613
+ ...location?.url ? { url: redactCaptureUrl(location.url) } : {},
614
+ timestamp: new Date().toISOString(),
615
+ });
616
+ if (this.capturedConsole.length > 200)
617
+ this.capturedConsole.splice(0, this.capturedConsole.length - 200);
618
+ });
619
+ page.on('response', (response) => {
620
+ if (!this.captureNetworkEnabled)
621
+ return;
622
+ const status = Number(response.status?.() ?? 0);
623
+ if (status < 400)
624
+ return;
625
+ const request = response.request?.();
626
+ this.capturedRequests.push({
627
+ method: String(request?.method?.() ?? 'GET').slice(0, 20),
628
+ url: redactCaptureUrl(String(response.url?.() ?? '')),
629
+ status,
630
+ timestamp: new Date().toISOString(),
631
+ });
632
+ if (this.capturedRequests.length > 200)
633
+ this.capturedRequests.splice(0, this.capturedRequests.length - 200);
634
+ });
635
+ page.on('requestfailed', (request) => {
636
+ if (!this.captureNetworkEnabled)
637
+ return;
638
+ this.capturedRequests.push({
639
+ method: String(request.method?.() ?? 'GET').slice(0, 20),
640
+ url: redactCaptureUrl(String(request.url?.() ?? '')),
641
+ failure: redactCaptureText(String(request.failure?.()?.errorText ?? 'request failed'), 500),
642
+ timestamp: new Date().toISOString(),
643
+ });
644
+ if (this.capturedRequests.length > 200)
645
+ this.capturedRequests.splice(0, this.capturedRequests.length - 200);
646
+ });
647
+ }
648
+ resetCapture(capture = []) {
649
+ this.captureConsoleEnabled = capture.includes('console');
650
+ this.captureNetworkEnabled = capture.includes('network');
651
+ this.capturedConsole = [];
652
+ this.capturedRequests = [];
653
+ }
654
+ resolveTarget(page, target) {
655
+ if (!target || (typeof target !== 'string' && typeof target !== 'object'))
656
+ throw new Error('browser target must be a selector string or locator object');
657
+ const spec = typeof target === 'string' ? { selector: target } : target;
658
+ const modes = [spec.selector, spec.role, spec.text, spec.label].filter(value => value !== undefined);
659
+ if (modes.length !== 1)
660
+ throw new Error('browser target requires exactly one of selector, role, text, or label');
661
+ if (spec.name !== undefined && spec.role === undefined)
662
+ throw new Error('browser target name is only valid with role');
663
+ let root = page;
664
+ if (spec.frame) {
665
+ const frameModes = [spec.frame.selector, spec.frame.name, spec.frame.url].filter(value => value !== undefined);
666
+ if (frameModes.length !== 1)
667
+ throw new Error('browser frame requires exactly one of selector, name, or url');
668
+ if (spec.frame.selector)
669
+ root = page.frameLocator(boundedString(spec.frame.selector, 'frame selector', 500));
670
+ else {
671
+ const frame = page.frame(spec.frame.name
672
+ ? { name: boundedString(spec.frame.name, 'frame name', 500) }
673
+ : { url: boundedString(spec.frame.url, 'frame url', 2_000) });
674
+ if (!frame)
675
+ throw new Error('browser target frame was not found');
676
+ root = frame;
677
+ }
678
+ }
679
+ if (spec.selector)
680
+ return root.locator(boundedString(spec.selector, 'selector', 500));
681
+ if (spec.role)
682
+ return root.getByRole(boundedString(spec.role, 'role', 100), {
683
+ ...spec.name !== undefined ? { name: boundedString(spec.name, 'role name', 2_000) } : {},
684
+ exact: spec.exact ?? false,
685
+ });
686
+ if (spec.text)
687
+ return root.getByText(boundedString(spec.text, 'text locator', 2_000), { exact: spec.exact ?? false });
688
+ return root.getByLabel(boundedString(spec.label, 'label locator', 2_000), { exact: spec.exact ?? false });
689
+ }
690
+ screenshotFile(options) {
691
+ const filename = options.filename ?? `shot-${Date.now()}-${uid().slice(0, 8)}.${options.format === 'jpeg' ? 'jpg' : 'png'}`;
692
+ if (filename !== path.basename(filename) || !/^[\w.() -]{1,160}$/.test(filename)) {
693
+ throw new Error('browser_screenshot filename must be a plain file name inside snapshotDir');
694
+ }
695
+ const extension = path.extname(filename).toLowerCase();
696
+ const inferred = extension === '.jpg' || extension === '.jpeg' ? 'jpeg' : extension === '.png' ? 'png' : undefined;
697
+ const format = options.format ?? inferred ?? 'png';
698
+ if (inferred && inferred !== format)
699
+ throw new Error('browser_screenshot filename extension does not match format');
700
+ if (!inferred)
701
+ throw new Error('browser_screenshot filename must end in .png, .jpg, or .jpeg');
702
+ return { file: path.join(this.config.snapshotDir, filename), format };
703
+ }
704
+ async captureScreenshot(page, options = {}) {
572
705
  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 });
706
+ if (options.target && options.clip)
707
+ throw new Error('browser_screenshot cannot combine target and clip');
708
+ if (options.target && options.fullPage)
709
+ throw new Error('browser_screenshot cannot combine target and fullPage');
710
+ if (options.quality !== undefined && (!Number.isInteger(options.quality) || options.quality < 0 || options.quality > 100)) {
711
+ throw new Error('browser_screenshot quality must be an integer from 0 to 100');
712
+ }
713
+ const { file, format } = this.screenshotFile(options);
714
+ if (format === 'png' && options.quality !== undefined)
715
+ throw new Error('browser_screenshot quality is only supported for jpeg');
716
+ const screenshotOptions = {
717
+ path: file,
718
+ type: format,
719
+ ...options.quality !== undefined ? { quality: options.quality } : {},
720
+ };
721
+ if (options.clip) {
722
+ const { x, y, width, height } = options.clip;
723
+ if (![x, y, width, height].every(Number.isFinite) || x < 0 || y < 0 || width <= 0 || height <= 0 || width > 20_000 || height > 20_000) {
724
+ throw new Error('browser_screenshot clip must use finite non-negative coordinates and dimensions from 1 to 20,000');
725
+ }
726
+ screenshotOptions.clip = options.clip;
727
+ }
728
+ else if (!options.target) {
729
+ screenshotOptions.fullPage = options.fullPage ?? true;
730
+ }
731
+ if (options.target)
732
+ await this.resolveTarget(page, options.target).screenshot(screenshotOptions);
733
+ else
734
+ await page.screenshot(screenshotOptions);
575
735
  return file;
576
736
  }
577
737
  async readState(page, includeScreenshot) {
@@ -587,6 +747,7 @@ export class BrowserService {
587
747
  }
588
748
  async open(url, opts = {}) {
589
749
  const page = await this.ensureActivePage(url, opts);
750
+ this.resetCapture(opts.capture);
590
751
  page.setDefaultTimeout(30_000);
591
752
  await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
592
753
  await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
@@ -595,32 +756,75 @@ export class BrowserService {
595
756
  await page.waitForTimeout(opts.waitMs);
596
757
  return this.readState(page, true);
597
758
  }
598
- async click(selector, opts = {}) {
759
+ async click(target, opts = {}) {
599
760
  const page = await this.ensureActivePage();
600
- await page.waitForSelector(selector, { timeout: opts.timeoutMs ?? 15_000 });
601
- await page.click(selector);
761
+ await this.resolveTarget(page, target).click({ timeout: boundedTimeout(opts.timeoutMs, 'browser_click timeoutMs') });
602
762
  if (opts.waitMs !== undefined)
603
763
  await page.waitForTimeout(opts.waitMs);
604
764
  else
605
765
  await page.waitForTimeout(500);
606
766
  return this.readState(page, true);
607
767
  }
608
- async type(selector, text, opts = {}) {
768
+ async type(target, text, opts = {}) {
769
+ const page = await this.ensureActivePage();
770
+ await this.resolveTarget(page, target).fill(text, { timeout: boundedTimeout(opts.timeoutMs, 'browser_type timeoutMs') });
771
+ return this.readState(page, false);
772
+ }
773
+ async wait(target, opts = {}) {
774
+ const page = await this.ensureActivePage();
775
+ const modes = [target !== undefined, opts.urlPattern !== undefined, opts.networkIdle === true, opts.timeMs !== undefined].filter(Boolean);
776
+ if (modes.length !== 1)
777
+ throw new Error('browser_wait requires exactly one target, urlPattern, networkIdle=true, or timeMs');
778
+ const timeout = boundedTimeout(opts.timeoutMs, 'browser_wait timeoutMs');
779
+ if (target !== undefined)
780
+ await this.resolveTarget(page, target).waitFor({ state: opts.state ?? 'visible', timeout });
781
+ else if (opts.urlPattern !== undefined)
782
+ await page.waitForURL(boundedString(opts.urlPattern, 'urlPattern', 2_000), { timeout });
783
+ else if (opts.networkIdle)
784
+ await page.waitForLoadState('networkidle', { timeout });
785
+ else {
786
+ const timeMs = opts.timeMs;
787
+ if (!Number.isFinite(timeMs) || timeMs < 0 || timeMs > 10_000)
788
+ throw new Error('browser_wait timeMs must be between 0 and 10,000');
789
+ await page.waitForTimeout(timeMs);
790
+ }
791
+ return this.readState(page, false);
792
+ }
793
+ async press(target, key, opts = {}) {
794
+ const page = await this.ensureActivePage();
795
+ const value = boundedString(key, 'browser_press key', 100);
796
+ if (target !== undefined)
797
+ await this.resolveTarget(page, target).press(value, { timeout: boundedTimeout(opts.timeoutMs, 'browser_press timeoutMs') });
798
+ else
799
+ await page.keyboard.press(value);
800
+ return this.readState(page, false);
801
+ }
802
+ async select(target, values, opts = {}) {
803
+ if (values.length < 1 || values.length > 20)
804
+ throw new Error('browser_select requires 1 to 20 values');
805
+ values.forEach(value => boundedString(value, 'browser_select value', 2_000));
806
+ const page = await this.ensureActivePage();
807
+ await this.resolveTarget(page, target).selectOption([...values], { timeout: boundedTimeout(opts.timeoutMs, 'browser_select timeoutMs') });
808
+ return this.readState(page, false);
809
+ }
810
+ async check(target, checked = true, opts = {}) {
609
811
  const page = await this.ensureActivePage();
610
- await page.waitForSelector(selector, { timeout: opts.timeoutMs ?? 15_000 });
611
- await page.fill(selector, text);
812
+ const locator = this.resolveTarget(page, target);
813
+ if (checked)
814
+ await locator.check({ timeout: boundedTimeout(opts.timeoutMs, 'browser_check timeoutMs') });
815
+ else
816
+ await locator.uncheck({ timeout: boundedTimeout(opts.timeoutMs, 'browser_check timeoutMs') });
612
817
  return this.readState(page, false);
613
818
  }
614
- async hover(selector, opts = {}) {
819
+ async hover(target, opts = {}) {
615
820
  const page = await this.ensureActivePage();
616
- const timeoutMs = opts.timeoutMs ?? 15_000;
821
+ const timeoutMs = boundedTimeout(opts.timeoutMs, 'browser_hover timeoutMs');
617
822
  const waitMs = Math.min(Math.max(opts.waitMs ?? 300, 0), timeoutMs);
618
- await page.waitForSelector(selector, { timeout: timeoutMs });
619
- await page.hover(selector);
823
+ await this.resolveTarget(page, target).hover({ timeout: timeoutMs });
620
824
  await page.waitForTimeout(waitMs);
621
825
  return this.readState(page, true);
622
826
  }
623
- async setFiles(selector, files, opts = {}) {
827
+ async setFiles(target, files, opts = {}) {
624
828
  if (files.length === 0 || files.length > 20)
625
829
  throw new Error('browser_set_files requires 1 to 20 files');
626
830
  const resolved = files.map(file => {
@@ -635,8 +839,7 @@ export class BrowserService {
635
839
  if (totalBytes > 512 * 1024 * 1024)
636
840
  throw new Error('browser_set_files total upload size exceeds 512 MiB');
637
841
  const page = await this.ensureActivePage();
638
- await page.waitForSelector(selector, { timeout: opts.timeoutMs ?? 15_000 });
639
- await page.setInputFiles(selector, resolved);
842
+ await this.resolveTarget(page, target).setInputFiles(resolved, { timeout: boundedTimeout(opts.timeoutMs, 'browser_set_files timeoutMs') });
640
843
  return { ...await this.readState(page, true), files: resolved.map(file => path.basename(file)) };
641
844
  }
642
845
  async evaluate(expression, opts = {}) {
@@ -648,15 +851,38 @@ export class BrowserService {
648
851
  const page = await this.ensureActivePage();
649
852
  const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? 15_000, 1_000), 30_000);
650
853
  let timer;
854
+ let timedOut = false;
651
855
  const timeout = new Promise((_resolve, reject) => {
652
856
  timer = setTimeout(() => {
653
- void this.closePage();
654
- reject(new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; the active page was closed'));
857
+ timedOut = true;
858
+ void (async () => {
859
+ try {
860
+ const session = await page.context().newCDPSession(page);
861
+ try {
862
+ await session.send('Runtime.terminateExecution');
863
+ }
864
+ finally {
865
+ await session.detach().catch(() => { });
866
+ }
867
+ reject(new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open'));
868
+ }
869
+ catch (error) {
870
+ 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)));
871
+ }
872
+ })();
655
873
  }, timeoutMs);
656
874
  });
657
875
  try {
658
876
  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]);
877
+ let result;
878
+ try {
879
+ result = await Promise.race([page.evaluate(script), timeout]);
880
+ }
881
+ catch (error) {
882
+ if (timedOut)
883
+ throw new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open');
884
+ throw error;
885
+ }
660
886
  return {
661
887
  url: page.url(),
662
888
  resultJson: result.resultJson,
@@ -684,9 +910,30 @@ export class BrowserService {
684
910
  const page = await this.ensureActivePage();
685
911
  return this.readState(page, false);
686
912
  }
687
- async screenshot() {
913
+ consoleMessages(opts = {}) {
914
+ const severities = ['debug', 'log', 'info', 'warning', 'error'];
915
+ const threshold = opts.level ? severities.indexOf(opts.level) : 0;
916
+ if (opts.level && threshold < 0)
917
+ throw new Error('browser_console level must be debug, log, info, warning, or error');
918
+ const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
919
+ const records = this.capturedConsole.filter(record => {
920
+ const index = severities.indexOf(record.type === 'warn' ? 'warning' : record.type);
921
+ return index < 0 || index >= threshold;
922
+ }).slice(-limit);
923
+ if (opts.clear)
924
+ this.capturedConsole = [];
925
+ return { enabled: this.captureConsoleEnabled, records };
926
+ }
927
+ networkRequests(opts = {}) {
928
+ const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
929
+ const records = this.capturedRequests.slice(-limit);
930
+ if (opts.clear)
931
+ this.capturedRequests = [];
932
+ return { enabled: this.captureNetworkEnabled, records };
933
+ }
934
+ async screenshot(options = {}) {
688
935
  const page = await this.ensureActivePage();
689
- return { path: await this.captureScreenshot(page) };
936
+ return { path: await this.captureScreenshot(page, options) };
690
937
  }
691
938
  async recipe(steps, opts = {}) {
692
939
  if (!opts.url && (!this.activePage || this.activePage.isClosed()))
@@ -723,6 +970,7 @@ export class BrowserService {
723
970
  this.activeContext = undefined;
724
971
  this.activeProfile = undefined;
725
972
  this.activeRulePack = undefined;
973
+ this.resetCapture();
726
974
  }
727
975
  async status() {
728
976
  let chromiumInstalled = false;