@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 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 briefly show the debug banner
466
- > while attached.
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
@@ -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
- switch (a.op) {
102
- case 'probe':
103
- return { found: true, url: location.href, title: document.title };
104
- case 'text': {
105
- if (missing)
106
- return { found: false };
107
- const root = el ?? document.body;
108
- return { found: true, text: root ? root.innerText ?? '' : '' };
109
- }
110
- case 'html': {
111
- if (missing)
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
- node.dispatchEvent(new Event('input', { bubbles: true }));
179
- node.dispatchEvent(new Event('change', { bubbles: true }));
180
- return { found: true, matched };
181
- }
182
- case 'measure': {
183
- const d = document.documentElement;
184
- const dims = {
185
- w: window.innerWidth,
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
- if (op === 'remove') {
229
- store.removeItem(String(a.key));
230
- return { found: true, ok: true };
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
- if (op === 'clear') {
233
- store.clear();
234
- return { found: true, ok: true };
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
- if (a.key)
237
- return { found: true, ok: true, value: store.getItem(a.key) };
238
- const entries = {};
239
- for (let i = 0; i < store.length; i++) {
240
- const k = store.key(i);
241
- if (k)
242
- entries[k] = store.getItem(k) ?? '';
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
- return { found: true, ok: true, entries };
245
- }
246
- // -- the two polling ops: one injection that resolves in-page, rather than
247
- // one executeScript round-trip per tick --
248
- case 'waitSelector': {
249
- const deadline = Date.now() + (a.timeoutMs ?? 5_000);
250
- const every = a.interval ?? 120;
251
- return new Promise((resolve) => {
252
- const tick = () => {
253
- if (sel && deepQuery(sel))
254
- return resolve({ found: true });
255
- if (Date.now() > deadline)
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
- tick();
260
- });
261
- }
262
- case 'waitFor': {
263
- const deadline = Date.now() + (a.timeoutMs ?? 30_000);
264
- const every = a.interval ?? 150;
265
- const want = typeof a.textContains === 'string' && a.textContains.length > 0 ? a.textContains : null;
266
- const gone = a.gone === true;
267
- return new Promise((resolve) => {
268
- const hit = () => {
269
- let present;
270
- if (sel)
271
- present = !!deepQuery(sel);
272
- else if (want)
273
- present = (document.body?.innerText ?? '').includes(want);
274
- else
275
- present = true;
276
- return gone ? !present : present;
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
- const tick = () => {
279
- if (hit())
280
- return resolve({ found: true, matched: true });
281
- if (Date.now() > deadline)
282
- return resolve({ found: true, matched: false });
283
- setTimeout(tick, every);
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
- tick();
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
- default:
289
- return { found: false, error: `unknown page op: ${String(a.op)}` };
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