@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.
- package/LICENSE +21 -21
- package/README.md +393 -382
- package/cordis.patch.yml +26 -26
- package/lib/approval-policy.js +1 -1
- package/lib/approval-policy.js.map +1 -1
- package/lib/browser-service.d.ts +86 -5
- package/lib/browser-service.js +326 -78
- package/lib/browser-service.js.map +1 -1
- package/lib/client/styles.js +12 -12
- package/lib/freedom.d.ts +1 -1
- package/lib/freedom.js +9 -0
- package/lib/freedom.js.map +1 -1
- package/lib/scripts.js +27 -27
- package/lib/tools.js +188 -28
- package/lib/tools.js.map +1 -1
- package/package.json +147 -150
- package/scripts/check-client-bundle.mjs +15 -15
- package/scripts/clean-lib.mjs +3 -3
- package/scripts/install-browser.mjs +18 -18
package/lib/browser-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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(
|
|
759
|
+
async click(target, opts = {}) {
|
|
599
760
|
const page = await this.ensureActivePage();
|
|
600
|
-
await
|
|
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(
|
|
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
|
-
|
|
611
|
-
|
|
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(
|
|
819
|
+
async hover(target, opts = {}) {
|
|
615
820
|
const page = await this.ensureActivePage();
|
|
616
|
-
const timeoutMs = opts.timeoutMs
|
|
821
|
+
const timeoutMs = boundedTimeout(opts.timeoutMs, 'browser_hover timeoutMs');
|
|
617
822
|
const waitMs = Math.min(Math.max(opts.waitMs ?? 300, 0), timeoutMs);
|
|
618
|
-
await
|
|
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(
|
|
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.
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|