@anweat/dsh-browser 0.1.17 → 0.2.0-rc.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/README.md +238 -71
- package/assets/skills/dsh-browser/SKILL.md +120 -0
- package/assets/skills/dsh-browser/references/develop.md +48 -0
- package/assets/skills/dsh-browser/references/observe.md +91 -0
- package/assets/skills/dsh-browser/references/opencli.md +25 -0
- package/assets/skills/dsh-browser/references/recipes.md +149 -0
- package/assets/skills/dsh-browser/references/userscripts.md +44 -0
- package/cordis.patch.yml +4 -0
- package/lib/actions/act.d.ts +3 -0
- package/lib/actions/act.js +147 -0
- package/lib/actions/act.js.map +1 -0
- package/lib/actions/automation.d.ts +3 -0
- package/lib/actions/automation.js +356 -0
- package/lib/actions/automation.js.map +1 -0
- package/lib/actions/crawl.d.ts +3 -0
- package/lib/actions/crawl.js +28 -0
- package/lib/actions/crawl.js.map +1 -0
- package/lib/actions/errors.d.ts +53 -0
- package/lib/actions/errors.js +178 -0
- package/lib/actions/errors.js.map +1 -0
- package/lib/actions/index-view.d.ts +42 -0
- package/lib/actions/index-view.js +268 -0
- package/lib/actions/index-view.js.map +1 -0
- package/lib/actions/inspect.d.ts +3 -0
- package/lib/actions/inspect.js +35 -0
- package/lib/actions/inspect.js.map +1 -0
- package/lib/actions/observe.d.ts +3 -0
- package/lib/actions/observe.js +121 -0
- package/lib/actions/observe.js.map +1 -0
- package/lib/actions/opencli.d.ts +3 -0
- package/lib/actions/opencli.js +51 -0
- package/lib/actions/opencli.js.map +1 -0
- package/lib/actions/registry.d.ts +35 -0
- package/lib/actions/registry.js +89 -0
- package/lib/actions/registry.js.map +1 -0
- package/lib/actions/run.d.ts +34 -0
- package/lib/actions/run.js +226 -0
- package/lib/actions/run.js.map +1 -0
- package/lib/actions/runtime.d.ts +3 -0
- package/lib/actions/runtime.js +33 -0
- package/lib/actions/runtime.js.map +1 -0
- package/lib/actions/schema.d.ts +31 -0
- package/lib/actions/schema.js +309 -0
- package/lib/actions/schema.js.map +1 -0
- package/lib/actions/script.d.ts +3 -0
- package/lib/actions/script.js +97 -0
- package/lib/actions/script.js.map +1 -0
- package/lib/actions/shared.d.ts +31 -0
- package/lib/actions/shared.js +45 -0
- package/lib/actions/shared.js.map +1 -0
- package/lib/actions/surface.d.ts +22 -0
- package/lib/actions/surface.js +18 -0
- package/lib/actions/surface.js.map +1 -0
- package/lib/actions/target.d.ts +3 -0
- package/lib/actions/target.js +66 -0
- package/lib/actions/target.js.map +1 -0
- package/lib/actions/types.d.ts +181 -0
- package/lib/actions/types.js +48 -0
- package/lib/actions/types.js.map +1 -0
- package/lib/activation-rules.d.ts +29 -0
- package/lib/activation-rules.js +33 -0
- package/lib/activation-rules.js.map +1 -0
- package/lib/approval-policy.d.ts +40 -2
- package/lib/approval-policy.js +184 -97
- package/lib/approval-policy.js.map +1 -1
- package/lib/automation-assets-rpc.d.ts +8 -2
- package/lib/automation-assets-rpc.js +67 -6
- package/lib/automation-assets-rpc.js.map +1 -1
- package/lib/automation-assets.d.ts +152 -4
- package/lib/automation-assets.js +255 -15
- package/lib/automation-assets.js.map +1 -1
- package/lib/automation-convert.d.ts +23 -0
- package/lib/automation-convert.js +110 -0
- package/lib/automation-convert.js.map +1 -0
- package/lib/automation-development.d.ts +26 -0
- package/lib/automation-development.js +47 -0
- package/lib/automation-development.js.map +1 -1
- package/lib/automation-execution.d.ts +57 -1
- package/lib/automation-execution.js +167 -11
- package/lib/automation-execution.js.map +1 -1
- package/lib/automation-journal.d.ts +92 -0
- package/lib/automation-journal.js +449 -0
- package/lib/automation-journal.js.map +1 -0
- package/lib/automation-v2.d.ts +138 -0
- package/lib/automation-v2.js +631 -0
- package/lib/automation-v2.js.map +1 -0
- package/lib/automation.d.ts +82 -5
- package/lib/automation.js +239 -82
- package/lib/automation.js.map +1 -1
- package/lib/browser-service.d.ts +304 -32
- package/lib/browser-service.js +631 -182
- package/lib/browser-service.js.map +1 -1
- package/lib/client/SettingsCard.js +95 -57
- package/lib/client/SettingsCard.js.map +1 -1
- package/lib/client/asset-editor.d.ts +55 -0
- package/lib/client/asset-editor.js +101 -0
- package/lib/client/asset-editor.js.map +1 -0
- package/lib/client/automation-assets-client.d.ts +99 -2
- package/lib/client/automation-assets-client.js +243 -10
- package/lib/client/automation-assets-client.js.map +1 -1
- package/lib/client/form.d.ts +48 -4
- package/lib/client/form.js +142 -29
- package/lib/client/form.js.map +1 -1
- package/lib/client/index.d.ts +25 -2
- package/lib/client/index.js +5 -1
- package/lib/client/index.js.map +1 -1
- package/lib/client/locales.d.ts +85 -6
- package/lib/client/locales.js +64 -12
- package/lib/client/locales.js.map +1 -1
- package/lib/client/prompts-form.d.ts +67 -0
- package/lib/client/prompts-form.js +140 -0
- package/lib/client/prompts-form.js.map +1 -0
- package/lib/client/prompts-status-client.d.ts +34 -0
- package/lib/client/prompts-status-client.js +53 -0
- package/lib/client/prompts-status-client.js.map +1 -0
- package/lib/client.js +1914 -329
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +50 -1
- package/lib/config.js +31 -1
- package/lib/config.js.map +1 -1
- package/lib/dialogs.d.ts +37 -0
- package/lib/dialogs.js +73 -0
- package/lib/dialogs.js.map +1 -0
- package/lib/freedom.d.ts +23 -7
- package/lib/freedom.js +48 -59
- package/lib/freedom.js.map +1 -1
- package/lib/index.d.ts +4 -4
- package/lib/index.js +36 -10
- package/lib/index.js.map +1 -1
- package/lib/journal.d.ts +109 -0
- package/lib/journal.js +238 -0
- package/lib/journal.js.map +1 -0
- package/lib/locator.d.ts +82 -0
- package/lib/locator.js +190 -0
- package/lib/locator.js.map +1 -0
- package/lib/observe-script.d.ts +27 -0
- package/lib/observe-script.js +565 -0
- package/lib/observe-script.js.map +1 -0
- package/lib/observe.d.ts +179 -0
- package/lib/observe.js +368 -0
- package/lib/observe.js.map +1 -0
- package/lib/policy-keys.d.ts +30 -0
- package/lib/policy-keys.js +34 -0
- package/lib/policy-keys.js.map +1 -0
- package/lib/prompt-limits.d.ts +30 -0
- package/lib/prompt-limits.js +31 -0
- package/lib/prompt-limits.js.map +1 -0
- package/lib/prompts.d.ts +131 -0
- package/lib/prompts.js +295 -0
- package/lib/prompts.js.map +1 -0
- package/lib/skill.d.ts +100 -0
- package/lib/skill.js +181 -0
- package/lib/skill.js.map +1 -0
- package/lib/tool-defs.d.ts +72 -0
- package/lib/tool-defs.js +67 -0
- package/lib/tool-defs.js.map +1 -0
- package/lib/tools.d.ts +19 -4
- package/lib/tools.js +124 -922
- package/lib/tools.js.map +1 -1
- package/package.json +5 -1
- package/scripts/dump-prompts.mjs +29 -0
- package/scripts/measure-tool-surface.mjs +190 -0
package/lib/browser-service.js
CHANGED
|
@@ -26,9 +26,19 @@ import { AuthProfileStore, hostAllowed } from "./auth-profiles.js";
|
|
|
26
26
|
import { applyRuleSteps, resolveRulePack } from "./rule-packs.js";
|
|
27
27
|
import { runRecipe } from "./automation.js";
|
|
28
28
|
import { BUILTIN_SCRIPTS, builtinScript, executeUserscript, validateUserscript } from "./scripts.js";
|
|
29
|
-
import { configuredBrowserTools } from "./freedom.js";
|
|
29
|
+
import { configuredBrowserActions, configuredBrowserTools } from "./freedom.js";
|
|
30
30
|
import { filterOpencliCatalog, parseOpencliCatalog } from "./opencli-catalog.js";
|
|
31
31
|
import { UsageGovernor } from "./usage-policy.js";
|
|
32
|
+
import { installDialogGuard } from "./dialogs.js";
|
|
33
|
+
import { TargetStaleError, resolveLocator, withStrictLocator } from "./locator.js";
|
|
34
|
+
import { SENSITIVE_NAME, SessionJournal } from "./journal.js";
|
|
35
|
+
import { describeScan, fitObservation, normalizeObserve, scanPage, scopeBase } from "./observe.js";
|
|
36
|
+
/**
|
|
37
|
+
* How many sessions may hold a browser context+page at once. Past this, the
|
|
38
|
+
* least-recently-used session is closed, so a long-lived profile cannot
|
|
39
|
+
* accumulate contexts until the browser dies.
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_MAX_SESSIONS = 8;
|
|
32
42
|
/** Rules-aware content extractor (runs in the page). */
|
|
33
43
|
const EXTRACTOR_FN = `(ruleList) => {
|
|
34
44
|
const doc = document
|
|
@@ -146,29 +156,132 @@ function redactCaptureUrl(value) {
|
|
|
146
156
|
return redactCaptureText(value);
|
|
147
157
|
}
|
|
148
158
|
}
|
|
159
|
+
function newSessionState() {
|
|
160
|
+
return {
|
|
161
|
+
pages: [],
|
|
162
|
+
nextTarget: 1,
|
|
163
|
+
generationClock: 0,
|
|
164
|
+
captureConsole: false,
|
|
165
|
+
captureNetwork: false,
|
|
166
|
+
capturedConsole: [],
|
|
167
|
+
capturedRequests: [],
|
|
168
|
+
lastUsed: 0,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Bucket for callers that carry no agent identity. */
|
|
172
|
+
const SHARED_SESSION = 'shared';
|
|
173
|
+
/**
|
|
174
|
+
* Session key for a tool execution.
|
|
175
|
+
*
|
|
176
|
+
* `ToolExecution.agent` is documented as "the agent on whose behalf the call
|
|
177
|
+
* runs (set by the agent loop)". Two session identities hang off it and BOTH are
|
|
178
|
+
* the session, not the turn:
|
|
179
|
+
*
|
|
180
|
+
* - `agent.session.id` — the live `Agent` face exposes `readonly session:
|
|
181
|
+
* Session`, and `Session.id` is a `SessionId`. This is the path the
|
|
182
|
+
* automation-asset recorder already uses in production, so it is tried first.
|
|
183
|
+
* - `agent.id` — the base `Agent` contract documents `readonly id: SessionId`
|
|
184
|
+
* as "Session-backed Agent identity". Used as a fallback.
|
|
185
|
+
*
|
|
186
|
+
* A session therefore keeps ONE page across all of its turns, and two
|
|
187
|
+
* concurrent sessions never share one. Calls carrying no agent at all
|
|
188
|
+
* (automation-asset replays, non-agent dispatchers) land in one shared bucket,
|
|
189
|
+
* which is the pre-existing single-session behaviour rather than a new hazard.
|
|
190
|
+
*/
|
|
191
|
+
export function sessionKeyFor(agent) {
|
|
192
|
+
const live = agent?.session?.id;
|
|
193
|
+
if (typeof live === 'string' && live)
|
|
194
|
+
return `session:${live}`;
|
|
195
|
+
const own = agent?.id;
|
|
196
|
+
if (typeof own === 'string' && own)
|
|
197
|
+
return `session:${own}`;
|
|
198
|
+
return SHARED_SESSION;
|
|
199
|
+
}
|
|
149
200
|
export class BrowserService {
|
|
150
201
|
config;
|
|
151
202
|
browser;
|
|
152
203
|
launching;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
204
|
+
/** Per-session interactive state. Replaces the former global activePage. */
|
|
205
|
+
sessions = new Map();
|
|
206
|
+
/** The tracked entry (target id, generation) of every page the service created. */
|
|
207
|
+
pageEntries = new WeakMap();
|
|
208
|
+
clock = 0;
|
|
157
209
|
authProfiles;
|
|
158
210
|
usageGovernor;
|
|
159
211
|
opencliCatalogCache;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
212
|
+
/** Per-session exploration journals (see src/journal.ts). Released with the session, never written to disk. */
|
|
213
|
+
journals = new Map();
|
|
214
|
+
/** How many sessions may hold a context+page at once before LRU eviction. */
|
|
215
|
+
sessionLimit;
|
|
164
216
|
constructor(config) {
|
|
165
217
|
this.config = config;
|
|
166
218
|
this.authProfiles = new AuthProfileStore(config.authProfiles);
|
|
167
219
|
this.usageGovernor = new UsageGovernor(config.usagePolicy);
|
|
220
|
+
this.sessionLimit = Math.max(1, Number.isInteger(config.maxSessions) ? config.maxSessions : DEFAULT_MAX_SESSIONS);
|
|
168
221
|
}
|
|
169
222
|
available() {
|
|
170
223
|
return this.config.enabled;
|
|
171
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* This session's exploration journal, created on first use. It outlives `target.close` (the session is
|
|
227
|
+
* still the same) and is released when the session is evicted or the service closes. A session that never
|
|
228
|
+
* records anything costs nothing: journals are only created by the action dispatcher.
|
|
229
|
+
*/
|
|
230
|
+
journalFor(session) {
|
|
231
|
+
let journal = this.journals.get(session);
|
|
232
|
+
if (!journal) {
|
|
233
|
+
journal = new SessionJournal();
|
|
234
|
+
this.journals.set(session, journal);
|
|
235
|
+
// Bounded like the sessions themselves: a journal whose session is long gone cannot pile up.
|
|
236
|
+
while (this.journals.size > this.sessionLimit * 4)
|
|
237
|
+
this.journals.delete(this.journals.keys().next().value);
|
|
238
|
+
}
|
|
239
|
+
return journal;
|
|
240
|
+
}
|
|
241
|
+
/** The active page of a session as `{targetId, generation, url}`, without opening or creating anything. */
|
|
242
|
+
pageStamp(session) {
|
|
243
|
+
const page = this.sessions.get(session)?.page;
|
|
244
|
+
if (!page || page.isClosed?.())
|
|
245
|
+
return undefined;
|
|
246
|
+
let url;
|
|
247
|
+
try {
|
|
248
|
+
url = page.url();
|
|
249
|
+
}
|
|
250
|
+
catch { /* mid-navigation */ }
|
|
251
|
+
return { ...this.stamp(page), ...url ? { url } : {} };
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Whether the control a fill/type would hit is a secret field (password, one-time code, name that says so).
|
|
255
|
+
* Best effort and quick: any trouble (no page, several matches, a slow page) answers false, and the journal
|
|
256
|
+
* still treats secret-looking values and secret-looking locators as sensitive.
|
|
257
|
+
*/
|
|
258
|
+
async inputSensitivity(target, opts = {}) {
|
|
259
|
+
const page = this.sessions.get(opts.session ?? SHARED_SESSION)?.page;
|
|
260
|
+
if (!page || page.isClosed?.())
|
|
261
|
+
return false;
|
|
262
|
+
try {
|
|
263
|
+
return await resolveLocator(page, target).evaluate((element, source) => {
|
|
264
|
+
const hints = [element.getAttribute('name'), element.id, element.getAttribute('autocomplete'), element.getAttribute('aria-label'), element.getAttribute('placeholder'), element.getAttribute('data-testid')];
|
|
265
|
+
const type = String(element.type ?? '').toLowerCase();
|
|
266
|
+
const auto = String(element.getAttribute('autocomplete') ?? '').toLowerCase();
|
|
267
|
+
const named = new RegExp(source, 'i');
|
|
268
|
+
return type === 'password' || type === 'hidden' || /password|one-time-code|cc-/.test(auto)
|
|
269
|
+
|| hints.some((hint) => hint && named.test(hint.replace(/([a-z])([A-Z])/g, '$1 $2')));
|
|
270
|
+
}, SENSITIVE_NAME.source, { timeout: 1_500 }) === true;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The state bucket belonging to a tool execution's session.
|
|
278
|
+
*
|
|
279
|
+
* Tools use this for the few operations that act on the bucket itself
|
|
280
|
+
* (`target.close`) rather than passing a key into a method.
|
|
281
|
+
*/
|
|
282
|
+
sessionState(agent) {
|
|
283
|
+
return this.peek(sessionKeyFor(agent));
|
|
284
|
+
}
|
|
172
285
|
assertEnabled() {
|
|
173
286
|
if (!this.config.enabled)
|
|
174
287
|
throw new Error('dsh-browser: browser service is disabled');
|
|
@@ -176,12 +289,83 @@ export class BrowserService {
|
|
|
176
289
|
browserConnected(browser = this.browser) {
|
|
177
290
|
return !!browser && (typeof browser.isConnected !== 'function' || browser.isConnected());
|
|
178
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* The state bucket for one session, created on demand.
|
|
294
|
+
*
|
|
295
|
+
* Eviction is least-recently-used and never touches the caller's own bucket,
|
|
296
|
+
* so a session cannot have its page closed out from under it by a burst of
|
|
297
|
+
* other sessions.
|
|
298
|
+
*/
|
|
299
|
+
state(session = SHARED_SESSION) {
|
|
300
|
+
const existing = this.sessions.get(session);
|
|
301
|
+
if (existing) {
|
|
302
|
+
existing.lastUsed = ++this.clock;
|
|
303
|
+
return existing;
|
|
304
|
+
}
|
|
305
|
+
const state = newSessionState();
|
|
306
|
+
state.lastUsed = ++this.clock;
|
|
307
|
+
this.sessions.set(session, state);
|
|
308
|
+
void this.evictSessions(session);
|
|
309
|
+
return state;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Look up an existing bucket WITHOUT creating one and WITHOUT evicting.
|
|
313
|
+
*
|
|
314
|
+
* Queries (`runtime.status`, `inspect.console`, `inspect.requests`) must go
|
|
315
|
+
* through this, not {@link state}. Creating a bucket for a session that only
|
|
316
|
+
* *asks* a question would both leak a slot and push a live session past the
|
|
317
|
+
* limit, so a read could evict the very page it was trying to describe.
|
|
318
|
+
*/
|
|
319
|
+
peek(session = SHARED_SESSION) {
|
|
320
|
+
const existing = this.sessions.get(session);
|
|
321
|
+
if (existing)
|
|
322
|
+
existing.lastUsed = ++this.clock;
|
|
323
|
+
return existing;
|
|
324
|
+
}
|
|
325
|
+
/** Close the least-recently-used sessions that exceed the limit. */
|
|
326
|
+
async evictSessions(keep) {
|
|
327
|
+
while (this.sessions.size > this.sessionLimit) {
|
|
328
|
+
let victim;
|
|
329
|
+
let oldest = Number.POSITIVE_INFINITY;
|
|
330
|
+
for (const [key, value] of this.sessions) {
|
|
331
|
+
if (key === keep)
|
|
332
|
+
continue;
|
|
333
|
+
if (value.lastUsed < oldest) {
|
|
334
|
+
oldest = value.lastUsed;
|
|
335
|
+
victim = key;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (victim === undefined)
|
|
339
|
+
return;
|
|
340
|
+
const state = this.sessions.get(victim);
|
|
341
|
+
this.sessions.delete(victim);
|
|
342
|
+
this.journals.delete(victim);
|
|
343
|
+
if (state)
|
|
344
|
+
await this.disposeState(state);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Close one session's page+context without persisting its auth state. */
|
|
348
|
+
async disposeState(state) {
|
|
349
|
+
const page = state.page;
|
|
350
|
+
const context = state.context;
|
|
351
|
+
state.page = undefined;
|
|
352
|
+
state.pages = [];
|
|
353
|
+
state.context = undefined;
|
|
354
|
+
state.profile = undefined;
|
|
355
|
+
state.rulePack = undefined;
|
|
356
|
+
state.capturedConsole = [];
|
|
357
|
+
state.capturedRequests = [];
|
|
358
|
+
state.captureConsole = false;
|
|
359
|
+
state.captureNetwork = false;
|
|
360
|
+
if (page)
|
|
361
|
+
await page.close().catch(() => { });
|
|
362
|
+
if (context)
|
|
363
|
+
await context.close().catch(() => { });
|
|
364
|
+
}
|
|
365
|
+
/** Drop all per-session state after a browser-level failure. */
|
|
179
366
|
clearActiveState() {
|
|
180
|
-
this.
|
|
181
|
-
this.
|
|
182
|
-
this.activeProfile = undefined;
|
|
183
|
-
this.activeRulePack = undefined;
|
|
184
|
-
this.resetCapture();
|
|
367
|
+
this.sessions.clear();
|
|
368
|
+
this.journals.clear();
|
|
185
369
|
}
|
|
186
370
|
handleBrowserDisconnected(browser) {
|
|
187
371
|
// A late event from an older process must not invalidate its replacement.
|
|
@@ -194,6 +378,9 @@ export class BrowserService {
|
|
|
194
378
|
trackBrowser(browser) {
|
|
195
379
|
this.browser = browser;
|
|
196
380
|
browser.on?.('disconnected', () => this.handleBrowserDisconnected(browser));
|
|
381
|
+
// Any dialog opened while this browser is live is auto-closed by the guard instead of
|
|
382
|
+
// by playwright-core's unhandled auto-dismiss promise (see dialogs.ts).
|
|
383
|
+
installDialogGuard(browser);
|
|
197
384
|
return browser;
|
|
198
385
|
}
|
|
199
386
|
async ensure() {
|
|
@@ -228,7 +415,7 @@ export class BrowserService {
|
|
|
228
415
|
return this.trackBrowser(await pw.chromium.launch(launchOptions));
|
|
229
416
|
}
|
|
230
417
|
else {
|
|
231
|
-
throw new Error('dsh-browser: chromium is not installed for ' + this.config.browserRuntime + '. Run the
|
|
418
|
+
throw new Error('dsh-browser: chromium is not installed for ' + this.config.browserRuntime + '. Run the runtime.install action, or: node "' + browserRuntimeCliPath(this.config.browserRuntime) + '" install chromium');
|
|
232
419
|
}
|
|
233
420
|
}
|
|
234
421
|
else {
|
|
@@ -646,160 +833,171 @@ export class BrowserService {
|
|
|
646
833
|
runUserscript(url, source, opts = {}) {
|
|
647
834
|
return this.runScript(url, source, opts);
|
|
648
835
|
}
|
|
649
|
-
// ── interactive surface (one
|
|
836
|
+
// ── interactive surface (one context + page PER SESSION) ──────────────────
|
|
837
|
+
/**
|
|
838
|
+
* Return the calling session's live page, creating one when needed.
|
|
839
|
+
*
|
|
840
|
+
* `session` is the key from {@link sessionKeyFor}. Every read and write below
|
|
841
|
+
* is confined to that bucket, which is what stops one session from observing
|
|
842
|
+
* or mutating another's page, cookies, console or network log.
|
|
843
|
+
*/
|
|
650
844
|
async ensureActivePage(targetUrl, opts = {}) {
|
|
651
845
|
if (targetUrl && !['http:', 'https:'].includes(new URL(targetUrl).protocol))
|
|
652
846
|
throw new Error('browser navigation requires an HTTP(S) URL');
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
847
|
+
const state = this.state(opts.session);
|
|
848
|
+
if (state.page && (state.page.isClosed() || !this.browserConnected()))
|
|
849
|
+
await this.closePage(state);
|
|
850
|
+
if (state.page && !state.page.isClosed()) {
|
|
656
851
|
if (!targetUrl)
|
|
657
|
-
return
|
|
658
|
-
if ((opts.authProfile ?? this.config.defaultAuthProfile) ===
|
|
659
|
-
if (
|
|
660
|
-
this.authProfiles.resolve(
|
|
661
|
-
if (
|
|
662
|
-
resolveRulePack(this.config.rulePacks,
|
|
663
|
-
return
|
|
852
|
+
return state.page;
|
|
853
|
+
if ((opts.authProfile ?? this.config.defaultAuthProfile) === state.profile?.id && opts.rulePack === state.rulePack?.id) {
|
|
854
|
+
if (state.profile)
|
|
855
|
+
this.authProfiles.resolve(state.profile.id, targetUrl);
|
|
856
|
+
if (state.rulePack)
|
|
857
|
+
resolveRulePack(this.config.rulePacks, state.rulePack.id, targetUrl);
|
|
858
|
+
return state.page;
|
|
664
859
|
}
|
|
665
|
-
await this.closePage();
|
|
860
|
+
await this.closePage(state);
|
|
666
861
|
}
|
|
667
862
|
if (targetUrl) {
|
|
668
863
|
const session = await this.transientContext(targetUrl, opts);
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
864
|
+
state.context = session.context;
|
|
865
|
+
state.profile = session.profile;
|
|
866
|
+
state.rulePack = session.rulePack;
|
|
672
867
|
}
|
|
673
868
|
else {
|
|
674
869
|
const browser = await this.ensure();
|
|
675
|
-
|
|
870
|
+
state.context = await browser.newContext(storageStateOptions(this.config.storageStatePath, 'global'));
|
|
676
871
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
872
|
+
// A popup (window.open, target=_blank) joins this session's page list; it never takes over the active page by itself.
|
|
873
|
+
state.context.on('page', (opened) => { if (state.context)
|
|
874
|
+
this.trackPage(state, opened); });
|
|
875
|
+
state.page = await state.context.newPage();
|
|
876
|
+
this.trackPage(state, state.page);
|
|
877
|
+
return state.page;
|
|
680
878
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Add a page to the session's list and wire its capture and close handling.
|
|
881
|
+
* Idempotent: the context 'page' event and the explicit call both land here.
|
|
882
|
+
*/
|
|
883
|
+
trackPage(state, page) {
|
|
884
|
+
if (state.pages.some(entry => entry.page === page))
|
|
885
|
+
return;
|
|
886
|
+
const entry = { id: 't' + state.nextTarget++, page, generation: ++state.generationClock };
|
|
887
|
+
state.pages.push(entry);
|
|
888
|
+
this.pageEntries.set(page, entry);
|
|
889
|
+
// Any committed navigation of the MAIN frame outdates what was observed: Playwright emits framenavigated for
|
|
890
|
+
// cross-document loads, reloads and history moves and for same-document ones (pushState, hash). Subframes do not count.
|
|
891
|
+
page.on('framenavigated', (frame) => {
|
|
892
|
+
if (frame === page.mainFrame?.())
|
|
893
|
+
entry.generation = ++state.generationClock;
|
|
894
|
+
});
|
|
895
|
+
const gone = () => {
|
|
896
|
+
state.pages = state.pages.filter(entry => entry.page !== page);
|
|
897
|
+
// A page that was not the active one just leaves the list.
|
|
898
|
+
if (state.page !== page)
|
|
899
|
+
return;
|
|
900
|
+
// The active page closed: fall back to another open page of the same context before giving up the session.
|
|
901
|
+
const fallback = state.pages[0]?.page;
|
|
902
|
+
if (fallback && !fallback.isClosed?.()) {
|
|
903
|
+
state.page = fallback;
|
|
684
904
|
return;
|
|
685
|
-
|
|
686
|
-
const
|
|
687
|
-
|
|
905
|
+
}
|
|
906
|
+
const context = state.context;
|
|
907
|
+
const profile = state.profile;
|
|
908
|
+
state.page = undefined;
|
|
909
|
+
state.context = undefined;
|
|
910
|
+
state.profile = undefined;
|
|
911
|
+
state.rulePack = undefined;
|
|
688
912
|
if (context)
|
|
689
913
|
void this.persistAndClose({ context, ...profile ? { profile } : {} }).catch(() => { });
|
|
690
914
|
};
|
|
691
|
-
page.on('close',
|
|
692
|
-
page.on('crash',
|
|
915
|
+
page.on('close', gone);
|
|
916
|
+
page.on('crash', gone);
|
|
693
917
|
page.on('console', (message) => {
|
|
694
|
-
if (!
|
|
918
|
+
if (!state.captureConsole)
|
|
695
919
|
return;
|
|
696
920
|
const location = message.location?.();
|
|
697
|
-
|
|
921
|
+
state.capturedConsole.push({
|
|
698
922
|
type: String(message.type?.() ?? 'log').slice(0, 40),
|
|
699
923
|
text: redactCaptureText(String(message.text?.() ?? '')),
|
|
700
924
|
...location?.url ? { url: redactCaptureUrl(location.url) } : {},
|
|
701
925
|
timestamp: new Date().toISOString(),
|
|
702
926
|
});
|
|
703
|
-
if (
|
|
704
|
-
|
|
927
|
+
if (state.capturedConsole.length > 200)
|
|
928
|
+
state.capturedConsole.splice(0, state.capturedConsole.length - 200);
|
|
705
929
|
});
|
|
706
930
|
page.on('response', (response) => {
|
|
707
|
-
if (!
|
|
931
|
+
if (!state.captureNetwork)
|
|
708
932
|
return;
|
|
709
933
|
const status = Number(response.status?.() ?? 0);
|
|
710
934
|
if (status < 400)
|
|
711
935
|
return;
|
|
712
936
|
const request = response.request?.();
|
|
713
|
-
|
|
937
|
+
state.capturedRequests.push({
|
|
714
938
|
method: String(request?.method?.() ?? 'GET').slice(0, 20),
|
|
715
939
|
url: redactCaptureUrl(String(response.url?.() ?? '')),
|
|
716
940
|
status,
|
|
717
941
|
timestamp: new Date().toISOString(),
|
|
718
942
|
});
|
|
719
|
-
if (
|
|
720
|
-
|
|
943
|
+
if (state.capturedRequests.length > 200)
|
|
944
|
+
state.capturedRequests.splice(0, state.capturedRequests.length - 200);
|
|
721
945
|
});
|
|
722
946
|
page.on('requestfailed', (request) => {
|
|
723
|
-
if (!
|
|
947
|
+
if (!state.captureNetwork)
|
|
724
948
|
return;
|
|
725
|
-
|
|
949
|
+
state.capturedRequests.push({
|
|
726
950
|
method: String(request.method?.() ?? 'GET').slice(0, 20),
|
|
727
951
|
url: redactCaptureUrl(String(request.url?.() ?? '')),
|
|
728
952
|
failure: redactCaptureText(String(request.failure?.()?.errorText ?? 'request failed'), 500),
|
|
729
953
|
timestamp: new Date().toISOString(),
|
|
730
954
|
});
|
|
731
|
-
if (
|
|
732
|
-
|
|
955
|
+
if (state.capturedRequests.length > 200)
|
|
956
|
+
state.capturedRequests.splice(0, state.capturedRequests.length - 200);
|
|
733
957
|
});
|
|
734
958
|
}
|
|
735
|
-
resetCapture(capture = []) {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
959
|
+
resetCapture(state, capture = []) {
|
|
960
|
+
state.captureConsole = capture.includes('console');
|
|
961
|
+
state.captureNetwork = capture.includes('network');
|
|
962
|
+
state.capturedConsole = [];
|
|
963
|
+
state.capturedRequests = [];
|
|
740
964
|
}
|
|
741
965
|
resolveTarget(page, target) {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
let root = page;
|
|
751
|
-
if (spec.frame) {
|
|
752
|
-
const frameModes = [spec.frame.selector, spec.frame.name, spec.frame.url].filter(value => value !== undefined);
|
|
753
|
-
if (frameModes.length !== 1)
|
|
754
|
-
throw new Error('browser frame requires exactly one of selector, name, or url');
|
|
755
|
-
if (spec.frame.selector)
|
|
756
|
-
root = page.frameLocator(boundedString(spec.frame.selector, 'frame selector', 500));
|
|
757
|
-
else {
|
|
758
|
-
const frame = page.frame(spec.frame.name
|
|
759
|
-
? { name: boundedString(spec.frame.name, 'frame name', 500) }
|
|
760
|
-
: { url: boundedString(spec.frame.url, 'frame url', 2_000) });
|
|
761
|
-
if (!frame)
|
|
762
|
-
throw new Error('browser target frame was not found');
|
|
763
|
-
root = frame;
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
if (spec.selector)
|
|
767
|
-
return root.locator(boundedString(spec.selector, 'selector', 500));
|
|
768
|
-
if (spec.role)
|
|
769
|
-
return root.getByRole(boundedString(spec.role, 'role', 100), {
|
|
770
|
-
...spec.name !== undefined ? { name: boundedString(spec.name, 'role name', 2_000) } : {},
|
|
771
|
-
exact: spec.exact ?? false,
|
|
772
|
-
});
|
|
773
|
-
if (spec.text)
|
|
774
|
-
return root.getByText(boundedString(spec.text, 'text locator', 2_000), { exact: spec.exact ?? false });
|
|
775
|
-
return root.getByLabel(boundedString(spec.label, 'label locator', 2_000), { exact: spec.exact ?? false });
|
|
966
|
+
return resolveLocator(page, target);
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Run an action on a strict locator. More than one match performs nothing and
|
|
970
|
+
* fails with LOCATOR_AMBIGUOUS plus a summary of the first candidates.
|
|
971
|
+
*/
|
|
972
|
+
onTarget(page, target, run) {
|
|
973
|
+
return withStrictLocator(this.resolveTarget(page, target), run);
|
|
776
974
|
}
|
|
777
975
|
screenshotFile(options) {
|
|
778
976
|
const filename = options.filename ?? `shot-${Date.now()}-${uid().slice(0, 8)}.${options.format === 'jpeg' ? 'jpg' : 'png'}`;
|
|
779
977
|
if (filename !== path.basename(filename) || !/^[\w.() -]{1,160}$/.test(filename)) {
|
|
780
|
-
throw new Error('
|
|
978
|
+
throw new Error('observe.screenshot filename must be a plain file name inside snapshotDir');
|
|
781
979
|
}
|
|
782
980
|
const extension = path.extname(filename).toLowerCase();
|
|
783
981
|
const inferred = extension === '.jpg' || extension === '.jpeg' ? 'jpeg' : extension === '.png' ? 'png' : undefined;
|
|
784
982
|
const format = options.format ?? inferred ?? 'png';
|
|
785
983
|
if (inferred && inferred !== format)
|
|
786
|
-
throw new Error('
|
|
984
|
+
throw new Error('observe.screenshot filename extension does not match format');
|
|
787
985
|
if (!inferred)
|
|
788
|
-
throw new Error('
|
|
986
|
+
throw new Error('observe.screenshot filename must end in .png, .jpg, or .jpeg');
|
|
789
987
|
return { file: path.join(this.config.snapshotDir, filename), format };
|
|
790
988
|
}
|
|
791
989
|
async captureScreenshot(page, options = {}) {
|
|
792
990
|
fs.mkdirSync(this.config.snapshotDir, { recursive: true });
|
|
793
991
|
if (options.target && options.clip)
|
|
794
|
-
throw new Error('
|
|
992
|
+
throw new Error('observe.screenshot cannot combine target and clip');
|
|
795
993
|
if (options.target && options.fullPage)
|
|
796
|
-
throw new Error('
|
|
994
|
+
throw new Error('observe.screenshot cannot combine target and fullPage');
|
|
797
995
|
if (options.quality !== undefined && (!Number.isInteger(options.quality) || options.quality < 0 || options.quality > 100)) {
|
|
798
|
-
throw new Error('
|
|
996
|
+
throw new Error('observe.screenshot quality must be an integer from 0 to 100');
|
|
799
997
|
}
|
|
800
998
|
const { file, format } = this.screenshotFile(options);
|
|
801
999
|
if (format === 'png' && options.quality !== undefined)
|
|
802
|
-
throw new Error('
|
|
1000
|
+
throw new Error('observe.screenshot quality is only supported for jpeg');
|
|
803
1001
|
const screenshotOptions = {
|
|
804
1002
|
path: file,
|
|
805
1003
|
type: format,
|
|
@@ -808,7 +1006,7 @@ export class BrowserService {
|
|
|
808
1006
|
if (options.clip) {
|
|
809
1007
|
const { x, y, width, height } = options.clip;
|
|
810
1008
|
if (![x, y, width, height].every(Number.isFinite) || x < 0 || y < 0 || width <= 0 || height <= 0 || width > 20_000 || height > 20_000) {
|
|
811
|
-
throw new Error('
|
|
1009
|
+
throw new Error('observe.screenshot clip must use finite non-negative coordinates and dimensions from 1 to 20,000');
|
|
812
1010
|
}
|
|
813
1011
|
screenshotOptions.clip = options.clip;
|
|
814
1012
|
}
|
|
@@ -816,17 +1014,35 @@ export class BrowserService {
|
|
|
816
1014
|
screenshotOptions.fullPage = options.fullPage ?? true;
|
|
817
1015
|
}
|
|
818
1016
|
if (options.target)
|
|
819
|
-
await this.
|
|
1017
|
+
await this.onTarget(page, options.target, locator => locator.screenshot(screenshotOptions));
|
|
820
1018
|
else
|
|
821
1019
|
await page.screenshot(screenshotOptions);
|
|
822
1020
|
return file;
|
|
823
1021
|
}
|
|
1022
|
+
/** `{targetId, generation}` of a tracked page (empty for a page the service does not track). */
|
|
1023
|
+
stamp(page) {
|
|
1024
|
+
const entry = this.pageEntries.get(page);
|
|
1025
|
+
return entry ? { targetId: entry.id, generation: entry.generation } : {};
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Refuse to act when the page's generation is not the one the caller observed. Nothing has been done yet
|
|
1029
|
+
* at this point, so the caller only needs to observe again.
|
|
1030
|
+
*/
|
|
1031
|
+
assertGeneration(page, expected) {
|
|
1032
|
+
if (expected === undefined)
|
|
1033
|
+
return;
|
|
1034
|
+
const entry = this.pageEntries.get(page);
|
|
1035
|
+
if (!entry || entry.generation === expected)
|
|
1036
|
+
return;
|
|
1037
|
+
throw new TargetStaleError(`The page ${entry.id} is at generation ${entry.generation}, not ${expected}: it navigated since it was observed, so nothing was done.`, { targetId: entry.id, generation: entry.generation });
|
|
1038
|
+
}
|
|
824
1039
|
async readState(page, includeScreenshot) {
|
|
825
1040
|
const data = await evaluateExtractor(page, []);
|
|
826
1041
|
const state = {
|
|
827
1042
|
url: page.url(),
|
|
828
1043
|
title: String(data.title ?? ''),
|
|
829
1044
|
text: capText(String(data.text ?? '').replace(/\n{3,}/g, '\n\n').trim(), 100_000),
|
|
1045
|
+
...this.stamp(page),
|
|
830
1046
|
};
|
|
831
1047
|
if (includeScreenshot)
|
|
832
1048
|
state.screenshotPath = await this.captureScreenshot(page);
|
|
@@ -834,37 +1050,64 @@ export class BrowserService {
|
|
|
834
1050
|
}
|
|
835
1051
|
async open(url, opts = {}) {
|
|
836
1052
|
const page = await this.ensureActivePage(url, opts);
|
|
837
|
-
this.
|
|
1053
|
+
const state = this.state(opts.session);
|
|
1054
|
+
this.resetCapture(state, opts.capture);
|
|
838
1055
|
page.setDefaultTimeout(30_000);
|
|
839
1056
|
await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
840
1057
|
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
841
|
-
await applyRuleSteps(page,
|
|
1058
|
+
await applyRuleSteps(page, state.rulePack);
|
|
842
1059
|
if (opts.waitMs)
|
|
843
1060
|
await page.waitForTimeout(opts.waitMs);
|
|
844
1061
|
return this.readState(page, true);
|
|
845
1062
|
}
|
|
846
1063
|
async click(target, opts = {}) {
|
|
847
|
-
const page = await this.ensureActivePage();
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1064
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1065
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
1066
|
+
await this.onTarget(page, target, locator => locator.click({ timeout: boundedTimeout(opts.timeoutMs, 'act.click timeoutMs') }));
|
|
1067
|
+
// The click may have closed the page it hit (a popup's own close button). That is a success, not TARGET_CLOSED.
|
|
1068
|
+
await page.waitForTimeout(opts.waitMs ?? 500).catch((error) => { if (!page.isClosed())
|
|
1069
|
+
throw error; });
|
|
1070
|
+
if (page.isClosed()) {
|
|
1071
|
+
const fallback = this.peek(opts.session)?.page;
|
|
1072
|
+
if (!fallback || fallback.isClosed())
|
|
1073
|
+
return { url: '', title: '', text: '', closedPage: true };
|
|
1074
|
+
return { ...await this.readState(fallback, true), closedPage: true };
|
|
1075
|
+
}
|
|
853
1076
|
return this.readState(page, true);
|
|
854
1077
|
}
|
|
855
1078
|
async type(target, text, opts = {}) {
|
|
856
|
-
const page = await this.ensureActivePage();
|
|
857
|
-
|
|
1079
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1080
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
1081
|
+
await this.onTarget(page, target, locator => locator.fill(text, { timeout: boundedTimeout(opts.timeoutMs, 'act.fill timeoutMs') }));
|
|
1082
|
+
return this.readState(page, false);
|
|
1083
|
+
}
|
|
1084
|
+
/** Type key by key (pressSequentially), so per-key handlers fire; unlike `type`, which replaces the whole value. */
|
|
1085
|
+
async typeKeys(target, text, opts = {}) {
|
|
1086
|
+
const value = boundedString(text, 'act.type text', 10_000);
|
|
1087
|
+
const delay = opts.delayMs ?? 0;
|
|
1088
|
+
if (!Number.isFinite(delay) || delay < 0 || delay > 1_000)
|
|
1089
|
+
throw new Error('act.type delayMs must be between 0 and 1,000');
|
|
1090
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1091
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
1092
|
+
const timeout = boundedTimeout(opts.timeoutMs, 'act.type timeoutMs');
|
|
1093
|
+
await this.onTarget(page, target, locator => locator.pressSequentially(value, { delay, timeout }));
|
|
1094
|
+
return this.readState(page, false);
|
|
1095
|
+
}
|
|
1096
|
+
async clear(target, opts = {}) {
|
|
1097
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1098
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
1099
|
+
const timeout = boundedTimeout(opts.timeoutMs, 'act.clear timeoutMs');
|
|
1100
|
+
await this.onTarget(page, target, locator => locator.clear({ timeout }));
|
|
858
1101
|
return this.readState(page, false);
|
|
859
1102
|
}
|
|
860
1103
|
async wait(target, opts = {}) {
|
|
861
|
-
const page = await this.ensureActivePage();
|
|
1104
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
862
1105
|
const modes = [target !== undefined, opts.urlPattern !== undefined, opts.networkIdle === true, opts.timeMs !== undefined].filter(Boolean);
|
|
863
1106
|
if (modes.length !== 1)
|
|
864
|
-
throw new Error('
|
|
865
|
-
const timeout = boundedTimeout(opts.timeoutMs, '
|
|
1107
|
+
throw new Error('act.wait requires exactly one target, urlPattern, networkIdle=true, or timeMs');
|
|
1108
|
+
const timeout = boundedTimeout(opts.timeoutMs, 'act.wait timeoutMs');
|
|
866
1109
|
if (target !== undefined)
|
|
867
|
-
await this.
|
|
1110
|
+
await this.onTarget(page, target, locator => locator.waitFor({ state: opts.state ?? 'visible', timeout }));
|
|
868
1111
|
else if (opts.urlPattern !== undefined)
|
|
869
1112
|
await page.waitForURL(boundedString(opts.urlPattern, 'urlPattern', 2_000), { timeout });
|
|
870
1113
|
else if (opts.networkIdle)
|
|
@@ -872,70 +1115,72 @@ export class BrowserService {
|
|
|
872
1115
|
else {
|
|
873
1116
|
const timeMs = opts.timeMs;
|
|
874
1117
|
if (!Number.isFinite(timeMs) || timeMs < 0 || timeMs > 10_000)
|
|
875
|
-
throw new Error('
|
|
1118
|
+
throw new Error('act.wait timeMs must be between 0 and 10,000');
|
|
876
1119
|
await page.waitForTimeout(timeMs);
|
|
877
1120
|
}
|
|
878
1121
|
return this.readState(page, false);
|
|
879
1122
|
}
|
|
880
1123
|
async press(target, key, opts = {}) {
|
|
881
|
-
const page = await this.ensureActivePage();
|
|
882
|
-
|
|
1124
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1125
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
1126
|
+
const value = boundedString(key, 'act.press key', 100);
|
|
883
1127
|
if (target !== undefined)
|
|
884
|
-
await this.
|
|
1128
|
+
await this.onTarget(page, target, locator => locator.press(value, { timeout: boundedTimeout(opts.timeoutMs, 'act.press timeoutMs') }));
|
|
885
1129
|
else
|
|
886
1130
|
await page.keyboard.press(value);
|
|
887
1131
|
return this.readState(page, false);
|
|
888
1132
|
}
|
|
889
1133
|
async select(target, values, opts = {}) {
|
|
890
1134
|
if (values.length < 1 || values.length > 20)
|
|
891
|
-
throw new Error('
|
|
892
|
-
values.forEach(value => boundedString(value, '
|
|
893
|
-
const page = await this.ensureActivePage();
|
|
894
|
-
|
|
1135
|
+
throw new Error('act.select requires 1 to 20 values');
|
|
1136
|
+
values.forEach(value => boundedString(value, 'act.select value', 2_000));
|
|
1137
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1138
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
1139
|
+
await this.onTarget(page, target, locator => locator.selectOption([...values], { timeout: boundedTimeout(opts.timeoutMs, 'act.select timeoutMs') }));
|
|
895
1140
|
return this.readState(page, false);
|
|
896
1141
|
}
|
|
897
1142
|
async check(target, checked = true, opts = {}) {
|
|
898
|
-
const page = await this.ensureActivePage();
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
else
|
|
903
|
-
await locator.uncheck({ timeout: boundedTimeout(opts.timeoutMs, 'browser_check timeoutMs') });
|
|
1143
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1144
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
1145
|
+
const timeout = boundedTimeout(opts.timeoutMs, 'act.check timeoutMs');
|
|
1146
|
+
await this.onTarget(page, target, locator => checked ? locator.check({ timeout }) : locator.uncheck({ timeout }));
|
|
904
1147
|
return this.readState(page, false);
|
|
905
1148
|
}
|
|
906
1149
|
async hover(target, opts = {}) {
|
|
907
|
-
const page = await this.ensureActivePage();
|
|
908
|
-
|
|
1150
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1151
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
1152
|
+
const timeoutMs = boundedTimeout(opts.timeoutMs, 'act.hover timeoutMs');
|
|
909
1153
|
const waitMs = Math.min(Math.max(opts.waitMs ?? 300, 0), timeoutMs);
|
|
910
|
-
await this.
|
|
1154
|
+
await this.onTarget(page, target, locator => locator.hover({ timeout: timeoutMs }));
|
|
911
1155
|
await page.waitForTimeout(waitMs);
|
|
912
1156
|
return this.readState(page, true);
|
|
913
1157
|
}
|
|
914
1158
|
async setFiles(target, files, opts = {}) {
|
|
915
1159
|
if (files.length === 0 || files.length > 20)
|
|
916
|
-
throw new Error('
|
|
1160
|
+
throw new Error('act.upload requires 1 to 20 files');
|
|
917
1161
|
const resolved = files.map(file => {
|
|
918
1162
|
if (!path.isAbsolute(file))
|
|
919
|
-
throw new Error('
|
|
1163
|
+
throw new Error('act.upload requires absolute file paths: ' + file);
|
|
920
1164
|
const real = fs.realpathSync(file);
|
|
921
1165
|
if (!fs.statSync(real).isFile())
|
|
922
|
-
throw new Error('
|
|
1166
|
+
throw new Error('act.upload path is not a file: ' + file);
|
|
923
1167
|
return real;
|
|
924
1168
|
});
|
|
925
1169
|
const totalBytes = resolved.reduce((total, file) => total + fs.statSync(file).size, 0);
|
|
926
1170
|
if (totalBytes > 512 * 1024 * 1024)
|
|
927
|
-
throw new Error('
|
|
928
|
-
const page = await this.ensureActivePage();
|
|
929
|
-
|
|
1171
|
+
throw new Error('act.upload total upload size exceeds 512 MiB');
|
|
1172
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1173
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
1174
|
+
await this.onTarget(page, target, locator => locator.setInputFiles(resolved, { timeout: boundedTimeout(opts.timeoutMs, 'act.upload timeoutMs') }));
|
|
930
1175
|
return { ...await this.readState(page, true), files: resolved.map(file => path.basename(file)) };
|
|
931
1176
|
}
|
|
932
1177
|
async evaluate(expression, opts = {}) {
|
|
933
1178
|
const source = expression.trim();
|
|
934
1179
|
if (!source)
|
|
935
|
-
throw new Error('
|
|
1180
|
+
throw new Error('script.evaluate requires a JavaScript expression');
|
|
936
1181
|
if (source.length > 20_000)
|
|
937
|
-
throw new Error('
|
|
938
|
-
const page = await this.ensureActivePage();
|
|
1182
|
+
throw new Error('script.evaluate expression exceeds 20,000 characters');
|
|
1183
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
939
1184
|
const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? 15_000, 1_000), 30_000);
|
|
940
1185
|
let timer;
|
|
941
1186
|
let timedOut = false;
|
|
@@ -951,10 +1196,10 @@ export class BrowserService {
|
|
|
951
1196
|
finally {
|
|
952
1197
|
await session.detach().catch(() => { });
|
|
953
1198
|
}
|
|
954
|
-
reject(new Error('
|
|
1199
|
+
reject(new Error('script.evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open'));
|
|
955
1200
|
}
|
|
956
1201
|
catch (error) {
|
|
957
|
-
reject(new Error('
|
|
1202
|
+
reject(new Error('script.evaluate timed out after ' + timeoutMs + 'ms; unable to terminate page JavaScript without closing the active page: ' + String(error).slice(0, 200)));
|
|
958
1203
|
}
|
|
959
1204
|
})();
|
|
960
1205
|
}, timeoutMs);
|
|
@@ -967,7 +1212,7 @@ export class BrowserService {
|
|
|
967
1212
|
}
|
|
968
1213
|
catch (error) {
|
|
969
1214
|
if (timedOut)
|
|
970
|
-
throw new Error('
|
|
1215
|
+
throw new Error('script.evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open');
|
|
971
1216
|
throw error;
|
|
972
1217
|
}
|
|
973
1218
|
return {
|
|
@@ -987,77 +1232,265 @@ export class BrowserService {
|
|
|
987
1232
|
clearTimeout(timer);
|
|
988
1233
|
}
|
|
989
1234
|
}
|
|
1235
|
+
/**
|
|
1236
|
+
* The pages this session holds: the one it opened plus any popup that page
|
|
1237
|
+
* (or a later one) opened in the same context. Pure query: it never creates
|
|
1238
|
+
* a bucket and never changes the active page.
|
|
1239
|
+
*/
|
|
1240
|
+
async listTargets(opts = {}) {
|
|
1241
|
+
const state = this.peek(opts.session);
|
|
1242
|
+
if (!state || !this.browserConnected())
|
|
1243
|
+
return { targets: [] };
|
|
1244
|
+
const targets = [];
|
|
1245
|
+
for (const entry of [...state.pages]) {
|
|
1246
|
+
if (entry.page.isClosed())
|
|
1247
|
+
continue;
|
|
1248
|
+
let title = '';
|
|
1249
|
+
try {
|
|
1250
|
+
title = String(await entry.page.title()).slice(0, 200);
|
|
1251
|
+
}
|
|
1252
|
+
catch { /* a page that is mid-navigation has no title yet */ }
|
|
1253
|
+
targets.push({ id: entry.id, url: entry.page.url(), title, active: entry.page === state.page, generation: entry.generation });
|
|
1254
|
+
}
|
|
1255
|
+
return { targets };
|
|
1256
|
+
}
|
|
1257
|
+
/** Make one of this session's pages the active page: every later action, read, and screenshot targets it. */
|
|
1258
|
+
async selectTarget(id, opts = {}) {
|
|
1259
|
+
const state = this.peek(opts.session);
|
|
1260
|
+
const entry = state?.pages.find(candidate => candidate.id === id && !candidate.page.isClosed());
|
|
1261
|
+
if (!state || !entry)
|
|
1262
|
+
throw new Error(`no active page with target id ${JSON.stringify(id)} in this session; target.list shows the open pages`);
|
|
1263
|
+
state.page = entry.page;
|
|
1264
|
+
await entry.page.bringToFront().catch(() => { });
|
|
1265
|
+
await entry.page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => { });
|
|
1266
|
+
return { id, ...await this.readState(entry.page, false) };
|
|
1267
|
+
}
|
|
990
1268
|
async scroll(deltaY, opts = {}) {
|
|
991
|
-
const page = await this.ensureActivePage();
|
|
1269
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1270
|
+
this.assertGeneration(page, opts.expectGeneration);
|
|
992
1271
|
await page.mouse?.wheel(0, deltaY || 2000).catch(() => { });
|
|
993
1272
|
await page.waitForTimeout(opts.waitMs ?? 500);
|
|
994
1273
|
return this.readState(page, false);
|
|
995
1274
|
}
|
|
996
|
-
async read() {
|
|
997
|
-
const page = await this.ensureActivePage();
|
|
998
|
-
return this.readState(page, false);
|
|
1275
|
+
async read(opts = {}) {
|
|
1276
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1277
|
+
return this.readingNavigation(page, () => this.readState(page, false));
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Run a read of the page. If the page navigates under it ("Execution context was destroyed"), the read is
|
|
1281
|
+
* tried once more on the new document; if that fails the same way the page is still moving, which is a
|
|
1282
|
+
* stale observation (TARGET_STALE), not a failed action. Nothing a read does changes the page.
|
|
1283
|
+
*/
|
|
1284
|
+
async readingNavigation(page, run) {
|
|
1285
|
+
const destroyed = (error) => /Execution context was destroyed|Cannot find context with specified id|Frame was detached|frame got detached/i.test(error instanceof Error ? error.message : String(error));
|
|
1286
|
+
try {
|
|
1287
|
+
return await run();
|
|
1288
|
+
}
|
|
1289
|
+
catch (error) {
|
|
1290
|
+
if (!destroyed(error))
|
|
1291
|
+
throw error;
|
|
1292
|
+
}
|
|
1293
|
+
await page.waitForLoadState?.('domcontentloaded', { timeout: 5_000 }).catch(() => { });
|
|
1294
|
+
try {
|
|
1295
|
+
return await run();
|
|
1296
|
+
}
|
|
1297
|
+
catch (error) {
|
|
1298
|
+
if (!destroyed(error))
|
|
1299
|
+
throw error;
|
|
1300
|
+
const stamp = this.stamp(page);
|
|
1301
|
+
throw new TargetStaleError(`The page ${stamp.targetId ?? ''} navigated while it was being read (Execution context was destroyed, twice): the observation is stale and nothing was changed.`, { targetId: stamp.targetId ?? '', generation: stamp.generation ?? 0 });
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Structured observation of the active page (or of one element's subtree): readable text, controls, links,
|
|
1306
|
+
* and tables, each section bounded by `maxItems` and the whole record by `maxBytes` (see src/observe.ts).
|
|
1307
|
+
* With only the default `content` section, no scope and no limits it is exactly {@link read}.
|
|
1308
|
+
*/
|
|
1309
|
+
async observe(opts = {}) {
|
|
1310
|
+
const request = normalizeObserve(opts);
|
|
1311
|
+
const page = await this.ensureActivePage(undefined, { session: opts.session });
|
|
1312
|
+
return this.readingNavigation(page, () => this.observeOnce(page, request, opts));
|
|
1313
|
+
}
|
|
1314
|
+
async observeOnce(page, request, opts) {
|
|
1315
|
+
const base = scopeBase(request.target);
|
|
1316
|
+
const structured = request.sections.some(section => section !== 'content');
|
|
1317
|
+
const wantsText = request.sections.includes('content');
|
|
1318
|
+
if (!structured && request.target === undefined && opts.maxBytes === undefined && opts.maxItems === undefined)
|
|
1319
|
+
return { ...await this.readState(page, false) };
|
|
1320
|
+
const generationBefore = this.stamp(page).generation;
|
|
1321
|
+
let title = '';
|
|
1322
|
+
let url = page.url();
|
|
1323
|
+
let text;
|
|
1324
|
+
if (wantsText) {
|
|
1325
|
+
if (request.target === undefined) {
|
|
1326
|
+
const state = await this.readState(page, false);
|
|
1327
|
+
title = state.title;
|
|
1328
|
+
url = state.url;
|
|
1329
|
+
text = state.text;
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
const locator = resolveLocator(page, request.target);
|
|
1333
|
+
text = capText(String(await withStrictLocator(locator, l => l.innerText({ timeout: request.timeoutMs }))).replace(/\n{3,}/g, '\n\n').trim(), 100_000);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
if (!title)
|
|
1337
|
+
title = String(await page.title().catch(() => '')).slice(0, 500);
|
|
1338
|
+
const raw = structured ? await scanPage(page, request, base) : undefined;
|
|
1339
|
+
const described = raw ? await describeScan(page, raw, request, base) : undefined;
|
|
1340
|
+
const stamp = this.stamp(page);
|
|
1341
|
+
const limits = [...described?.limits ?? []];
|
|
1342
|
+
if (generationBefore !== stamp.generation)
|
|
1343
|
+
limits.push('the page navigated while it was being observed; observe again');
|
|
1344
|
+
const head = {
|
|
1345
|
+
url, title,
|
|
1346
|
+
...stamp.targetId !== undefined ? { targetId: stamp.targetId } : {},
|
|
1347
|
+
// The generation from before the scan: if the page moved meanwhile, a guarded action based on this fails stale.
|
|
1348
|
+
...generationBefore !== undefined ? { generation: generationBefore } : {},
|
|
1349
|
+
...described && Object.keys(described.counts).length ? { counts: described.counts } : {},
|
|
1350
|
+
...raw && raw.shadowRoots > 0 ? { scanned: { shadowRoots: raw.shadowRoots } } : {},
|
|
1351
|
+
...described?.frames ? { frames: described.frames } : {},
|
|
1352
|
+
...limits.length ? { limits } : {},
|
|
1353
|
+
};
|
|
1354
|
+
const fitted = fitObservation({
|
|
1355
|
+
head,
|
|
1356
|
+
...text !== undefined ? { text } : {},
|
|
1357
|
+
...described?.controls ? { controls: described.controls } : {},
|
|
1358
|
+
...described?.links ? { links: described.links } : {},
|
|
1359
|
+
...described?.tables ? { tables: described.tables } : {},
|
|
1360
|
+
}, request.maxBytes, raw?.counts ?? {}, request.maxItems);
|
|
1361
|
+
return { ...fitted.record, ...fitted.truncation ? { truncation: fitted.truncation } : {} };
|
|
999
1362
|
}
|
|
1000
1363
|
consoleMessages(opts = {}) {
|
|
1001
1364
|
const severities = ['debug', 'log', 'info', 'warning', 'error'];
|
|
1002
1365
|
const threshold = opts.level ? severities.indexOf(opts.level) : 0;
|
|
1003
1366
|
if (opts.level && threshold < 0)
|
|
1004
|
-
throw new Error('
|
|
1367
|
+
throw new Error('inspect.console level must be debug, log, info, warning, or error');
|
|
1005
1368
|
const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
|
|
1006
|
-
|
|
1369
|
+
// A session that never captured has nothing to report; reading must not
|
|
1370
|
+
// mint it a bucket, or `inspect.console` would evict someone's page.
|
|
1371
|
+
const state = this.peek(opts.session) ?? newSessionState();
|
|
1372
|
+
const records = state.capturedConsole.filter(record => {
|
|
1007
1373
|
const index = severities.indexOf(record.type === 'warn' ? 'warning' : record.type);
|
|
1008
1374
|
return index < 0 || index >= threshold;
|
|
1009
1375
|
}).slice(-limit);
|
|
1010
1376
|
if (opts.clear)
|
|
1011
|
-
|
|
1012
|
-
return { enabled:
|
|
1377
|
+
state.capturedConsole = [];
|
|
1378
|
+
return { enabled: state.captureConsole, records };
|
|
1013
1379
|
}
|
|
1014
1380
|
networkRequests(opts = {}) {
|
|
1015
1381
|
const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
|
|
1016
|
-
const
|
|
1382
|
+
const state = this.peek(opts.session) ?? newSessionState();
|
|
1383
|
+
const records = state.capturedRequests.slice(-limit);
|
|
1017
1384
|
if (opts.clear)
|
|
1018
|
-
|
|
1019
|
-
return { enabled:
|
|
1385
|
+
state.capturedRequests = [];
|
|
1386
|
+
return { enabled: state.captureNetwork, records };
|
|
1020
1387
|
}
|
|
1021
1388
|
async screenshot(options = {}) {
|
|
1022
|
-
const page = await this.ensureActivePage();
|
|
1389
|
+
const page = await this.ensureActivePage(undefined, { session: options.session });
|
|
1023
1390
|
return { path: await this.captureScreenshot(page, options) };
|
|
1024
1391
|
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Run a recipe on the session's page.
|
|
1394
|
+
*
|
|
1395
|
+
* A business failure comes back as a value (`executionStatus`, `failedStep`,
|
|
1396
|
+
* `completedSteps`, `effects`), never as an exception; only problems before
|
|
1397
|
+
* any step ran (no page, navigation failure, a malformed recipe) throw.
|
|
1398
|
+
*
|
|
1399
|
+
* Cancelling stops this recipe only: the session page stays open and usable.
|
|
1400
|
+
* A step that is already running cannot be interrupted, so the call returns
|
|
1401
|
+
* once that step has returned. The caller therefore still holds the session
|
|
1402
|
+
* (browser_call is serialized per agent) until the page is quiet again.
|
|
1403
|
+
*/
|
|
1025
1404
|
async recipe(steps, opts = {}) {
|
|
1026
|
-
if (
|
|
1027
|
-
|
|
1405
|
+
if (opts.isolated)
|
|
1406
|
+
return this.isolatedRecipe(steps, opts);
|
|
1407
|
+
const existing = this.peek(opts.session)?.page;
|
|
1408
|
+
if (!opts.url && (!existing || existing.isClosed()))
|
|
1409
|
+
throw new Error('browser recipe requires url or an active target.open page');
|
|
1028
1410
|
const page = await this.ensureActivePage(opts.url, opts);
|
|
1411
|
+
return this.runRecipeOn(page, this.state(opts.session), steps, opts);
|
|
1412
|
+
}
|
|
1413
|
+
/** A recipe in its own context: nothing of the caller's session (page, cookies, storage, journal) is shared or changed. */
|
|
1414
|
+
async isolatedRecipe(steps, opts) {
|
|
1415
|
+
if (!opts.url)
|
|
1416
|
+
throw new Error('an isolated recipe run requires url');
|
|
1417
|
+
const state = newSessionState();
|
|
1418
|
+
const created = await this.transientContext(opts.url, opts);
|
|
1419
|
+
state.context = created.context;
|
|
1420
|
+
state.profile = created.profile;
|
|
1421
|
+
state.rulePack = created.rulePack;
|
|
1422
|
+
try {
|
|
1423
|
+
state.context.on('page', (opened) => { if (state.context)
|
|
1424
|
+
this.trackPage(state, opened); });
|
|
1425
|
+
state.page = await state.context.newPage();
|
|
1426
|
+
this.trackPage(state, state.page);
|
|
1427
|
+
return await this.runRecipeOn(state.page, state, steps, opts);
|
|
1428
|
+
}
|
|
1429
|
+
finally {
|
|
1430
|
+
await this.closePage(state).catch(() => { });
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
async runRecipeOn(page, state, steps, opts) {
|
|
1029
1434
|
if (opts.url) {
|
|
1030
1435
|
page.setDefaultTimeout(30_000);
|
|
1031
1436
|
await this.navigate(page, opts.url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, opts.signal);
|
|
1032
1437
|
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
1033
|
-
await applyRuleSteps(page,
|
|
1438
|
+
await applyRuleSteps(page, state.rulePack);
|
|
1034
1439
|
if (opts.waitMs)
|
|
1035
1440
|
await page.waitForTimeout(opts.waitMs);
|
|
1036
1441
|
}
|
|
1037
|
-
const
|
|
1038
|
-
if (opts.
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1442
|
+
const options = { legacy: opts.legacyRecipe };
|
|
1443
|
+
if (opts.schemaVersion === 2) {
|
|
1444
|
+
let startOrigin;
|
|
1445
|
+
try {
|
|
1446
|
+
startOrigin = new URL(page.url()).origin;
|
|
1447
|
+
}
|
|
1448
|
+
catch { /* about:blank has no origin */ }
|
|
1449
|
+
Object.assign(options, {
|
|
1450
|
+
schemaVersion: 2,
|
|
1451
|
+
...opts.postconditions ? { postconditions: opts.postconditions } : {},
|
|
1452
|
+
...opts.outputSchema ? { outputSchema: opts.outputSchema } : {},
|
|
1453
|
+
...opts.allowedDomains ? { allowedDomains: opts.allowedDomains } : {},
|
|
1454
|
+
...opts.gotoSameOrigin && startOrigin && startOrigin !== 'null' ? { sameOrigin: startOrigin } : {},
|
|
1455
|
+
// Same navigation path as target.open: usage governor, settle, rule steps.
|
|
1456
|
+
goto: async (target) => {
|
|
1457
|
+
await this.navigate(page, target, { waitUntil: 'domcontentloaded', timeout: 30_000 }, opts.signal);
|
|
1458
|
+
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
1459
|
+
await applyRuleSteps(page, state.rulePack);
|
|
1460
|
+
},
|
|
1461
|
+
});
|
|
1048
1462
|
}
|
|
1463
|
+
const run = await runRecipe(page, steps, () => this.captureScreenshot(page), opts.signal, options);
|
|
1464
|
+
const result = await this.readState(page, false).catch(() => ({ url: page.isClosed() ? '' : page.url(), title: '', text: '' }));
|
|
1465
|
+
return { ...result, ...run, steps: run.completedSteps };
|
|
1049
1466
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1467
|
+
/**
|
|
1468
|
+
* Close one session's page.
|
|
1469
|
+
*
|
|
1470
|
+
* `session` is the caller's bucket: a tool call closes only the page that
|
|
1471
|
+
* session opened. Only a caller with no session (a service consumer outside
|
|
1472
|
+
* the agent loop) reaches the shared bucket, which preserves the historical
|
|
1473
|
+
* behaviour for those consumers.
|
|
1474
|
+
*/
|
|
1475
|
+
async closePage(session) {
|
|
1476
|
+
if (!session)
|
|
1477
|
+
return;
|
|
1478
|
+
const page = session.page;
|
|
1479
|
+
const context = session.context;
|
|
1480
|
+
const profile = session.profile;
|
|
1481
|
+
session.page = undefined;
|
|
1482
|
+
session.pages = [];
|
|
1483
|
+
session.context = undefined;
|
|
1484
|
+
session.profile = undefined;
|
|
1485
|
+
session.rulePack = undefined;
|
|
1486
|
+
session.capturedConsole = [];
|
|
1487
|
+
session.capturedRequests = [];
|
|
1055
1488
|
if (page)
|
|
1056
1489
|
await page.close().catch(() => { });
|
|
1057
1490
|
if (context)
|
|
1058
1491
|
await this.persistAndClose({ context, ...profile ? { profile } : {} });
|
|
1059
1492
|
}
|
|
1060
|
-
async status() {
|
|
1493
|
+
async status(opts = {}) {
|
|
1061
1494
|
let chromiumInstalled = false;
|
|
1062
1495
|
let chromiumExecutablePath;
|
|
1063
1496
|
try {
|
|
@@ -1089,7 +1522,7 @@ export class BrowserService {
|
|
|
1089
1522
|
]
|
|
1090
1523
|
: [];
|
|
1091
1524
|
if (!chromiumInstalled && chromiumExecutablePath && (this.config.channel === 'chromium' || !!this.config.executablePath)) {
|
|
1092
|
-
runtimeWarnings.push(`Expected Chromium executable is missing: ${chromiumExecutablePath}. Run
|
|
1525
|
+
runtimeWarnings.push(`Expected Chromium executable is missing: ${chromiumExecutablePath}. Run runtime.install for ${this.config.browserRuntime}.`);
|
|
1093
1526
|
}
|
|
1094
1527
|
if (this.config.opencliEnabled && !opencliInstalled)
|
|
1095
1528
|
runtimeWarnings.push('OpenCLI is enabled but its package entry is not installed.');
|
|
@@ -1103,7 +1536,8 @@ export class BrowserService {
|
|
|
1103
1536
|
opencliInstalled,
|
|
1104
1537
|
...resolvedOpencliEntryPath ? { opencliEntryPath: resolvedOpencliEntryPath } : {},
|
|
1105
1538
|
automationMode: this.config.automationMode,
|
|
1106
|
-
exposedTools: configuredBrowserTools(this.config.automationMode, this.config.automationAssets, this.config.enabled),
|
|
1539
|
+
exposedTools: configuredBrowserTools(this.config.automationMode, this.config.automationAssets, this.config.enabled, this.config.toolSurface),
|
|
1540
|
+
exposedActions: configuredBrowserActions(this.config.automationMode, this.config.automationAssets, this.config.enabled),
|
|
1107
1541
|
directInteractionPolicy: !this.config.enabled || this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'standard' ? 'ask' : 'allow',
|
|
1108
1542
|
mutatingRecipePolicy: !this.config.enabled || this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'standard' ? 'ask' : 'allow',
|
|
1109
1543
|
externalUserscriptPolicy: !this.config.enabled || this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'unrestricted' ? 'allow' : 'ask',
|
|
@@ -1119,12 +1553,27 @@ export class BrowserService {
|
|
|
1119
1553
|
builtinScripts: BUILTIN_SCRIPTS.map(script => script.id),
|
|
1120
1554
|
externalUserscriptsRequireApproval: ['standard', 'autonomous'].includes(this.config.automationMode),
|
|
1121
1555
|
mutatingRecipesRequireApproval: this.config.automationMode === 'standard',
|
|
1122
|
-
...(
|
|
1123
|
-
|
|
1556
|
+
...(() => {
|
|
1557
|
+
// Report only the caller's own page, so one session cannot observe that
|
|
1558
|
+
// another session currently has a page open. `peek` keeps this a pure
|
|
1559
|
+
// query: asking about a session never creates or evicts a bucket.
|
|
1560
|
+
const own = this.peek(opts.session);
|
|
1561
|
+
if (!this.browserConnected() || !own?.page || own.page.isClosed())
|
|
1562
|
+
return {};
|
|
1563
|
+
return {
|
|
1564
|
+
activeUrl: own.page.url(),
|
|
1565
|
+
...own.profile ? { activeAuthProfile: own.profile.id } : {},
|
|
1566
|
+
};
|
|
1567
|
+
})(),
|
|
1124
1568
|
};
|
|
1125
1569
|
}
|
|
1126
1570
|
async close() {
|
|
1127
|
-
|
|
1571
|
+
// Process teardown (fiber dispose): every session's page and context goes,
|
|
1572
|
+
// because the browser process itself is going away.
|
|
1573
|
+
for (const state of [...this.sessions.values()])
|
|
1574
|
+
await this.closePage(state);
|
|
1575
|
+
this.sessions.clear();
|
|
1576
|
+
this.journals.clear();
|
|
1128
1577
|
const b = this.browser;
|
|
1129
1578
|
this.browser = undefined;
|
|
1130
1579
|
this.launching = undefined;
|