@oharness/engine 0.7.0 → 0.8.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/dist/agent/agent.d.ts +72 -0
- package/dist/agent/agent.js +114 -1
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/permissions.js +8 -0
- package/dist/agent/permissions.js.map +1 -1
- package/dist/browser/cdp.d.ts +75 -0
- package/dist/browser/cdp.js +136 -0
- package/dist/browser/cdp.js.map +1 -0
- package/dist/browser/chrome.d.ts +19 -0
- package/dist/browser/chrome.js +306 -0
- package/dist/browser/chrome.js.map +1 -0
- package/dist/browser/extension.d.ts +35 -0
- package/dist/browser/extension.js +297 -0
- package/dist/browser/extension.js.map +1 -0
- package/dist/browser/host-manifest.d.ts +78 -0
- package/dist/browser/host-manifest.js +166 -0
- package/dist/browser/host-manifest.js.map +1 -0
- package/dist/browser/host.d.ts +27 -0
- package/dist/browser/host.js +311 -0
- package/dist/browser/host.js.map +1 -0
- package/dist/browser/index.d.ts +26 -0
- package/dist/browser/index.js +15 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/login-import.d.ts +98 -0
- package/dist/browser/login-import.js +244 -0
- package/dist/browser/login-import.js.map +1 -0
- package/dist/browser/plugin.d.ts +109 -0
- package/dist/browser/plugin.js +22 -0
- package/dist/browser/plugin.js.map +1 -0
- package/dist/browser/presence.d.ts +29 -0
- package/dist/browser/presence.js +54 -0
- package/dist/browser/presence.js.map +1 -0
- package/dist/browser/provider.d.ts +17 -0
- package/dist/browser/provider.js +13 -0
- package/dist/browser/provider.js.map +1 -0
- package/dist/browser/screencast.d.ts +42 -0
- package/dist/browser/screencast.js +99 -0
- package/dist/browser/screencast.js.map +1 -0
- package/dist/browser/session.d.ts +320 -0
- package/dist/browser/session.js +837 -0
- package/dist/browser/session.js.map +1 -0
- package/dist/browser/snapshot.d.ts +49 -0
- package/dist/browser/snapshot.js +145 -0
- package/dist/browser/snapshot.js.map +1 -0
- package/dist/browser/socket.d.ts +15 -0
- package/dist/browser/socket.js +32 -0
- package/dist/browser/socket.js.map +1 -0
- package/dist/browser/target.d.ts +112 -0
- package/dist/browser/target.js +20 -0
- package/dist/browser/target.js.map +1 -0
- package/dist/browser/tools.d.ts +3 -0
- package/dist/browser/tools.js +537 -0
- package/dist/browser/tools.js.map +1 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js.map +1 -1
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +14 -1
- package/dist/core/errors.js.map +1 -1
- package/dist/harness.d.ts +49 -0
- package/dist/harness.js +97 -1
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/piai.js +7 -2
- package/dist/providers/piai.js.map +1 -1
- package/dist/tools/define.d.ts +1 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/define.js.map +1 -1
- package/dist/tools/types.d.ts +11 -0
- package/dist/tools/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import { BrowserError } from "../core/errors.js";
|
|
2
|
+
import { browserProviderFor } from "./provider.js";
|
|
3
|
+
import { buildSnapshot } from "./snapshot.js";
|
|
4
|
+
import { LoginImportMemory, externalBrowserName, hostInSite, listExternalSites, normalizeSite, readSiteLoginState, } from "./login-import.js";
|
|
5
|
+
/**
|
|
6
|
+
* The browser, for the length of one agent session.
|
|
7
|
+
*
|
|
8
|
+
* Allocated for an agent session and launched at first use. That split is the
|
|
9
|
+
* point of this class: registering the tools has to be free, because most
|
|
10
|
+
* sessions never touch a browser and a scheduled routine that starts Chrome to
|
|
11
|
+
* run a shell command would be indefensible — but once a tool is called, every
|
|
12
|
+
* later call in the session has to land on the same browser, or a login on one
|
|
13
|
+
* step is gone by the next.
|
|
14
|
+
*
|
|
15
|
+
* It also holds the two things that make the tools usable rather than merely
|
|
16
|
+
* present: the ref table from the last snapshot, and the console and network
|
|
17
|
+
* rings. Those rings are why this exists at all for most users — the common
|
|
18
|
+
* errand is not "book me a flight", it is "open my dev server and tell me what
|
|
19
|
+
* the error is", and the answer to that is in the console.
|
|
20
|
+
*/
|
|
21
|
+
const CONSOLE_LIMIT = 200;
|
|
22
|
+
const NETWORK_LIMIT = 200;
|
|
23
|
+
function emptySide() {
|
|
24
|
+
return { refs: new Map(), wired: new WeakSet() };
|
|
25
|
+
}
|
|
26
|
+
/** How long a "is this site open in your browser?" answer is trusted. */
|
|
27
|
+
const SUGGESTION_TTL_MS = 60_000;
|
|
28
|
+
let sharedLoginMemory;
|
|
29
|
+
export class BrowserSession {
|
|
30
|
+
/**
|
|
31
|
+
* Both browsers' state, each started only when first used. Everything below
|
|
32
|
+
* reads the one in use through the accessors, so a call that switches
|
|
33
|
+
* browsers finds the other one's tab, refs and origin exactly as it left
|
|
34
|
+
* them — the built-in browser keeps its page while the person's is used.
|
|
35
|
+
*/
|
|
36
|
+
sides = { builtin: emptySide(), extension: emptySide() };
|
|
37
|
+
get side() {
|
|
38
|
+
return this.sides[this.mode];
|
|
39
|
+
}
|
|
40
|
+
get target() { return this.side.target; }
|
|
41
|
+
set target(value) { this.side.target = value; }
|
|
42
|
+
get starting() { return this.side.starting; }
|
|
43
|
+
set starting(value) { this.side.starting = value; }
|
|
44
|
+
get active() { return this.side.active; }
|
|
45
|
+
set active(value) { this.side.active = value; }
|
|
46
|
+
get pageStarting() { return this.side.pageStarting; }
|
|
47
|
+
set pageStarting(value) { this.side.pageStarting = value; }
|
|
48
|
+
get refs() { return this.side.refs; }
|
|
49
|
+
set refs(value) { this.side.refs = value; }
|
|
50
|
+
get wired() { return this.side.wired; }
|
|
51
|
+
set wired(value) { this.side.wired = value; }
|
|
52
|
+
get currentOrigin() { return this.side.currentOrigin; }
|
|
53
|
+
set currentOrigin(value) { this.side.currentOrigin = value; }
|
|
54
|
+
consoleLog = [];
|
|
55
|
+
network = [];
|
|
56
|
+
opts;
|
|
57
|
+
sessionId;
|
|
58
|
+
label;
|
|
59
|
+
/** The browser in use now. */
|
|
60
|
+
mode;
|
|
61
|
+
/** Which browsers this session may use. */
|
|
62
|
+
policy;
|
|
63
|
+
/** Said once, with the next tool result: a fallback the model should know about. */
|
|
64
|
+
notices = [];
|
|
65
|
+
importing = false;
|
|
66
|
+
probing;
|
|
67
|
+
suggestions = new Map();
|
|
68
|
+
constructor(opts = {}) {
|
|
69
|
+
this.opts = opts;
|
|
70
|
+
this.policy = opts.mode === "extension" || opts.mode === "auto" ? opts.mode : "builtin";
|
|
71
|
+
// Auto starts where a browser costs the person nothing: the built-in one.
|
|
72
|
+
this.mode = this.policy === "extension" ? "extension" : "builtin";
|
|
73
|
+
this.label = opts.label;
|
|
74
|
+
}
|
|
75
|
+
/** Name the conversation this browser works for. Read lazily, so it may change. */
|
|
76
|
+
setLabel(label) {
|
|
77
|
+
this.label = label;
|
|
78
|
+
}
|
|
79
|
+
/** Bind the lazily-created browser to its owning conversation. */
|
|
80
|
+
bind(sessionId) {
|
|
81
|
+
if (this.target || this.starting)
|
|
82
|
+
throw new Error("cannot rebind a running browser session");
|
|
83
|
+
this.sessionId = sessionId;
|
|
84
|
+
}
|
|
85
|
+
/** Whether a browser is running, without starting one to find out. */
|
|
86
|
+
get running() {
|
|
87
|
+
return this.target !== undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The origin the permission engine asks about.
|
|
91
|
+
*
|
|
92
|
+
* Undefined before the first navigation, which is what makes the very first
|
|
93
|
+
* `browser_navigate` the call that gets asked: after that the origin is known
|
|
94
|
+
* and an approval for it covers the clicking and typing that follow.
|
|
95
|
+
*/
|
|
96
|
+
origin() {
|
|
97
|
+
return this.currentOrigin;
|
|
98
|
+
}
|
|
99
|
+
async page() {
|
|
100
|
+
if (this.active)
|
|
101
|
+
return this.active;
|
|
102
|
+
// The pane asks for URL and viewport together, and models often batch
|
|
103
|
+
// browser tools. Until the first page has been adopted all of those calls
|
|
104
|
+
// can arrive here at once. Share that first lookup/creation so read-only
|
|
105
|
+
// callers cannot accidentally leave several about:blank tabs behind.
|
|
106
|
+
this.pageStarting ??= (async () => {
|
|
107
|
+
const target = await this.start();
|
|
108
|
+
const existing = await target.pages();
|
|
109
|
+
const page = existing[0] ?? (await target.newPage());
|
|
110
|
+
await this.adopt(page);
|
|
111
|
+
return page;
|
|
112
|
+
})();
|
|
113
|
+
try {
|
|
114
|
+
return await this.pageStarting;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
this.pageStarting = undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async start() {
|
|
121
|
+
if (this.target)
|
|
122
|
+
return this.target;
|
|
123
|
+
const side = this.mode;
|
|
124
|
+
// Concurrent first calls must not race two browsers into existence; the
|
|
125
|
+
// tools are concurrency-safe on paper and the model does batch them.
|
|
126
|
+
this.starting ??= (async () => {
|
|
127
|
+
const factory = this.opts.target ?? browserProviderFor(this.mode, this.opts.providers, {
|
|
128
|
+
headless: this.opts.headless ?? true,
|
|
129
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
130
|
+
}).createTarget;
|
|
131
|
+
const target = await factory({
|
|
132
|
+
...(this.sessionId ? { sessionId: this.sessionId } : {}),
|
|
133
|
+
...(this.label ? { label: this.label } : {}),
|
|
134
|
+
...(this.opts.cwd ? { cwd: this.opts.cwd } : {}),
|
|
135
|
+
});
|
|
136
|
+
this.target = target;
|
|
137
|
+
return target;
|
|
138
|
+
})();
|
|
139
|
+
try {
|
|
140
|
+
return await this.starting;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
// A failed launch must not poison the session: the person may be about
|
|
144
|
+
// to install a browser, or set OHARNESS_BROWSER_PATH, and try again.
|
|
145
|
+
this.sides[side].starting = undefined;
|
|
146
|
+
// Batched calls all wait on the one launch. Whichever got here first
|
|
147
|
+
// has already fallen back; the rest follow it rather than fail.
|
|
148
|
+
if (this.policy === "auto" && side === "extension" && this.mode === "builtin")
|
|
149
|
+
return this.start();
|
|
150
|
+
// In auto, the person's browser being out of reach is not the end of
|
|
151
|
+
// the task: the built-in one can do it, with its own sign-ins, and the
|
|
152
|
+
// model is told so it can say as much. The other way round is never
|
|
153
|
+
// taken on its own — moving into someone's signed-in browser is a
|
|
154
|
+
// choice the model makes in the open, and asks permission for.
|
|
155
|
+
if (this.policy === "auto" && side === "extension") {
|
|
156
|
+
this.mode = "builtin";
|
|
157
|
+
this.notices.push(`The user's own browser could not be reached (${errorText(err)}), so this ran in the built-in browser instead. Its sign-ins are separate from the user's.`);
|
|
158
|
+
return this.start();
|
|
159
|
+
}
|
|
160
|
+
if (this.policy === "auto" && side === "builtin") {
|
|
161
|
+
throw new BrowserError(`the built-in browser could not start (${errorText(err)}). To work in the user's own browser instead, call again with browser: "yours".`, err);
|
|
162
|
+
}
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Which browser a call that can open a page will use.
|
|
168
|
+
*
|
|
169
|
+
* Pure and synchronous, because the permission check asks it before the
|
|
170
|
+
* call runs: what is approved has to be what then happens. A fixed policy
|
|
171
|
+
* always answers itself. In auto, the call's own `browser` wins — the model
|
|
172
|
+
* reads the person's intent, and says "yours" when the task needs their
|
|
173
|
+
* accounts or a page they have open. Without one, the conversation stays in
|
|
174
|
+
* the browser it is already working in, so a task never changes browsers
|
|
175
|
+
* halfway and loses the page it was on.
|
|
176
|
+
*/
|
|
177
|
+
sideFor(request) {
|
|
178
|
+
if (this.policy !== "auto")
|
|
179
|
+
return this.policy;
|
|
180
|
+
if (request)
|
|
181
|
+
return request === "yours" ? "extension" : "builtin";
|
|
182
|
+
return this.mode;
|
|
183
|
+
}
|
|
184
|
+
/** The browser in use now, and which ones this session may use. */
|
|
185
|
+
current() {
|
|
186
|
+
return { side: this.mode, policy: this.policy };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Move to the browser a call asked for, before it runs. A request a fixed
|
|
190
|
+
* policy cannot honour is answered with why, rather than ignored.
|
|
191
|
+
*/
|
|
192
|
+
use(request) {
|
|
193
|
+
const side = this.sideFor(request);
|
|
194
|
+
if (request && this.policy !== "auto" && side !== (request === "yours" ? "extension" : "builtin")) {
|
|
195
|
+
this.notices.push(side === "builtin"
|
|
196
|
+
? "Settings keep browser use in the built-in browser, so this ran there."
|
|
197
|
+
: "Settings keep browser use in the user's own browser, so this ran there.");
|
|
198
|
+
}
|
|
199
|
+
this.mode = side;
|
|
200
|
+
}
|
|
201
|
+
/** What the model has not been told yet. Each notice is handed out once. */
|
|
202
|
+
takeNotices() {
|
|
203
|
+
return this.notices.splice(0);
|
|
204
|
+
}
|
|
205
|
+
/** Make `page` the one the tools act on, wiring its log streams once. */
|
|
206
|
+
async adopt(page) {
|
|
207
|
+
this.active = page;
|
|
208
|
+
await this.target?.activate?.(page);
|
|
209
|
+
this.refs = new Map();
|
|
210
|
+
if (this.wired.has(page))
|
|
211
|
+
return;
|
|
212
|
+
this.wired.add(page);
|
|
213
|
+
page.on("Runtime.consoleAPICalled", (params) => {
|
|
214
|
+
const args = Array.isArray(params["args"]) ? params["args"] : [];
|
|
215
|
+
const text = args
|
|
216
|
+
.map((arg) => {
|
|
217
|
+
const record = arg;
|
|
218
|
+
const value = record["value"] ?? record["description"] ?? record["unserializableValue"];
|
|
219
|
+
return value === undefined ? String(record["type"] ?? "") : String(value);
|
|
220
|
+
})
|
|
221
|
+
.join(" ");
|
|
222
|
+
this.record(this.consoleLog, { level: String(params["type"] ?? "log"), text, at: Date.now() }, CONSOLE_LIMIT);
|
|
223
|
+
});
|
|
224
|
+
page.on("Log.entryAdded", (params) => {
|
|
225
|
+
const entry = (params["entry"] ?? {});
|
|
226
|
+
this.record(this.consoleLog, {
|
|
227
|
+
level: String(entry["level"] ?? "info"),
|
|
228
|
+
text: String(entry["text"] ?? ""),
|
|
229
|
+
...(typeof entry["url"] === "string" ? { url: entry["url"] } : {}),
|
|
230
|
+
at: Date.now(),
|
|
231
|
+
}, CONSOLE_LIMIT);
|
|
232
|
+
});
|
|
233
|
+
page.on("Runtime.exceptionThrown", (params) => {
|
|
234
|
+
const details = (params["exceptionDetails"] ?? {});
|
|
235
|
+
const thrown = (details["exception"] ?? {});
|
|
236
|
+
this.record(this.consoleLog, {
|
|
237
|
+
level: "error",
|
|
238
|
+
text: String(thrown["description"] ?? details["text"] ?? "uncaught exception"),
|
|
239
|
+
at: Date.now(),
|
|
240
|
+
}, CONSOLE_LIMIT);
|
|
241
|
+
});
|
|
242
|
+
page.on("Network.requestWillBeSent", (params) => {
|
|
243
|
+
const request = (params["request"] ?? {});
|
|
244
|
+
this.record(this.network, {
|
|
245
|
+
requestId: String(params["requestId"] ?? ""),
|
|
246
|
+
method: String(request["method"] ?? "GET"),
|
|
247
|
+
url: String(request["url"] ?? ""),
|
|
248
|
+
at: Date.now(),
|
|
249
|
+
}, NETWORK_LIMIT);
|
|
250
|
+
});
|
|
251
|
+
page.on("Network.responseReceived", (params) => {
|
|
252
|
+
const response = (params["response"] ?? {});
|
|
253
|
+
const entry = this.network.find((item) => item.requestId === String(params["requestId"] ?? ""));
|
|
254
|
+
if (!entry)
|
|
255
|
+
return;
|
|
256
|
+
entry.status = Number(response["status"] ?? 0);
|
|
257
|
+
entry.mimeType = String(response["mimeType"] ?? "");
|
|
258
|
+
});
|
|
259
|
+
page.on("Network.loadingFailed", (params) => {
|
|
260
|
+
const entry = this.network.find((item) => item.requestId === String(params["requestId"] ?? ""));
|
|
261
|
+
if (entry)
|
|
262
|
+
entry.failed = String(params["errorText"] ?? "failed");
|
|
263
|
+
});
|
|
264
|
+
await page.send("Network.enable").catch(() => { });
|
|
265
|
+
}
|
|
266
|
+
record(ring, entry, limit) {
|
|
267
|
+
ring.push(entry);
|
|
268
|
+
if (ring.length > limit)
|
|
269
|
+
ring.splice(0, ring.length - limit);
|
|
270
|
+
}
|
|
271
|
+
async navigate(url) {
|
|
272
|
+
const page = await this.page();
|
|
273
|
+
const before = await this.tabUrl(page).catch(() => "");
|
|
274
|
+
let redirected;
|
|
275
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
276
|
+
const result = await page.send("Page.navigate", { url });
|
|
277
|
+
const error = result["errorText"];
|
|
278
|
+
if (typeof error !== "string" || error === "") {
|
|
279
|
+
await this.settle(page);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
// Chromium reports ERR_ABORTED when a navigation is replaced by its
|
|
283
|
+
// redirect or another renderer-initiated navigation. It is not a failure
|
|
284
|
+
// if the page actually moved; if it did not, one retry handles the brief
|
|
285
|
+
// overlap without hiding a real error forever.
|
|
286
|
+
if (error === "net::ERR_ABORTED") {
|
|
287
|
+
redirected = await this.waitForUrlChange(page, before);
|
|
288
|
+
if (redirected)
|
|
289
|
+
break;
|
|
290
|
+
if (attempt === 0)
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
throw new BrowserError(`could not load ${url}: ${error}`);
|
|
294
|
+
}
|
|
295
|
+
const current = redirected ?? await this.tabUrl(page);
|
|
296
|
+
this.currentOrigin = originOf(current);
|
|
297
|
+
this.refs = new Map();
|
|
298
|
+
return { url: current };
|
|
299
|
+
}
|
|
300
|
+
async waitForUrlChange(page, before, timeoutMs = 1_200) {
|
|
301
|
+
const deadline = Date.now() + timeoutMs;
|
|
302
|
+
while (Date.now() < deadline) {
|
|
303
|
+
const current = await this.tabUrl(page).catch(() => "");
|
|
304
|
+
if (current && current !== before && !current.startsWith("chrome-error://"))
|
|
305
|
+
return current;
|
|
306
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
async reload() {
|
|
311
|
+
const page = await this.page();
|
|
312
|
+
await page.send("Page.reload");
|
|
313
|
+
await this.settle(page);
|
|
314
|
+
const url = await this.url();
|
|
315
|
+
this.currentOrigin = originOf(url);
|
|
316
|
+
this.refs = new Map();
|
|
317
|
+
return { url };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Wait for the page to stop changing, with a ceiling.
|
|
321
|
+
*
|
|
322
|
+
* `Page.loadEventFired` is the signal, but a single-page app may never fire
|
|
323
|
+
* another one after the first route, so the timeout is the real contract: this
|
|
324
|
+
* returns when the page looks ready *or* soon, and never blocks a turn on a
|
|
325
|
+
* page that streams forever.
|
|
326
|
+
*/
|
|
327
|
+
async settle(page, timeoutMs = 10_000) {
|
|
328
|
+
await new Promise((resolve) => {
|
|
329
|
+
const done = () => {
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
off();
|
|
332
|
+
resolve();
|
|
333
|
+
};
|
|
334
|
+
const timer = setTimeout(done, timeoutMs);
|
|
335
|
+
const off = page.on("Page.loadEventFired", done);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
async url() {
|
|
339
|
+
const page = await this.page();
|
|
340
|
+
const result = await page.send("Runtime.evaluate", {
|
|
341
|
+
expression: "location.href",
|
|
342
|
+
returnByValue: true,
|
|
343
|
+
});
|
|
344
|
+
const value = (result["result"] ?? {});
|
|
345
|
+
return String(value["value"] ?? "");
|
|
346
|
+
}
|
|
347
|
+
async snapshot(opts = {}) {
|
|
348
|
+
const page = await this.page();
|
|
349
|
+
const result = await page.send("Accessibility.getFullAXTree");
|
|
350
|
+
const nodes = (Array.isArray(result["nodes"]) ? result["nodes"] : []);
|
|
351
|
+
const snapshot = buildSnapshot(nodes, opts);
|
|
352
|
+
// Refs are only meaningful against the tree they came from, so each
|
|
353
|
+
// snapshot replaces the table rather than adding to it: a stale ref should
|
|
354
|
+
// fail loudly, not click whatever now occupies that slot.
|
|
355
|
+
this.refs = snapshot.refs;
|
|
356
|
+
this.currentOrigin = originOf(await this.url());
|
|
357
|
+
return snapshot;
|
|
358
|
+
}
|
|
359
|
+
/** Where a ref sits on screen, in viewport coordinates. */
|
|
360
|
+
async locate(ref) {
|
|
361
|
+
const page = await this.page();
|
|
362
|
+
const backendNodeId = this.refs.get(ref);
|
|
363
|
+
if (backendNodeId === undefined) {
|
|
364
|
+
throw new BrowserError(`no such element ${ref}. Call browser_read_page to get current refs — they are replaced on every snapshot.`);
|
|
365
|
+
}
|
|
366
|
+
await page.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(() => { });
|
|
367
|
+
const box = await page.send("DOM.getBoxModel", { backendNodeId }).catch(() => undefined);
|
|
368
|
+
const model = (box?.["model"] ?? {});
|
|
369
|
+
const quad = Array.isArray(model["content"]) ? model["content"] : undefined;
|
|
370
|
+
if (!quad || quad.length < 8) {
|
|
371
|
+
throw new BrowserError(`${ref} is not visible on the page`);
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
x: (quad[0] + quad[2] + quad[4] + quad[6]) / 4,
|
|
375
|
+
y: (quad[1] + quad[3] + quad[5] + quad[7]) / 4,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
async resolveObject(ref) {
|
|
379
|
+
const page = await this.page();
|
|
380
|
+
const backendNodeId = this.refs.get(ref);
|
|
381
|
+
if (backendNodeId === undefined)
|
|
382
|
+
throw new BrowserError(`no such element ${ref}`);
|
|
383
|
+
const result = await page.send("DOM.resolveNode", { backendNodeId });
|
|
384
|
+
const object = (result["object"] ?? {});
|
|
385
|
+
const objectId = object["objectId"];
|
|
386
|
+
if (typeof objectId !== "string")
|
|
387
|
+
throw new BrowserError(`could not address ${ref}`);
|
|
388
|
+
return objectId;
|
|
389
|
+
}
|
|
390
|
+
/** Every open tab. Starts the browser if nothing has yet. */
|
|
391
|
+
async tabs() {
|
|
392
|
+
const target = await this.start();
|
|
393
|
+
return target.pages();
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Every open tab, described without attaching to any. Starts the browser if
|
|
397
|
+
* nothing has yet. In the person's own browser this is the whole window
|
|
398
|
+
* list, and a tab is only attached — and bannered — once it is selected.
|
|
399
|
+
*/
|
|
400
|
+
async tabList() {
|
|
401
|
+
const target = await this.start();
|
|
402
|
+
if (!target.pageInfos)
|
|
403
|
+
return (await this.paneTabs()).map((tab) => ({ ...tab, mine: true }));
|
|
404
|
+
const infos = await target.pageInfos();
|
|
405
|
+
const mine = target.opened ? new Set((await target.pages()).map((page) => page.id)) : undefined;
|
|
406
|
+
return infos.map((page) => ({ ...page, active: page.id === this.active?.id, mine: !mine || mine.has(page.id) }));
|
|
407
|
+
}
|
|
408
|
+
/** Open a tab and make it the active one. */
|
|
409
|
+
async newTab(url) {
|
|
410
|
+
const target = await this.start();
|
|
411
|
+
const page = await target.newPage(url);
|
|
412
|
+
await this.adopt(page);
|
|
413
|
+
if (url)
|
|
414
|
+
this.currentOrigin = originOf(url);
|
|
415
|
+
return page;
|
|
416
|
+
}
|
|
417
|
+
/** Where a specific tab is, without switching to it. */
|
|
418
|
+
async tabUrl(page) {
|
|
419
|
+
const result = await page.send("Runtime.evaluate", {
|
|
420
|
+
expression: "location.href",
|
|
421
|
+
returnByValue: true,
|
|
422
|
+
});
|
|
423
|
+
const value = (result["result"] ?? {});
|
|
424
|
+
return String(value["value"] ?? "");
|
|
425
|
+
}
|
|
426
|
+
/** A non-launching description of the tabs for the session Browser pane. */
|
|
427
|
+
async paneTabs() {
|
|
428
|
+
if (!this.target)
|
|
429
|
+
return [];
|
|
430
|
+
if (this.target.pageInfos) {
|
|
431
|
+
const infos = await this.target.pageInfos();
|
|
432
|
+
// In the person's own browser the pane is this conversation's tab strip:
|
|
433
|
+
// the tabs it opened or was pointed at, never the rest of their window.
|
|
434
|
+
const mine = this.target.opened ? new Set((await this.target.pages()).map((page) => page.id)) : undefined;
|
|
435
|
+
const opened = this.target.opened?.bind(this.target);
|
|
436
|
+
return infos
|
|
437
|
+
.filter((page) => !mine || mine.has(page.id))
|
|
438
|
+
.map((page) => ({
|
|
439
|
+
...page,
|
|
440
|
+
active: page.id === this.active?.id,
|
|
441
|
+
// One of the person's own tabs this conversation was pointed at:
|
|
442
|
+
// closing it from the strip only lets go of it, so it says so.
|
|
443
|
+
...(opened && !opened(page.id) ? { mine: false } : {}),
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
const pages = await this.target.pages();
|
|
447
|
+
return Promise.all(pages.map(async (page) => {
|
|
448
|
+
const result = await page.send("Runtime.evaluate", {
|
|
449
|
+
expression: "({ url: location.href, title: document.title })",
|
|
450
|
+
returnByValue: true,
|
|
451
|
+
}).catch(() => ({}));
|
|
452
|
+
const remote = (result["result"] ?? {});
|
|
453
|
+
const value = (remote["value"] ?? {});
|
|
454
|
+
const url = typeof value["url"] === "string" ? value["url"] : await this.tabUrl(page).catch(() => "");
|
|
455
|
+
const title = typeof value["title"] === "string" ? value["title"].trim() : "";
|
|
456
|
+
return { id: page.id, url, ...(title ? { title } : {}), active: page === this.active };
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
/** Switch the pane and subsequent browser tools to an already-open tab. */
|
|
460
|
+
async selectPaneTab(id) {
|
|
461
|
+
if (!this.target)
|
|
462
|
+
throw new BrowserError("no browser is running");
|
|
463
|
+
const page = this.target.pageById
|
|
464
|
+
? await this.target.pageById(id)
|
|
465
|
+
: (await this.target.pages()).find((candidate) => candidate.id === id);
|
|
466
|
+
if (!page)
|
|
467
|
+
throw new BrowserError(`no tab ${id}`);
|
|
468
|
+
await this.adopt(page);
|
|
469
|
+
const url = await this.tabUrl(page);
|
|
470
|
+
this.currentOrigin = originOf(url);
|
|
471
|
+
return { id: page.id, url };
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Give this conversation one of the person's own tabs, because they asked —
|
|
475
|
+
* from the extension's side panel, on the tab in front of them. Their asking
|
|
476
|
+
* is what makes it this conversation's tab; it is not a permission to act on
|
|
477
|
+
* the site, which the first action there still asks for as ever.
|
|
478
|
+
*/
|
|
479
|
+
async adoptYourTab(id) {
|
|
480
|
+
if (this.policy === "builtin") {
|
|
481
|
+
throw new BrowserError("settings keep browser use in the built-in browser, so a tab of your own cannot be handed to it");
|
|
482
|
+
}
|
|
483
|
+
this.mode = "extension";
|
|
484
|
+
await this.start();
|
|
485
|
+
return this.selectPaneTab(id);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Close one page without disturbing its siblings; the last one releases the
|
|
489
|
+
* target. A tab the person had open before the conversation was pointed at
|
|
490
|
+
* it is let go of rather than closed — it was theirs, not the agent's.
|
|
491
|
+
*/
|
|
492
|
+
async closePaneTab(id) {
|
|
493
|
+
if (!this.target)
|
|
494
|
+
throw new BrowserError("no browser is running");
|
|
495
|
+
const target = this.target;
|
|
496
|
+
const page = target.pageById
|
|
497
|
+
? await target.pageById(id)
|
|
498
|
+
: (await target.pages()).find((candidate) => candidate.id === id);
|
|
499
|
+
if (!page)
|
|
500
|
+
throw new BrowserError(`no tab ${id}`);
|
|
501
|
+
const wasActive = page === this.active;
|
|
502
|
+
const released = Boolean(target.opened && target.release && !target.opened(id));
|
|
503
|
+
if (released)
|
|
504
|
+
await target.release(page);
|
|
505
|
+
else
|
|
506
|
+
await page.close();
|
|
507
|
+
if (wasActive)
|
|
508
|
+
this.active = undefined;
|
|
509
|
+
// What is left to work in: in the person's own browser only this
|
|
510
|
+
// conversation's tabs count, so closing its last one never lands the agent
|
|
511
|
+
// on one of theirs.
|
|
512
|
+
const infos = target.pageInfos && !target.opened ? await target.pageInfos() : undefined;
|
|
513
|
+
const remaining = infos ? infos.length : (await target.pages()).length;
|
|
514
|
+
if (remaining === 0) {
|
|
515
|
+
await this.closeSide(this.mode);
|
|
516
|
+
return { closed: id, ...(released ? { released } : {}), running: false };
|
|
517
|
+
}
|
|
518
|
+
if (wasActive) {
|
|
519
|
+
const next = infos
|
|
520
|
+
? await target.pageById?.(infos.at(-1).id)
|
|
521
|
+
: (await target.pages()).at(-1);
|
|
522
|
+
if (next) {
|
|
523
|
+
await this.adopt(next);
|
|
524
|
+
this.currentOrigin = originOf(await this.tabUrl(next));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return { closed: id, ...(released ? { released } : {}), ...(this.active ? { active: this.active.id } : {}), running: true };
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* What a pane needs to draw itself without starting anything.
|
|
531
|
+
*
|
|
532
|
+
* Asked on an interval by every open page, so it must never be the call that
|
|
533
|
+
* launches a browser: somebody who opened the pane to look at it has not
|
|
534
|
+
* asked for Chrome, and a pane that started one would make the feature cost
|
|
535
|
+
* memory for people who never use it.
|
|
536
|
+
*/
|
|
537
|
+
state() {
|
|
538
|
+
return {
|
|
539
|
+
running: this.running,
|
|
540
|
+
mode: this.mode,
|
|
541
|
+
policy: this.policy,
|
|
542
|
+
...(this.currentOrigin ? { origin: this.currentOrigin } : {}),
|
|
543
|
+
...(this.target?.presentation ? { presentation: this.target.presentation } : {}),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* The page's own coordinate space.
|
|
548
|
+
*
|
|
549
|
+
* A screencast frame is not the page: Chrome scales it down, so a 1280-wide
|
|
550
|
+
* viewport arrives as a 756-wide picture. A pane that treated the picture's
|
|
551
|
+
* pixels as the page's would put every human click in the wrong place — near
|
|
552
|
+
* enough to look plausible, far enough to miss the button. This is what the
|
|
553
|
+
* pane scales by.
|
|
554
|
+
*/
|
|
555
|
+
async viewport() {
|
|
556
|
+
if (!this.target)
|
|
557
|
+
return undefined;
|
|
558
|
+
try {
|
|
559
|
+
const page = await this.page();
|
|
560
|
+
const metrics = await page.send("Page.getLayoutMetrics");
|
|
561
|
+
const box = (metrics["cssLayoutViewport"] ?? metrics["layoutViewport"] ?? {});
|
|
562
|
+
const width = Number(box["clientWidth"]);
|
|
563
|
+
const height = Number(box["clientHeight"]);
|
|
564
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0)
|
|
565
|
+
return undefined;
|
|
566
|
+
return { width, height };
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/** Frames for a viewer. Starts nothing — an idle session yields nothing. */
|
|
573
|
+
async *frames(opts = {}) {
|
|
574
|
+
if (!this.target)
|
|
575
|
+
return;
|
|
576
|
+
const page = await this.page();
|
|
577
|
+
yield* page.frames(opts);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Input from a person who has taken the pane over.
|
|
581
|
+
*
|
|
582
|
+
* The same channel the tools use, deliberately: a human click and a model
|
|
583
|
+
* click arrive at the page identically, so there is no second code path that
|
|
584
|
+
* can drift, and no state the page can be in that only one of them reaches.
|
|
585
|
+
*/
|
|
586
|
+
async forward(event) {
|
|
587
|
+
if (!this.target)
|
|
588
|
+
return;
|
|
589
|
+
const page = await this.page();
|
|
590
|
+
await page.input(event);
|
|
591
|
+
}
|
|
592
|
+
consoleEntries() {
|
|
593
|
+
return this.consoleLog;
|
|
594
|
+
}
|
|
595
|
+
networkEntries() {
|
|
596
|
+
return this.network;
|
|
597
|
+
}
|
|
598
|
+
/** Let go of both browsers: the built-in one closes, the person's is detached. */
|
|
599
|
+
async close() {
|
|
600
|
+
this.consoleLog.length = 0;
|
|
601
|
+
this.network.length = 0;
|
|
602
|
+
await Promise.all([this.closeSide("builtin"), this.closeSide("extension")]);
|
|
603
|
+
}
|
|
604
|
+
/** Let go of one browser, leaving the other as it is. */
|
|
605
|
+
async closeSide(side) {
|
|
606
|
+
const target = this.sides[side].target;
|
|
607
|
+
this.sides[side] = emptySide();
|
|
608
|
+
await target?.close().catch(() => { });
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Change which browsers this session may use.
|
|
612
|
+
*
|
|
613
|
+
* Takes effect on the next browser call. Pinning it to one browser lets go
|
|
614
|
+
* of the other — the built-in one closes, the person's is only detached,
|
|
615
|
+
* their tabs never closed. Moving to auto keeps whatever is open, since
|
|
616
|
+
* either browser is now fair game.
|
|
617
|
+
*/
|
|
618
|
+
async setMode(mode) {
|
|
619
|
+
this.policy = mode;
|
|
620
|
+
if (mode === "auto")
|
|
621
|
+
return;
|
|
622
|
+
const other = mode === "builtin" ? "extension" : "builtin";
|
|
623
|
+
await this.closeSide(other);
|
|
624
|
+
this.mode = mode;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* The external browser a sign-in can be imported from, and the sites it has
|
|
628
|
+
* open. Lists tabs only — nothing is attached, so the person's browser shows
|
|
629
|
+
* no debugging banner just because the dialog was opened.
|
|
630
|
+
*/
|
|
631
|
+
async loginImportSources() {
|
|
632
|
+
const found = await this.withExternal(async (external) => ({
|
|
633
|
+
browser: await externalBrowserName(external),
|
|
634
|
+
sites: await listExternalSites(external),
|
|
635
|
+
}));
|
|
636
|
+
const current = this.mode === "builtin" && this.currentOrigin ? normalizeSite(this.currentOrigin) : undefined;
|
|
637
|
+
return { ...found, ...(current ? { current } : {}) };
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Copy the chosen sites' sign-in into the built-in browser's profile.
|
|
641
|
+
*
|
|
642
|
+
* Only ever called from the confirmation dialog. In extension mode this is
|
|
643
|
+
* also the migration: the session lets go of the person's browser (detaching,
|
|
644
|
+
* never closing their tabs) and continues in the built-in one, on the site it
|
|
645
|
+
* just imported.
|
|
646
|
+
*/
|
|
647
|
+
async importLogin(sites) {
|
|
648
|
+
const wanted = [...new Set(sites.map((site) => normalizeSite(site)).filter((site) => Boolean(site)))];
|
|
649
|
+
if (wanted.length === 0)
|
|
650
|
+
throw new BrowserError("choose at least one website to import");
|
|
651
|
+
if (this.importing)
|
|
652
|
+
throw new BrowserError("an import is already running");
|
|
653
|
+
this.importing = true;
|
|
654
|
+
try {
|
|
655
|
+
const { browser, states } = await this.withExternal(async (external) => {
|
|
656
|
+
const states = [];
|
|
657
|
+
for (const site of wanted)
|
|
658
|
+
states.push(await readSiteLoginState(external, site));
|
|
659
|
+
return { browser: await externalBrowserName(external), states };
|
|
660
|
+
});
|
|
661
|
+
const migrated = this.mode === "extension";
|
|
662
|
+
if (migrated) {
|
|
663
|
+
// Closing an extension target detaches; it never closes the person's tabs.
|
|
664
|
+
await this.closeSide("extension");
|
|
665
|
+
this.mode = "builtin";
|
|
666
|
+
// Pinned to the person's browser, the session now works on in the
|
|
667
|
+
// built-in one it just moved to, as it asked to.
|
|
668
|
+
if (this.policy === "extension")
|
|
669
|
+
this.policy = "builtin";
|
|
670
|
+
}
|
|
671
|
+
const page = await this.page();
|
|
672
|
+
const imported = [];
|
|
673
|
+
for (const state of states) {
|
|
674
|
+
if (state.cookies.length)
|
|
675
|
+
await page.send("Network.setCookies", { cookies: state.cookies });
|
|
676
|
+
let stored = 0;
|
|
677
|
+
for (const origin of state.origins) {
|
|
678
|
+
if (!origin.localStorage.length)
|
|
679
|
+
continue;
|
|
680
|
+
await this.seedLocalStorage(page, origin);
|
|
681
|
+
stored += origin.localStorage.length;
|
|
682
|
+
}
|
|
683
|
+
imported.push({ site: state.site, cookies: state.cookies.length, localStorage: stored, ...(state.origins[0] ? { url: state.origins[0].url } : {}) });
|
|
684
|
+
await this.loginMemory().add(state.site);
|
|
685
|
+
}
|
|
686
|
+
// Land where the sign-in shows: reload a page that belongs to an imported
|
|
687
|
+
// site, open the first site on a blank browser, and otherwise leave the
|
|
688
|
+
// page the agent is working on alone.
|
|
689
|
+
const here = await this.tabUrl(page).catch(() => "");
|
|
690
|
+
const hereSite = /^https?:/i.test(here) ? normalizeSite(here) : undefined;
|
|
691
|
+
if (hereSite && imported.some((entry) => hostInSite(hereSite, entry.site))) {
|
|
692
|
+
await this.navigate(here);
|
|
693
|
+
}
|
|
694
|
+
else if (!hereSite) {
|
|
695
|
+
const first = imported[0];
|
|
696
|
+
await this.navigate(first.url ?? `https://${first.site}/`);
|
|
697
|
+
}
|
|
698
|
+
return { browser, imported, migrated, url: await this.tabUrl(page).catch(() => here) };
|
|
699
|
+
}
|
|
700
|
+
finally {
|
|
701
|
+
this.importing = false;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Offer an import when the built-in browser reaches a site the person has
|
|
706
|
+
* open in their own browser.
|
|
707
|
+
*
|
|
708
|
+
* Asked by the pane's state poll, so it never waits on the extension: the
|
|
709
|
+
* first call for a site starts one probe and answers nothing, and a later poll
|
|
710
|
+
* picks the result up. The probe lists tabs only and attaches to none.
|
|
711
|
+
*/
|
|
712
|
+
loginImportSuggestion() {
|
|
713
|
+
if (this.mode !== "builtin" || !this.target || !this.currentOrigin)
|
|
714
|
+
return undefined;
|
|
715
|
+
const site = normalizeSite(this.currentOrigin);
|
|
716
|
+
if (!site || this.importing)
|
|
717
|
+
return undefined;
|
|
718
|
+
const known = this.suggestions.get(site);
|
|
719
|
+
if (known && Date.now() - known.at < SUGGESTION_TTL_MS)
|
|
720
|
+
return known.value;
|
|
721
|
+
if (!this.probing) {
|
|
722
|
+
this.probing = (async () => {
|
|
723
|
+
let value;
|
|
724
|
+
try {
|
|
725
|
+
if (!(await this.loginMemory().has(site))) {
|
|
726
|
+
value = await this.withExternal(async (external) => {
|
|
727
|
+
const open = (await listExternalSites(external)).some((entry) => hostInSite(entry.site, site) || hostInSite(site, entry.site));
|
|
728
|
+
return open ? { site, browser: await externalBrowserName(external) } : undefined;
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
// No extension, or it is busy with another session: nothing to offer.
|
|
734
|
+
}
|
|
735
|
+
this.suggestions.set(site, { at: Date.now(), ...(value ? { value } : {}) });
|
|
736
|
+
})().finally(() => {
|
|
737
|
+
this.probing = undefined;
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
return known?.value;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Whether the person has this site open in their own browser, while this
|
|
744
|
+
* session works on it in the built-in one without its sign-in. The model is
|
|
745
|
+
* told, and decides; nothing moves on the strength of it. Lists tabs only,
|
|
746
|
+
* attaches to none, and gives up quickly when the extension is not there.
|
|
747
|
+
*/
|
|
748
|
+
async openInYourBrowser(url, timeoutMs = 1_500) {
|
|
749
|
+
if (this.policy !== "auto" || this.mode !== "builtin")
|
|
750
|
+
return false;
|
|
751
|
+
const site = normalizeSite(url);
|
|
752
|
+
// A local dev server is the built-in browser's own business.
|
|
753
|
+
if (!site || site === "localhost" || /^127\.|^\[?::1\]?$/.test(site))
|
|
754
|
+
return false;
|
|
755
|
+
if (await this.loginMemory().has(site).catch(() => false))
|
|
756
|
+
return false;
|
|
757
|
+
const probe = this.withExternal(async (external) => (await listExternalSites(external)).some((entry) => hostInSite(entry.site, site) || hostInSite(site, entry.site))).catch(() => false);
|
|
758
|
+
let timer;
|
|
759
|
+
const late = new Promise((resolve) => {
|
|
760
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
761
|
+
timer.unref?.();
|
|
762
|
+
});
|
|
763
|
+
try {
|
|
764
|
+
return await Promise.race([probe, late]);
|
|
765
|
+
}
|
|
766
|
+
finally {
|
|
767
|
+
clearTimeout(timer);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/** "Don't ask again" for one site, remembered with the shared profile. */
|
|
771
|
+
async dismissLoginImport(site) {
|
|
772
|
+
const normalized = normalizeSite(site);
|
|
773
|
+
if (!normalized)
|
|
774
|
+
return;
|
|
775
|
+
this.suggestions.set(normalized, { at: Date.now() });
|
|
776
|
+
await this.loginMemory().add(normalized);
|
|
777
|
+
}
|
|
778
|
+
loginMemory() {
|
|
779
|
+
return this.opts.loginMemory ?? (sharedLoginMemory ??= new LoginImportMemory());
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* The external browser, for the length of one read.
|
|
783
|
+
*
|
|
784
|
+
* In extension mode that is the session's own target. Otherwise a connection
|
|
785
|
+
* is opened for the read and closed after it, so the extension is free again
|
|
786
|
+
* and the person's tabs are detached, not closed.
|
|
787
|
+
*/
|
|
788
|
+
async withExternal(fn) {
|
|
789
|
+
const own = this.sides.extension.target;
|
|
790
|
+
if (own)
|
|
791
|
+
return fn(own);
|
|
792
|
+
const provider = browserProviderFor("extension", this.opts.providers);
|
|
793
|
+
const target = await provider.createTarget({ ...(this.sessionId ? { sessionId: this.sessionId } : {}) });
|
|
794
|
+
try {
|
|
795
|
+
return await fn(target);
|
|
796
|
+
}
|
|
797
|
+
finally {
|
|
798
|
+
await target.close().catch(() => { });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Put localStorage in place before the site's own scripts run.
|
|
803
|
+
*
|
|
804
|
+
* Written from a script injected ahead of the document rather than after the
|
|
805
|
+
* load: a signed-out page that loaded first could overwrite the keys it finds
|
|
806
|
+
* missing, and would then need a reload that sees the same state it wrote.
|
|
807
|
+
*/
|
|
808
|
+
async seedLocalStorage(page, origin) {
|
|
809
|
+
const source = `(() => { if (location.origin !== ${JSON.stringify(origin.origin)}) return; try { for (const [key, value] of ${JSON.stringify(origin.localStorage)}) localStorage.setItem(key, value); } catch {} })();`;
|
|
810
|
+
const added = await page.send("Page.addScriptToEvaluateOnNewDocument", { source });
|
|
811
|
+
try {
|
|
812
|
+
await this.navigate(origin.url);
|
|
813
|
+
}
|
|
814
|
+
finally {
|
|
815
|
+
if (typeof added["identifier"] === "string") {
|
|
816
|
+
await page.send("Page.removeScriptToEvaluateOnNewDocument", { identifier: added["identifier"] }).catch(() => { });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
export function originOf(url) {
|
|
822
|
+
try {
|
|
823
|
+
const parsed = new URL(url);
|
|
824
|
+
// A file: or about: URL has no host, and `origin` reads "null" for them —
|
|
825
|
+
// which would collapse every local file into one permission subject.
|
|
826
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:")
|
|
827
|
+
return parsed.origin;
|
|
828
|
+
return parsed.protocol === "about:" ? undefined : parsed.protocol;
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function errorText(err) {
|
|
835
|
+
return err instanceof Error ? err.message : String(err);
|
|
836
|
+
}
|
|
837
|
+
//# sourceMappingURL=session.js.map
|