@mehmoodqureshi/chrome-mcp 0.9.1 → 0.9.3
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/README.md +44 -2
- package/dist/shared/auth-wall.d.ts +51 -0
- package/dist/shared/auth-wall.js +140 -0
- package/dist/shared/page-fns.js +208 -182
- package/dist/shared/screenshot.d.ts +14 -0
- package/dist/shared/screenshot.js +19 -4
- package/dist/shared/snapshot.d.ts +24 -2
- package/dist/shared/snapshot.js +117 -33
- package/dist/src/bridge/connection.d.ts +13 -0
- package/dist/src/bridge/connection.js +55 -1
- package/dist/src/bridge/server.d.ts +2 -0
- package/dist/src/bridge/server.js +5 -0
- package/dist/src/bridge/workspace.d.ts +1 -1
- package/dist/src/bridge/workspace.js +2 -2
- package/dist/src/config.js +7 -0
- package/dist/src/executor/cdp-executor.d.ts +3 -2
- package/dist/src/executor/cdp-executor.js +21 -6
- package/dist/src/executor/extension-executor.d.ts +6 -2
- package/dist/src/executor/extension-executor.js +18 -5
- package/dist/src/executor/stub-executor.d.ts +15 -0
- package/dist/src/executor/stub-executor.js +26 -0
- package/dist/src/executor/types.d.ts +28 -3
- package/dist/src/mcp/locate.d.ts +7 -4
- package/dist/src/mcp/locate.js +15 -8
- package/dist/src/mcp/tools.js +137 -40
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +2 -0
- package/docs/BLUEPRINT.md +2 -2
- package/extension-dist/background.js +494 -263
- package/extension-dist/manifest.json +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -333,6 +333,46 @@ type { "role": "textbox", "name": "Email", "text": "a@b.com" }
|
|
|
333
333
|
Resolution is server-side and refuses to guess: an ambiguous locator fails with
|
|
334
334
|
the candidates listed rather than acting on the first one (pass `nth` to pick).
|
|
335
335
|
|
|
336
|
+
### Did the session expire? — `auth_check` and `failOnAuthWall`
|
|
337
|
+
|
|
338
|
+
Reusing a signed-in Chrome removes the login step, but a session cookie can
|
|
339
|
+
still expire mid-run. Without a distinct signal the next step fails as
|
|
340
|
+
`SELECTOR_NOT_FOUND` or `TIMEOUT`, and an eval harness scores the run as an
|
|
341
|
+
agent failure when it was an auth failure. Every `snapshot` now carries an
|
|
342
|
+
`authWall` verdict when the page looks like a sign-in wall, and there is a
|
|
343
|
+
dedicated probe:
|
|
344
|
+
|
|
345
|
+
```jsonc
|
|
346
|
+
auth_check {} // { authRequired, confidence, signals }
|
|
347
|
+
auth_check { "failOnAuthWall": true } // [AUTH_REQUIRED] error instead
|
|
348
|
+
navigate { "url": "https://app.example.com/dashboard", "failOnAuthWall": true }
|
|
349
|
+
snapshot { "failOnAuthWall": true }
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
For a harness, set it once instead of per call:
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
npx -y @mehmoodqureshi/chrome-mcp --allow-domain app.example.com --enable-mutations --fail-on-auth-wall
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
With the flag on, every step that can move the tab (`navigate`, `click`, `type`,
|
|
359
|
+
`select_option`, `press`, `fill_form`, `back`, `forward`, `reload`) checks the
|
|
360
|
+
page it landed on and fails with `[AUTH_REQUIRED]` if that page is a sign-in
|
|
361
|
+
wall, and a `wait_for` that times out on such a page reports `[AUTH_REQUIRED]`
|
|
362
|
+
instead of `[TIMEOUT]`. Each guarded step costs one extra snapshot round-trip;
|
|
363
|
+
with the flag off the cost is zero. `[AUTH_REQUIRED]` is where a harness pauses
|
|
364
|
+
for a human to sign in again in the same Chrome, then retries the step. chrome-mcp
|
|
365
|
+
never re-authenticates on its own: it holds no credentials, by design.
|
|
366
|
+
|
|
367
|
+
Detection reads only what the snapshot already has: the URL (sign-in routes,
|
|
368
|
+
identity-provider hosts such as `accounts.google.com`, `login.microsoftonline.com`,
|
|
369
|
+
Okta, Auth0), the title, password fields, and sign-in controls. `high`
|
|
370
|
+
confidence needs two independent cues (a password field plus a sign-in button,
|
|
371
|
+
say); a lone password field or a bare `/auth/...` URL is `medium`. A header
|
|
372
|
+
"Sign in" link on an ordinary page never counts. `failOnAuthWall` fires only on
|
|
373
|
+
`high`, so a harness can bucket `[AUTH_REQUIRED]` separately from every other
|
|
374
|
+
failure while a settings page with a "current password" field carries on.
|
|
375
|
+
|
|
336
376
|
### Printing — `print_pdf`
|
|
337
377
|
|
|
338
378
|
```jsonc
|
|
@@ -462,5 +502,7 @@ the fallback.
|
|
|
462
502
|
> `chrome.debugger` is used only where it's needed and worth it: `trusted: true`
|
|
463
503
|
> input (real OS-level events on React/Vue inputs) and `screenshot` (captures a
|
|
464
504
|
> specific tab **without** activating it — safe under parallel `batch` — with
|
|
465
|
-
> true full-page and element capture). Those ops
|
|
466
|
-
>
|
|
505
|
+
> true full-page and element capture). Those ops show the debug banner while
|
|
506
|
+
> attached; the session lingers 1.5s after the last op so a burst of them
|
|
507
|
+
> attaches once. Screenshots are JPEG (quality 70) at CSS-pixel size by
|
|
508
|
+
> default — pass `format: "png"`, `quality`, or `scale` to change that.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/auth-wall.ts — decide whether a page is a login wall.
|
|
3
|
+
*
|
|
4
|
+
* A browser agent that reuses a signed-in Chrome profile still hits the moment
|
|
5
|
+
* the session cookie expires mid-run: the next page is a sign-in form, and
|
|
6
|
+
* every later step fails for a reason that has nothing to do with the task.
|
|
7
|
+
* Without a distinct signal, an eval harness buckets that as SELECTOR_NOT_FOUND
|
|
8
|
+
* or TIMEOUT and the run is scored as an agent failure instead of an auth
|
|
9
|
+
* failure.
|
|
10
|
+
*
|
|
11
|
+
* This module is the pure decision. It reads only what an accessibility
|
|
12
|
+
* snapshot already carries (URL, title, password fields, button/link names), so
|
|
13
|
+
* it works identically on both backends and needs nothing new from the
|
|
14
|
+
* extension. Server-side callers attach the verdict to snapshot results, expose
|
|
15
|
+
* it as `auth_check`, and can turn it into an `AUTH_REQUIRED` error.
|
|
16
|
+
*/
|
|
17
|
+
export type AuthWallConfidence = 'high' | 'medium';
|
|
18
|
+
export type AuthWallSignal = 'password-field' | 'login-url' | 'identity-provider' | 'login-title' | 'login-button' | 'username-field';
|
|
19
|
+
export interface AuthWall {
|
|
20
|
+
detected: true;
|
|
21
|
+
confidence: AuthWallConfidence;
|
|
22
|
+
signals: AuthWallSignal[];
|
|
23
|
+
}
|
|
24
|
+
/** The subset of a snapshot the detector reads. */
|
|
25
|
+
export interface AuthWallPage {
|
|
26
|
+
url: string;
|
|
27
|
+
title: string;
|
|
28
|
+
nodes: Array<{
|
|
29
|
+
role: string;
|
|
30
|
+
name: string;
|
|
31
|
+
tag: string;
|
|
32
|
+
secret?: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Inspect a page and return a verdict, or `null` when it does not look like a
|
|
37
|
+
* login wall.
|
|
38
|
+
*
|
|
39
|
+
* Confidence:
|
|
40
|
+
* - `high`: a password field plus a sign-in control, title, or URL; or a
|
|
41
|
+
* sign-in URL plus a sign-in control or title; or an identity-provider host.
|
|
42
|
+
* - `medium`: exactly one weaker cue on its own (a password field, or a
|
|
43
|
+
* sign-in URL) with nothing to corroborate it. Callers that fail a run on a
|
|
44
|
+
* wall should usually require `high`.
|
|
45
|
+
*
|
|
46
|
+
* A lone "Sign in" link or a title mention never counts: most public pages have
|
|
47
|
+
* one in the header.
|
|
48
|
+
*/
|
|
49
|
+
export declare function detectAuthWall(page: AuthWallPage): AuthWall | null;
|
|
50
|
+
/** A one-line, human-readable explanation for error messages and logs. */
|
|
51
|
+
export declare function describeAuthWall(wall: AuthWall, url: string): string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/auth-wall.ts — decide whether a page is a login wall.
|
|
4
|
+
*
|
|
5
|
+
* A browser agent that reuses a signed-in Chrome profile still hits the moment
|
|
6
|
+
* the session cookie expires mid-run: the next page is a sign-in form, and
|
|
7
|
+
* every later step fails for a reason that has nothing to do with the task.
|
|
8
|
+
* Without a distinct signal, an eval harness buckets that as SELECTOR_NOT_FOUND
|
|
9
|
+
* or TIMEOUT and the run is scored as an agent failure instead of an auth
|
|
10
|
+
* failure.
|
|
11
|
+
*
|
|
12
|
+
* This module is the pure decision. It reads only what an accessibility
|
|
13
|
+
* snapshot already carries (URL, title, password fields, button/link names), so
|
|
14
|
+
* it works identically on both backends and needs nothing new from the
|
|
15
|
+
* extension. Server-side callers attach the verdict to snapshot results, expose
|
|
16
|
+
* it as `auth_check`, and can turn it into an `AUTH_REQUIRED` error.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.detectAuthWall = detectAuthWall;
|
|
20
|
+
exports.describeAuthWall = describeAuthWall;
|
|
21
|
+
/** Path segments that mark a sign-in route on most stacks. */
|
|
22
|
+
const LOGIN_PATH = /(^|[\/._-])(login|log-in|logon|log-on|signin|sign-in|sign_in|authenticate|auth|sso|oauth2?|saml|sessions?\/new|users\/sign_in|account\/login|idp)([\/?#._-]|$)/i;
|
|
23
|
+
/** Hosts (or host suffixes) that are identity providers, whatever the path. */
|
|
24
|
+
const IDP_HOSTS = [
|
|
25
|
+
'accounts.google.com',
|
|
26
|
+
'login.microsoftonline.com',
|
|
27
|
+
'login.live.com',
|
|
28
|
+
'login.microsoft.com',
|
|
29
|
+
'appleid.apple.com',
|
|
30
|
+
'login.yahoo.com',
|
|
31
|
+
'auth0.com',
|
|
32
|
+
'okta.com',
|
|
33
|
+
'oktapreview.com',
|
|
34
|
+
'onelogin.com',
|
|
35
|
+
'pingidentity.com',
|
|
36
|
+
'duosecurity.com',
|
|
37
|
+
'github.com/login',
|
|
38
|
+
'login.salesforce.com',
|
|
39
|
+
'signin.aws.amazon.com',
|
|
40
|
+
'auth.atlassian.com',
|
|
41
|
+
'id.atlassian.com',
|
|
42
|
+
'login.linkedin.com',
|
|
43
|
+
];
|
|
44
|
+
const LOGIN_TITLE = /\b(sign in|sign-in|signin|log in|log-in|login|log on|logon|authenticate|authentication required|session (has )?expired|session timed out|please sign in|please log in|verify it'?s you)\b/i;
|
|
45
|
+
/** Button/link labels that submit or start a sign-in. Anchored so a header "Sign in" link with extra copy still matches only when the label is the action. */
|
|
46
|
+
const LOGIN_BUTTON = /^(sign in|sign-in|signin|log in|log-in|login|log on|logon|continue|next|submit|authenticate|(sign|log) ?in (with|via|using) .{1,40}|continue with .{1,40}|use (your )?password|sign in to continue|log in to continue)$/i;
|
|
47
|
+
/** Labels that mark a credential's username half; only a booster, never a wall on its own. */
|
|
48
|
+
const USERNAME_FIELD = /\b(email|e-mail|username|user name|user id|userid|phone|account|login id)\b/i;
|
|
49
|
+
/** The action-shaped labels that, next to a password field, make this a form and not a "change password" settings page. */
|
|
50
|
+
const STRONG_BUTTON = /^(sign in|sign-in|signin|log in|log-in|login|log on|logon|authenticate|sign in to continue|log in to continue|(sign|log) ?in (with|via|using) .{1,40}|continue with .{1,40})$/i;
|
|
51
|
+
function parseUrl(url) {
|
|
52
|
+
try {
|
|
53
|
+
return new URL(url);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function isIdpHost(u) {
|
|
60
|
+
const host = u.hostname.toLowerCase();
|
|
61
|
+
const hostAndPath = `${host}${u.pathname}`.toLowerCase();
|
|
62
|
+
return IDP_HOSTS.some((h) => {
|
|
63
|
+
if (h.includes('/'))
|
|
64
|
+
return hostAndPath === h || hostAndPath.startsWith(`${h}/`);
|
|
65
|
+
return host === h || host.endsWith(`.${h}`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Inspect a page and return a verdict, or `null` when it does not look like a
|
|
70
|
+
* login wall.
|
|
71
|
+
*
|
|
72
|
+
* Confidence:
|
|
73
|
+
* - `high`: a password field plus a sign-in control, title, or URL; or a
|
|
74
|
+
* sign-in URL plus a sign-in control or title; or an identity-provider host.
|
|
75
|
+
* - `medium`: exactly one weaker cue on its own (a password field, or a
|
|
76
|
+
* sign-in URL) with nothing to corroborate it. Callers that fail a run on a
|
|
77
|
+
* wall should usually require `high`.
|
|
78
|
+
*
|
|
79
|
+
* A lone "Sign in" link or a title mention never counts: most public pages have
|
|
80
|
+
* one in the header.
|
|
81
|
+
*/
|
|
82
|
+
function detectAuthWall(page) {
|
|
83
|
+
const signals = new Set();
|
|
84
|
+
const u = parseUrl(page.url);
|
|
85
|
+
if (u && LOGIN_PATH.test(u.pathname))
|
|
86
|
+
signals.add('login-url');
|
|
87
|
+
if (u && isIdpHost(u))
|
|
88
|
+
signals.add('identity-provider');
|
|
89
|
+
if (LOGIN_TITLE.test(page.title || ''))
|
|
90
|
+
signals.add('login-title');
|
|
91
|
+
let strongButton = false;
|
|
92
|
+
for (const n of page.nodes) {
|
|
93
|
+
if (n.secret === true || (n.tag === 'input' && n.role === 'textbox' && /password/i.test(n.name))) {
|
|
94
|
+
signals.add('password-field');
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const label = (n.name || '').trim();
|
|
98
|
+
if (!label)
|
|
99
|
+
continue;
|
|
100
|
+
const clickable = n.role === 'button' || n.role === 'link' || n.tag === 'button';
|
|
101
|
+
if (clickable && LOGIN_BUTTON.test(label)) {
|
|
102
|
+
signals.add('login-button');
|
|
103
|
+
if (STRONG_BUTTON.test(label))
|
|
104
|
+
strongButton = true;
|
|
105
|
+
}
|
|
106
|
+
else if ((n.role === 'textbox' || n.role === 'combobox') && USERNAME_FIELD.test(label)) {
|
|
107
|
+
signals.add('username-field');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const has = (s) => signals.has(s);
|
|
111
|
+
const password = has('password-field');
|
|
112
|
+
const loginUrl = has('login-url');
|
|
113
|
+
const idp = has('identity-provider');
|
|
114
|
+
const title = has('login-title');
|
|
115
|
+
let confidence = null;
|
|
116
|
+
if (idp)
|
|
117
|
+
confidence = 'high';
|
|
118
|
+
else if (password && (strongButton || title || loginUrl || has('username-field')))
|
|
119
|
+
confidence = 'high';
|
|
120
|
+
else if (loginUrl && (strongButton || title || has('username-field')))
|
|
121
|
+
confidence = 'high';
|
|
122
|
+
else if (password || loginUrl)
|
|
123
|
+
confidence = 'medium';
|
|
124
|
+
if (!confidence)
|
|
125
|
+
return null;
|
|
126
|
+
const order = [
|
|
127
|
+
'password-field',
|
|
128
|
+
'identity-provider',
|
|
129
|
+
'login-url',
|
|
130
|
+
'login-title',
|
|
131
|
+
'login-button',
|
|
132
|
+
'username-field',
|
|
133
|
+
];
|
|
134
|
+
return { detected: true, confidence, signals: order.filter(has) };
|
|
135
|
+
}
|
|
136
|
+
/** A one-line, human-readable explanation for error messages and logs. */
|
|
137
|
+
function describeAuthWall(wall, url) {
|
|
138
|
+
return `the page at ${url} looks like a sign-in wall (${wall.confidence} confidence: ${wall.signals.join(', ')}); the session has probably expired`;
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=auth-wall.js.map
|
package/dist/shared/page-fns.js
CHANGED
|
@@ -87,8 +87,6 @@ function pageOp(a) {
|
|
|
87
87
|
return { dx, dy, exact: true, scrollX: win.scrollX, scrollY: win.scrollY };
|
|
88
88
|
};
|
|
89
89
|
const sel = typeof a.selector === 'string' && a.selector.length > 0 ? a.selector : null;
|
|
90
|
-
const el = sel ? deepQuery(sel) : null;
|
|
91
|
-
const missing = sel !== null && el === null;
|
|
92
90
|
/** Set a value the way React/Vue see it (they patch the instance setter). */
|
|
93
91
|
const setValue = (node, next) => {
|
|
94
92
|
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), 'value')?.set;
|
|
@@ -98,195 +96,223 @@ function pageOp(a) {
|
|
|
98
96
|
node.value = next;
|
|
99
97
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
100
98
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return { found:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return { found: false };
|
|
113
|
-
const root = el ?? document.documentElement;
|
|
114
|
-
if (!root)
|
|
115
|
-
return { found: true, html: '' };
|
|
116
|
-
const outer = a.outer === true || !sel;
|
|
117
|
-
return { found: true, html: outer ? root.outerHTML : root.innerHTML };
|
|
118
|
-
}
|
|
119
|
-
case 'click': {
|
|
120
|
-
if (!el)
|
|
121
|
-
return { found: false };
|
|
122
|
-
el.scrollIntoView({ block: 'center' });
|
|
123
|
-
el.click();
|
|
124
|
-
return { found: true };
|
|
125
|
-
}
|
|
126
|
-
case 'type': {
|
|
127
|
-
const node = el;
|
|
128
|
-
if (!node)
|
|
129
|
-
return { found: false };
|
|
130
|
-
node.focus();
|
|
131
|
-
const next = (a.clear ? '' : node.value ?? '') + (a.text ?? '');
|
|
132
|
-
setValue(node, next);
|
|
133
|
-
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
134
|
-
return { found: true };
|
|
135
|
-
}
|
|
136
|
-
case 'focus': {
|
|
137
|
-
const node = el;
|
|
138
|
-
if (!node)
|
|
139
|
-
return { found: false };
|
|
140
|
-
node.focus();
|
|
141
|
-
if (a.clear)
|
|
142
|
-
setValue(node, '');
|
|
143
|
-
return { found: true };
|
|
144
|
-
}
|
|
145
|
-
case 'point': {
|
|
146
|
-
if (!el)
|
|
147
|
-
return { found: false };
|
|
148
|
-
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
149
|
-
const r = el.getBoundingClientRect();
|
|
150
|
-
const off = frameOffset();
|
|
151
|
-
return {
|
|
152
|
-
found: true,
|
|
153
|
-
x: r.left + r.width / 2 + off.dx,
|
|
154
|
-
y: r.top + r.height / 2 + off.dy,
|
|
155
|
-
// false => this frame's coordinates cannot be mapped to the top viewport.
|
|
156
|
-
exact: off.exact,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
case 'hover': {
|
|
160
|
-
if (!el)
|
|
161
|
-
return { found: false };
|
|
162
|
-
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
163
|
-
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
164
|
-
return { found: true };
|
|
165
|
-
}
|
|
166
|
-
case 'select': {
|
|
167
|
-
const node = el;
|
|
168
|
-
if (!node || !node.options)
|
|
169
|
-
return { found: false };
|
|
170
|
-
const want = new Set(a.values ?? []);
|
|
171
|
-
let matched = false;
|
|
172
|
-
for (const opt of Array.from(node.options)) {
|
|
173
|
-
const on = want.has(opt.value) || want.has(opt.label) || want.has(opt.text);
|
|
174
|
-
opt.selected = on;
|
|
175
|
-
if (on)
|
|
176
|
-
matched = true;
|
|
99
|
+
/** The op proper, against an element already resolved (or null). */
|
|
100
|
+
const perform = (el) => {
|
|
101
|
+
const missing = sel !== null && el === null;
|
|
102
|
+
switch (a.op) {
|
|
103
|
+
case 'probe':
|
|
104
|
+
return { found: true, url: location.href, title: document.title };
|
|
105
|
+
case 'text': {
|
|
106
|
+
if (missing)
|
|
107
|
+
return { found: false };
|
|
108
|
+
const root = el ?? document.body;
|
|
109
|
+
return { found: true, text: root ? root.innerText ?? '' : '' };
|
|
177
110
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
h: window.innerHeight,
|
|
187
|
-
fullW: Math.max(d.scrollWidth, d.clientWidth),
|
|
188
|
-
fullH: Math.max(d.scrollHeight, d.clientHeight),
|
|
189
|
-
};
|
|
190
|
-
if (!sel)
|
|
191
|
-
return { found: true, dims, element: null, missing: false };
|
|
192
|
-
if (!el)
|
|
193
|
-
return { found: true, dims, element: null, missing: true };
|
|
194
|
-
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
195
|
-
const r = el.getBoundingClientRect();
|
|
196
|
-
const off = frameOffset();
|
|
197
|
-
// viewport rect + this frame's offset + the TOP document's scroll ->
|
|
198
|
-
// document coordinates of the page the screenshot actually captures.
|
|
199
|
-
return {
|
|
200
|
-
found: true,
|
|
201
|
-
dims,
|
|
202
|
-
element: {
|
|
203
|
-
x: r.left + off.dx + off.scrollX,
|
|
204
|
-
y: r.top + off.dy + off.scrollY,
|
|
205
|
-
w: r.width,
|
|
206
|
-
h: r.height,
|
|
207
|
-
},
|
|
208
|
-
missing: false,
|
|
209
|
-
exact: off.exact,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
case 'scroll': {
|
|
213
|
-
if (el)
|
|
214
|
-
el.scrollIntoView({ block: 'center' });
|
|
215
|
-
else if (a.x != null || a.y != null)
|
|
216
|
-
window.scrollTo(a.x ?? 0, a.y ?? 0);
|
|
217
|
-
else
|
|
218
|
-
window.scrollBy(a.deltaX ?? 0, a.deltaY ?? 0);
|
|
219
|
-
return { found: !missing };
|
|
220
|
-
}
|
|
221
|
-
case 'storage': {
|
|
222
|
-
const store = a.session ? window.sessionStorage : window.localStorage;
|
|
223
|
-
const op = a.storageOp;
|
|
224
|
-
if (op === 'set') {
|
|
225
|
-
store.setItem(String(a.key), String(a.value ?? ''));
|
|
226
|
-
return { found: true, ok: true };
|
|
111
|
+
case 'html': {
|
|
112
|
+
if (missing)
|
|
113
|
+
return { found: false };
|
|
114
|
+
const root = el ?? document.documentElement;
|
|
115
|
+
if (!root)
|
|
116
|
+
return { found: true, html: '' };
|
|
117
|
+
const outer = a.outer === true || !sel;
|
|
118
|
+
return { found: true, html: outer ? root.outerHTML : root.innerHTML };
|
|
227
119
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
120
|
+
case 'click': {
|
|
121
|
+
if (!el)
|
|
122
|
+
return { found: false };
|
|
123
|
+
el.scrollIntoView({ block: 'center' });
|
|
124
|
+
el.click();
|
|
125
|
+
return { found: true };
|
|
231
126
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
127
|
+
case 'type': {
|
|
128
|
+
const node = el;
|
|
129
|
+
if (!node)
|
|
130
|
+
return { found: false };
|
|
131
|
+
node.focus();
|
|
132
|
+
const next = (a.clear ? '' : node.value ?? '') + (a.text ?? '');
|
|
133
|
+
setValue(node, next);
|
|
134
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
135
|
+
return { found: true };
|
|
235
136
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
137
|
+
case 'focus': {
|
|
138
|
+
const node = el;
|
|
139
|
+
if (!node)
|
|
140
|
+
return { found: false };
|
|
141
|
+
node.focus();
|
|
142
|
+
if (a.clear)
|
|
143
|
+
setValue(node, '');
|
|
144
|
+
return { found: true };
|
|
243
145
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return resolve({ found: false });
|
|
257
|
-
setTimeout(tick, every);
|
|
146
|
+
case 'point': {
|
|
147
|
+
if (!el)
|
|
148
|
+
return { found: false };
|
|
149
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
150
|
+
const r = el.getBoundingClientRect();
|
|
151
|
+
const off = frameOffset();
|
|
152
|
+
return {
|
|
153
|
+
found: true,
|
|
154
|
+
x: r.left + r.width / 2 + off.dx,
|
|
155
|
+
y: r.top + r.height / 2 + off.dy,
|
|
156
|
+
// false => this frame's coordinates cannot be mapped to the top viewport.
|
|
157
|
+
exact: off.exact,
|
|
258
158
|
};
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
159
|
+
}
|
|
160
|
+
case 'hover': {
|
|
161
|
+
if (!el)
|
|
162
|
+
return { found: false };
|
|
163
|
+
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
164
|
+
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
165
|
+
return { found: true };
|
|
166
|
+
}
|
|
167
|
+
case 'select': {
|
|
168
|
+
const node = el;
|
|
169
|
+
if (!node || !node.options)
|
|
170
|
+
return { found: false };
|
|
171
|
+
const want = new Set(a.values ?? []);
|
|
172
|
+
let matched = false;
|
|
173
|
+
for (const opt of Array.from(node.options)) {
|
|
174
|
+
const on = want.has(opt.value) || want.has(opt.label) || want.has(opt.text);
|
|
175
|
+
opt.selected = on;
|
|
176
|
+
if (on)
|
|
177
|
+
matched = true;
|
|
178
|
+
}
|
|
179
|
+
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
180
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
181
|
+
return { found: true, matched };
|
|
182
|
+
}
|
|
183
|
+
case 'measure': {
|
|
184
|
+
const d = document.documentElement;
|
|
185
|
+
const dims = {
|
|
186
|
+
w: window.innerWidth,
|
|
187
|
+
h: window.innerHeight,
|
|
188
|
+
fullW: Math.max(d.scrollWidth, d.clientWidth),
|
|
189
|
+
fullH: Math.max(d.scrollHeight, d.clientHeight),
|
|
190
|
+
dpr: window.devicePixelRatio || 1,
|
|
191
|
+
scrollX: window.scrollX,
|
|
192
|
+
scrollY: window.scrollY,
|
|
277
193
|
};
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
194
|
+
if (!sel)
|
|
195
|
+
return { found: true, dims, element: null, missing: false };
|
|
196
|
+
if (!el)
|
|
197
|
+
return { found: true, dims, element: null, missing: true };
|
|
198
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
199
|
+
const r = el.getBoundingClientRect();
|
|
200
|
+
const off = frameOffset();
|
|
201
|
+
// viewport rect + this frame's offset + the TOP document's scroll ->
|
|
202
|
+
// document coordinates of the page the screenshot actually captures.
|
|
203
|
+
return {
|
|
204
|
+
found: true,
|
|
205
|
+
dims,
|
|
206
|
+
element: {
|
|
207
|
+
x: r.left + off.dx + off.scrollX,
|
|
208
|
+
y: r.top + off.dy + off.scrollY,
|
|
209
|
+
w: r.width,
|
|
210
|
+
h: r.height,
|
|
211
|
+
},
|
|
212
|
+
missing: false,
|
|
213
|
+
exact: off.exact,
|
|
284
214
|
};
|
|
285
|
-
|
|
286
|
-
|
|
215
|
+
}
|
|
216
|
+
case 'scroll': {
|
|
217
|
+
if (el)
|
|
218
|
+
el.scrollIntoView({ block: 'center' });
|
|
219
|
+
else if (a.x != null || a.y != null)
|
|
220
|
+
window.scrollTo(a.x ?? 0, a.y ?? 0);
|
|
221
|
+
else
|
|
222
|
+
window.scrollBy(a.deltaX ?? 0, a.deltaY ?? 0);
|
|
223
|
+
return { found: !missing };
|
|
224
|
+
}
|
|
225
|
+
case 'storage': {
|
|
226
|
+
const store = a.session ? window.sessionStorage : window.localStorage;
|
|
227
|
+
const op = a.storageOp;
|
|
228
|
+
if (op === 'set') {
|
|
229
|
+
store.setItem(String(a.key), String(a.value ?? ''));
|
|
230
|
+
return { found: true, ok: true };
|
|
231
|
+
}
|
|
232
|
+
if (op === 'remove') {
|
|
233
|
+
store.removeItem(String(a.key));
|
|
234
|
+
return { found: true, ok: true };
|
|
235
|
+
}
|
|
236
|
+
if (op === 'clear') {
|
|
237
|
+
store.clear();
|
|
238
|
+
return { found: true, ok: true };
|
|
239
|
+
}
|
|
240
|
+
if (a.key)
|
|
241
|
+
return { found: true, ok: true, value: store.getItem(a.key) };
|
|
242
|
+
const entries = {};
|
|
243
|
+
for (let i = 0; i < store.length; i++) {
|
|
244
|
+
const k = store.key(i);
|
|
245
|
+
if (k)
|
|
246
|
+
entries[k] = store.getItem(k) ?? '';
|
|
247
|
+
}
|
|
248
|
+
return { found: true, ok: true, entries };
|
|
249
|
+
}
|
|
250
|
+
// -- the two polling ops: one injection that resolves in-page, rather than
|
|
251
|
+
// one executeScript round-trip per tick --
|
|
252
|
+
case 'waitSelector': {
|
|
253
|
+
const deadline = Date.now() + (a.timeoutMs ?? 5_000);
|
|
254
|
+
const every = a.interval ?? 120;
|
|
255
|
+
return new Promise((resolve) => {
|
|
256
|
+
const tick = () => {
|
|
257
|
+
if (sel && deepQuery(sel))
|
|
258
|
+
return resolve({ found: true });
|
|
259
|
+
if (Date.now() > deadline)
|
|
260
|
+
return resolve({ found: false });
|
|
261
|
+
setTimeout(tick, every);
|
|
262
|
+
};
|
|
263
|
+
tick();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
case 'waitFor': {
|
|
267
|
+
const deadline = Date.now() + (a.timeoutMs ?? 30_000);
|
|
268
|
+
const every = a.interval ?? 150;
|
|
269
|
+
const want = typeof a.textContains === 'string' && a.textContains.length > 0 ? a.textContains : null;
|
|
270
|
+
const gone = a.gone === true;
|
|
271
|
+
return new Promise((resolve) => {
|
|
272
|
+
const hit = () => {
|
|
273
|
+
let present;
|
|
274
|
+
if (sel)
|
|
275
|
+
present = !!deepQuery(sel);
|
|
276
|
+
else if (want)
|
|
277
|
+
present = (document.body?.innerText ?? '').includes(want);
|
|
278
|
+
else
|
|
279
|
+
present = true;
|
|
280
|
+
return gone ? !present : present;
|
|
281
|
+
};
|
|
282
|
+
const tick = () => {
|
|
283
|
+
if (hit())
|
|
284
|
+
return resolve({ found: true, matched: true });
|
|
285
|
+
if (Date.now() > deadline)
|
|
286
|
+
return resolve({ found: true, matched: false });
|
|
287
|
+
setTimeout(tick, every);
|
|
288
|
+
};
|
|
289
|
+
tick();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
default:
|
|
293
|
+
return { found: false, error: `unknown page op: ${String(a.op)}` };
|
|
287
294
|
}
|
|
288
|
-
|
|
289
|
-
|
|
295
|
+
};
|
|
296
|
+
const el = sel ? deepQuery(sel) : null;
|
|
297
|
+
// Element ops wait for their target IN THIS SAME injection when `timeoutMs`
|
|
298
|
+
// is set: a not-yet-rendered button costs a few in-page ticks, not a second
|
|
299
|
+
// executeScript round-trip (the old waitSelector-then-act pair).
|
|
300
|
+
const waits = a.op === 'click' || a.op === 'type' || a.op === 'focus' || a.op === 'point' || a.op === 'hover' || a.op === 'select';
|
|
301
|
+
if (waits && sel && !el && a.timeoutMs && a.timeoutMs > 0) {
|
|
302
|
+
const deadline = Date.now() + a.timeoutMs;
|
|
303
|
+
const every = a.interval ?? 120;
|
|
304
|
+
return new Promise((resolve) => {
|
|
305
|
+
const tick = () => {
|
|
306
|
+
const hit = deepQuery(sel);
|
|
307
|
+
if (hit)
|
|
308
|
+
return resolve(perform(hit));
|
|
309
|
+
if (Date.now() > deadline)
|
|
310
|
+
return resolve({ found: false });
|
|
311
|
+
setTimeout(tick, every);
|
|
312
|
+
};
|
|
313
|
+
setTimeout(tick, every);
|
|
314
|
+
});
|
|
290
315
|
}
|
|
316
|
+
return perform(el);
|
|
291
317
|
}
|
|
292
318
|
//# sourceMappingURL=page-fns.js.map
|