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