@melaya/runner 1.1.68 → 1.1.70

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.
@@ -0,0 +1,363 @@
1
+ // packages/runner/src/browserFast.ts
2
+ //
3
+ // browser_fast on the runner's OWN browser (the desktop app's dedicated
4
+ // Playwright browser, browserBridge.ts). Design: Obsidian/37 System One
5
+ // Models/System One Model & Browser Extension.md, section 2.
6
+ //
7
+ // The fast EXECUTOR stays on the server (server/src/services/fast/fastRun.ts):
8
+ // one plan call, deterministic grounding first, Laya as a gated fallback with
9
+ // the per-user and box budgets, verification after every step, STOP, no blind
10
+ // retry. Only the transport differs from the extension path. The server
11
+ // cannot reach this machine's loopback bridge, so the runner opens the
12
+ // channel itself:
13
+ //
14
+ // browser.py browser_fast --POST /browser/fast--> bridge (per-run bearer,
15
+ // grant expiry) --POST /api/v1/browser/runner/fast {op:"start"}--> server
16
+ // (runner token, session ownership, grant not revoked) runs fastToolRun
17
+ // with runnerBrowserFastHost (server/src/services/fast/fastRunnerBrowser.ts)
18
+ //
19
+ // meanwhile the bridge pumps commands: POST /api/v1/browser/runner/fast/pump
20
+ // carries the previous command's outcome and long-polls the next one.
21
+ // Every command runs HERE, under the SAME run registration (the signed
22
+ // grant the bridge verified), through performAct: effect ceiling, origin
23
+ // policy, takeover pause, the publish gate and its approval card.
24
+ //
25
+ // This module holds the parts that need no browser: stable refs, the fast
26
+ // screen rendering, command execution over an injected ops interface, the
27
+ // pump loop and the HTTP client. browserBridge.ts supplies the real ops.
28
+ import { request as httpRequest } from "node:http";
29
+ import { request as httpsRequest } from "node:https";
30
+ // ---------------------------------------------------------------------
31
+ // Stable refs
32
+ // ---------------------------------------------------------------------
33
+ //
34
+ // The bridge renumbers @eN on every snapshot and refuses a ref from an older
35
+ // one. The executor compares refs ACROSS reads (a control that appeared since
36
+ // the last action wins a tie between identical labels; a picker's options are
37
+ // the elements that were not there before), so it needs refs that stay put
38
+ // while the element lives, as the extension's do. A stable ref is keyed on
39
+ // the DOM node (frame + backendNodeId), numbered per lease and never reused:
40
+ // a ref from an earlier document can never land on a node of a later one.
41
+ // They carry their own prefix ("r@e12") so the per-step tools, whose @eN are
42
+ // per snapshot, refuse them instead of clicking whatever @e12 is now.
43
+ export const STABLE_REF_PREFIX = "r";
44
+ const STABLE_REF_RE = /^r@e(\d+)$/;
45
+ const SNAPSHOT_REF_RE = /^@e\d+$/;
46
+ export class StableRefs {
47
+ documentGeneration = -1;
48
+ next = 0;
49
+ byKey = new Map();
50
+ byNum = new Map();
51
+ static keyOf(b) {
52
+ return `${b.frameKey}:${b.backendNodeId}`;
53
+ }
54
+ sync(documentGeneration) {
55
+ if (documentGeneration === this.documentGeneration)
56
+ return;
57
+ // A new document: forget every node, keep counting (never reuse a number).
58
+ this.documentGeneration = documentGeneration;
59
+ this.byKey.clear();
60
+ this.byNum.clear();
61
+ }
62
+ /** Stable ref for every binding of the current snapshot (snapshot ref ->
63
+ * stable ref). Records each node's latest role and label. */
64
+ assign(documentGeneration, bindings) {
65
+ this.sync(documentGeneration);
66
+ const out = new Map();
67
+ for (const b of bindings) {
68
+ const key = StableRefs.keyOf(b);
69
+ let num = this.byKey.get(key);
70
+ if (num === undefined) {
71
+ num = ++this.next;
72
+ this.byKey.set(key, num);
73
+ }
74
+ this.byNum.set(num, { key, frameKey: b.frameKey, backendNodeId: b.backendNodeId, role: b.role, name: b.name });
75
+ out.set(b.ref, `${STABLE_REF_PREFIX}@e${num}`);
76
+ }
77
+ return out;
78
+ }
79
+ /** Stable ref of a binding already numbered (no new number). */
80
+ stableOf(documentGeneration, b) {
81
+ if (documentGeneration !== this.documentGeneration)
82
+ return null;
83
+ const num = this.byKey.get(StableRefs.keyOf(b));
84
+ return num === undefined ? null : `${STABLE_REF_PREFIX}@e${num}`;
85
+ }
86
+ /** The current snapshot's ref for a stable ref, or null when the element is
87
+ * gone, the document changed, or the node no longer carries the role and
88
+ * label it was read with. Never a guess. */
89
+ resolve(stableRef, documentGeneration, current) {
90
+ const m = STABLE_REF_RE.exec(String(stableRef || "").trim());
91
+ if (!m || documentGeneration !== this.documentGeneration)
92
+ return null;
93
+ const e = this.byNum.get(Number(m[1]));
94
+ if (!e)
95
+ return null;
96
+ for (const b of current) {
97
+ if (b.frameKey !== e.frameKey || b.backendNodeId !== e.backendNodeId)
98
+ continue;
99
+ if (b.role !== e.role || b.name !== e.name)
100
+ return null;
101
+ return b.ref;
102
+ }
103
+ return null;
104
+ }
105
+ }
106
+ export function isStableRef(ref) {
107
+ return typeof ref === "string" && STABLE_REF_RE.test(ref.trim());
108
+ }
109
+ const clean = (s, max) => String(s || "").replace(/\s+/g, " ").replace(/"/g, "'").trim().slice(0, max);
110
+ /** The screen in the line format the server's parseBrowserTree reads:
111
+ * `r@e12 <role> [click] [edit] [COVERED] "name" ="value" ~aria #id @href <l,t,r,b>`.
112
+ * A COVERED element (something else is painted over its centre) is not
113
+ * offered as clickable: a click there lands on whatever covers it. */
114
+ export function renderFastLines(items, toStable) {
115
+ const lines = [];
116
+ for (const n of items) {
117
+ const ref = toStable.get(n.ref);
118
+ if (!ref)
119
+ continue;
120
+ const flags = `${n.click && !n.covered ? " click" : ""}${n.edit ? " edit" : ""}${n.covered ? " COVERED" : ""}`;
121
+ const name = ` "${clean(n.name, 240)}"`;
122
+ const value = n.edit && n.value ? ` ="${clean(n.value, 200)}"` : "";
123
+ const aria = n.aria ? ` ~${clean(n.aria, 40).replace(/\s@/g, " ")}` : "";
124
+ const domId = n.domId ? ` #${clean(n.domId, 40).replace(/\s/g, "")}` : "";
125
+ const href = n.href ? ` @${clean(n.href, 200).replace(/\s/g, "")}` : "";
126
+ lines.push(`${ref} <${n.role || "node"}>${flags}${name}${value}${aria}${domId}${href} <${Math.round(n.l)},${Math.round(n.t)},${Math.round(n.r)},${Math.round(n.b)}>`);
127
+ }
128
+ return lines;
129
+ }
130
+ // ---------------------------------------------------------------------
131
+ // Steps that quote the per-step tree's refs
132
+ // ---------------------------------------------------------------------
133
+ /** The LLM usually read the page with browser_get_screen_tree before planning,
134
+ * so its steps may quote that snapshot's @eN (a target's `ref`, a list's
135
+ * `in`). The run's first read takes a new snapshot, which would orphan them:
136
+ * translate them to stable refs now, while that snapshot is still current.
137
+ * A ref that no longer resolves is dropped; the step falls back to its label. */
138
+ export function translateStepRefs(steps, toStable) {
139
+ if (!Array.isArray(steps))
140
+ return steps;
141
+ return steps.map((raw) => {
142
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
143
+ return raw;
144
+ const s = { ...raw };
145
+ for (const k of ["ref", "in"]) {
146
+ const v = s[k];
147
+ if (typeof v === "string" && SNAPSHOT_REF_RE.test(v.trim())) {
148
+ const stable = toStable(v.trim());
149
+ if (stable)
150
+ s[k] = stable;
151
+ else
152
+ delete s[k];
153
+ }
154
+ }
155
+ if (Array.isArray(s.steps))
156
+ s.steps = translateStepRefs(s.steps, toStable);
157
+ return s;
158
+ });
159
+ }
160
+ // ---------------------------------------------------------------------
161
+ // Autonomy
162
+ // ---------------------------------------------------------------------
163
+ const MODE_RANK = { safe: 0, payments_only: 1, autonomous: 2 };
164
+ const asMode = (m) => m === "autonomous" || m === "payments_only" ? m : "safe";
165
+ /** The run's autonomy: the registered (server-clamped) mode, which a caller
166
+ * holding the bridge token may TIGHTEN but never loosen (same rule as the
167
+ * bridge's publish gate). No registered mode: the caller's, default safe. */
168
+ export function clampHitlMode(specMode, bodyMode) {
169
+ if (specMode == null || specMode === "")
170
+ return asMode(bodyMode);
171
+ const spec = asMode(specMode);
172
+ if (bodyMode == null || bodyMode === "")
173
+ return spec;
174
+ const body = asMode(bodyMode);
175
+ return MODE_RANK[body] < MODE_RANK[spec] ? body : spec;
176
+ }
177
+ const ACT_KINDS = new Set(["click", "input_text", "press_key", "scroll", "select_option", "back", "navigate"]);
178
+ function errOf(e) {
179
+ const x = e;
180
+ return {
181
+ code: typeof x?.code === "string" && x.code ? x.code : "bridge_error",
182
+ message: String(x?.message ?? e ?? "failed").slice(0, 600),
183
+ };
184
+ }
185
+ export class FastCommandError extends Error {
186
+ code;
187
+ constructor(code, message) {
188
+ super(message);
189
+ this.code = code;
190
+ }
191
+ }
192
+ async function screenOf(ops) {
193
+ const obs = await ops.observe();
194
+ const refs = await ops.stableRefs();
195
+ const toStable = refs.assign(obs.documentGeneration, obs.items);
196
+ const lines = renderFastLines(obs.items, toStable);
197
+ return { url: obs.url, title: obs.title, generation: obs.generation, tree: lines.join("\n"), nodes: lines.length };
198
+ }
199
+ async function snapshotRefOf(ops, ref) {
200
+ const r = typeof ref === "string" ? ref.trim() : "";
201
+ if (!isStableRef(r))
202
+ throw new FastCommandError("stale_ref", `ref '${r.slice(0, 40)}' is not a fast-mode ref of this page`);
203
+ const [b, refs] = await Promise.all([ops.bindings(), ops.stableRefs()]);
204
+ const snap = refs.resolve(r, b.documentGeneration, b.refs);
205
+ if (!snap)
206
+ throw new FastCommandError("stale_ref", `the element ${r} is no longer on the page as it was read`);
207
+ return snap;
208
+ }
209
+ /** Run one command the server's executor sent, under this run's grant and
210
+ * autonomy (`mode`, clamped on this side: the server cannot loosen it). */
211
+ export async function execFastCommand(ops, cmd, mode) {
212
+ try {
213
+ const st = ops.state();
214
+ if (st.cancelled)
215
+ return { ok: false, code: "canceled", message: "the browser run was stopped or torn down" };
216
+ if (st.expired)
217
+ return { ok: false, code: "grant_expired", message: "the browser grant for this run has expired" };
218
+ const body = cmd.body ?? {};
219
+ switch (cmd.kind) {
220
+ case "observe":
221
+ return { ok: true, data: await screenOf(ops) };
222
+ case "location":
223
+ return { ok: true, data: await ops.location() };
224
+ case "text": {
225
+ const ref = body.ref ? await snapshotRefOf(ops, body.ref) : undefined;
226
+ const out = (await ops.act({ kind: "get_text", ...(ref ? { ref } : {}), hitl_mode: mode }));
227
+ return { ok: true, data: { text: String(out?.text ?? "") } };
228
+ }
229
+ case "act": {
230
+ const action = String(body.action ?? "");
231
+ if (!ACT_KINDS.has(action))
232
+ return { ok: false, code: "act_kind_unknown", message: `fast mode does not send '${action.slice(0, 40)}'` };
233
+ // The user took over: stop here, before anything is acted on.
234
+ if (st.paused)
235
+ return { ok: false, code: "paused_by_user", message: "the user has taken manual control of the browser" };
236
+ const args = { kind: action, hitl_mode: mode };
237
+ if (body.ref !== undefined)
238
+ args.ref = await snapshotRefOf(ops, body.ref);
239
+ let result;
240
+ switch (action) {
241
+ case "input_text": {
242
+ const text = String(body.text ?? "");
243
+ // A submitting type is a commit (Enter): its approval is settled
244
+ // before a single character is typed, so a pending or declined
245
+ // card leaves the field untouched.
246
+ if (body.submit === true)
247
+ await ops.gateSubmit(mode);
248
+ result = await ops.act({ ...args, text, ...(body.mode === "human" ? { mode: "human" } : {}) });
249
+ if (body.submit === true)
250
+ await ops.act({ kind: "press_key", key: "Enter", hitl_mode: mode });
251
+ break;
252
+ }
253
+ case "press_key":
254
+ result = await ops.act({ ...args, key: String(body.key ?? "") });
255
+ break;
256
+ case "scroll":
257
+ result = await ops.act({ ...args, text: body.direction === "up" ? "up" : "down", value: "medium" });
258
+ // Let an infinite feed load its next batch before the page is read.
259
+ if (body.direction !== "up" && body.feed !== false)
260
+ await ops.wait(700);
261
+ break;
262
+ case "select_option":
263
+ result = await ops.act({ ...args, value: String(body.option ?? ""), match_label: true });
264
+ break;
265
+ case "navigate":
266
+ result = await ops.act({ ...args, url: String(body.url ?? "") });
267
+ break;
268
+ default:
269
+ result = await ops.act(args);
270
+ }
271
+ return { ok: true, data: { result: result ?? null, ...(await screenOf(ops)) } };
272
+ }
273
+ default:
274
+ return { ok: false, code: "unknown_command", message: `unknown fast command '${String(cmd.kind).slice(0, 40)}'` };
275
+ }
276
+ }
277
+ catch (e) {
278
+ return { ok: false, ...errOf(e) };
279
+ }
280
+ }
281
+ /** Pull the server executor's commands and run them, one at a time, until the
282
+ * server ends the run or this run is torn down. A command is executed once:
283
+ * its outcome is carried by the next poll, resent unchanged if that poll
284
+ * fails, and the server ignores an outcome it already has. Nothing is ever
285
+ * re-run here. */
286
+ export async function runFastPump(o) {
287
+ const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
288
+ const maxFailures = o.maxFailures ?? 5;
289
+ let outcome = null;
290
+ let failures = 0;
291
+ const executed = new Set();
292
+ for (;;) {
293
+ const cancelled = o.isCancelled();
294
+ let reply;
295
+ try {
296
+ reply = await o.post({ fast_id: o.fastId, ...(outcome ? { result: outcome } : {}), ...(cancelled ? { abort: true } : {}) });
297
+ }
298
+ catch (e) {
299
+ if (cancelled || ++failures >= maxFailures) {
300
+ o.log?.(`[fast] pump ${o.fastId.slice(0, 8)} gave up: ${e?.message || e}`);
301
+ return;
302
+ }
303
+ await sleep(Math.min(5_000, 500 * failures));
304
+ continue;
305
+ }
306
+ failures = 0;
307
+ outcome = null;
308
+ if (cancelled || reply.end)
309
+ return;
310
+ const cmd = reply.command;
311
+ if (!cmd || typeof cmd.seq !== "number")
312
+ continue;
313
+ if (executed.has(cmd.seq)) {
314
+ // Never run a command twice, whatever the channel did.
315
+ outcome = { seq: cmd.seq, outcome: { ok: false, code: "unknown_outcome", message: "this command was already delivered once; it is not run again" } };
316
+ continue;
317
+ }
318
+ executed.add(cmd.seq);
319
+ outcome = { seq: cmd.seq, outcome: await o.exec(cmd) };
320
+ }
321
+ }
322
+ // ---------------------------------------------------------------------
323
+ // HTTP to the server (same base URL + runner token the publish gate uses)
324
+ // ---------------------------------------------------------------------
325
+ export function postServerJson(serverBase, path, authHeader, body, timeoutMs) {
326
+ return new Promise((resolve, reject) => {
327
+ let u;
328
+ try {
329
+ u = new URL(serverBase.replace(/\/$/, "") + path);
330
+ }
331
+ catch (e) {
332
+ reject(e);
333
+ return;
334
+ }
335
+ const isHttps = u.protocol === "https:";
336
+ const data = JSON.stringify(body);
337
+ const req = (isHttps ? httpsRequest : httpRequest)({
338
+ hostname: u.hostname,
339
+ port: u.port || (isHttps ? 443 : 80),
340
+ path: u.pathname + u.search,
341
+ method: "POST",
342
+ headers: {
343
+ "Content-Type": "application/json",
344
+ "Content-Length": Buffer.byteLength(data),
345
+ "Authorization": authHeader,
346
+ },
347
+ }, (res) => {
348
+ const chunks = [];
349
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
350
+ res.on("end", () => {
351
+ let json = {};
352
+ try {
353
+ json = JSON.parse(Buffer.concat(chunks).toString("utf8"));
354
+ }
355
+ catch { /* non-JSON body */ }
356
+ resolve({ status: res.statusCode || 0, json });
357
+ });
358
+ });
359
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`no answer from the server in ${Math.round(timeoutMs / 1000)}s`)));
360
+ req.on("error", reject);
361
+ req.end(data);
362
+ });
363
+ }
@@ -31,3 +31,42 @@ export declare function ensureEngine(engine: BrowserEngineId, opts?: {
31
31
  allowDownload?: boolean;
32
32
  log?: (m: string) => void;
33
33
  }): Promise<InstalledEngine>;
34
+ /** Preference order for headed flows. */
35
+ export declare const INSTALLED_BROWSER_PREFERENCE: readonly BrowserEngineId[];
36
+ /** Actionable error when no supported browser is installed. */
37
+ export declare function noInstalledBrowserError(): ProvisionError;
38
+ /** `npx` on PATH (Windows: npx.cmd). File checks only, no subprocess. */
39
+ export declare function npxOnPath(env?: NodeJS.ProcessEnv): boolean;
40
+ /** May this process download Playwright's Chromium? Only the CLI, with npx present. */
41
+ export declare function canDownloadBundledChromium(opts?: {
42
+ hosted?: boolean;
43
+ npx?: boolean;
44
+ }): boolean;
45
+ /** Installed engines in preference order (Chrome, Edge, Brave, on-disk Chromium). */
46
+ export declare function installedBrowsersByPreference(engines?: InstalledEngine[]): Promise<InstalledEngine[]>;
47
+ type LaunchFn<B> = (o: {
48
+ headless: boolean;
49
+ channel?: string;
50
+ executablePath?: string;
51
+ args?: string[];
52
+ }) => Promise<B>;
53
+ /**
54
+ * Launch a headed browser for a sign-in flow from the installed set. Tries each
55
+ * installed engine in preference order, then Playwright's own Chrome / Edge
56
+ * channel lookup. When nothing launches: in the CLI with npx, `downloadChromium`
57
+ * (if given) installs Playwright's Chromium and it is launched; otherwise a
58
+ * ProvisionError with install instructions is thrown. Never spawns npx in the
59
+ * desktop app.
60
+ */
61
+ export declare function launchInstalledBrowser<B>(launch: LaunchFn<B>, opts?: {
62
+ args?: string[];
63
+ progress?: (m: string) => void;
64
+ downloadChromium?: () => Promise<void>;
65
+ engines?: InstalledEngine[];
66
+ hosted?: boolean;
67
+ npx?: boolean;
68
+ }): Promise<{
69
+ browser: B;
70
+ engine: BrowserEngineId | "channel";
71
+ }>;
72
+ export {};
@@ -239,6 +239,12 @@ export async function ensureEngine(engine, opts = {}) {
239
239
  };
240
240
  throw new ProvisionError("engine_not_installed", `${engine} is not installed on this machine`, `Install ${engine} from ${urls[engine]} and reconnect the runner.`);
241
241
  }
242
+ // Desktop app: process.execPath is the Electron binary, which cannot run
243
+ // Playwright's CLI (Electron-as-Node is fused off), and there is no npx.
244
+ // Never shell out; ask for an installed browser instead (audit P1.4).
245
+ if (hostedByDesktopApp()) {
246
+ throw noInstalledBrowserError();
247
+ }
242
248
  if (!opts.allowDownload) {
243
249
  throw new ProvisionError("download_not_authorized", "bundled Chromium is not on disk and download was not authorized", "Re-run with the download flag enabled, or run: npx playwright install chromium");
244
250
  }
@@ -258,6 +264,96 @@ export async function ensureEngine(engine, opts = {}) {
258
264
  throw new ProvisionError("download_failed", "Chromium installed but executable not found");
259
265
  return post;
260
266
  }
267
+ // ---------------------------------------------------------------------
268
+ // Installed-browser selection (one provisioner for every headed flow)
269
+ // ---------------------------------------------------------------------
270
+ //
271
+ // LinkedIn / Luma sign-in and any other headed flow pick a browser here:
272
+ // installed Chrome, then Edge, then Brave, then a Playwright Chromium that is
273
+ // already on disk. Downloading Chromium is a CLI-only last resort (needs npx
274
+ // or a real Node), never in the desktop app.
275
+ /** Preference order for headed flows. */
276
+ export const INSTALLED_BROWSER_PREFERENCE = ["chrome", "edge", "brave", "chromium"];
277
+ /** Actionable error when no supported browser is installed. */
278
+ export function noInstalledBrowserError() {
279
+ return new ProvisionError("engine_not_installed", "No supported browser found on this computer (Chrome, Edge or Brave).", "Install Google Chrome (https://www.google.com/chrome/) or Microsoft Edge (https://www.microsoft.com/edge), then try again.");
280
+ }
281
+ function hostedByDesktopApp() {
282
+ const p = process.parentPort;
283
+ return !!p && typeof p.postMessage === "function";
284
+ }
285
+ /** `npx` on PATH (Windows: npx.cmd). File checks only, no subprocess. */
286
+ export function npxOnPath(env = process.env) {
287
+ const sep = process.platform === "win32" ? ";" : ":";
288
+ const names = process.platform === "win32" ? ["npx.cmd", "npx.exe", "npx"] : ["npx"];
289
+ for (const dir of String(env.PATH ?? env.Path ?? "").split(sep)) {
290
+ if (!dir)
291
+ continue;
292
+ for (const n of names) {
293
+ try {
294
+ if (existsSync(join(dir, n)))
295
+ return true;
296
+ }
297
+ catch { /* unreadable */ }
298
+ }
299
+ }
300
+ return false;
301
+ }
302
+ /** May this process download Playwright's Chromium? Only the CLI, with npx present. */
303
+ export function canDownloadBundledChromium(opts = {}) {
304
+ const hosted = opts.hosted ?? hostedByDesktopApp();
305
+ if (hosted)
306
+ return false;
307
+ return opts.npx ?? npxOnPath();
308
+ }
309
+ /** Installed engines in preference order (Chrome, Edge, Brave, on-disk Chromium). */
310
+ export async function installedBrowsersByPreference(engines) {
311
+ const list = engines ?? await discoverEngines();
312
+ return INSTALLED_BROWSER_PREFERENCE
313
+ .map((id) => list.find((e) => e.engine === id))
314
+ .filter((e) => !!e);
315
+ }
316
+ /**
317
+ * Launch a headed browser for a sign-in flow from the installed set. Tries each
318
+ * installed engine in preference order, then Playwright's own Chrome / Edge
319
+ * channel lookup. When nothing launches: in the CLI with npx, `downloadChromium`
320
+ * (if given) installs Playwright's Chromium and it is launched; otherwise a
321
+ * ProvisionError with install instructions is thrown. Never spawns npx in the
322
+ * desktop app.
323
+ */
324
+ export async function launchInstalledBrowser(launch, opts = {}) {
325
+ const args = opts.args ?? [];
326
+ const tried = new Set();
327
+ for (const e of await installedBrowsersByPreference(opts.engines)) {
328
+ const key = e.channel ? `channel:${e.channel}` : `path:${e.executablePath}`;
329
+ if (tried.has(key))
330
+ continue;
331
+ tried.add(key);
332
+ try {
333
+ const browser = e.channel
334
+ ? await launch({ headless: false, channel: e.channel, args })
335
+ : await launch({ headless: false, executablePath: e.executablePath, args });
336
+ return { browser, engine: e.engine };
337
+ }
338
+ catch (err) {
339
+ opts.progress?.(`${e.engine} did not start (${String(err?.message || err).split("\n")[0]}); trying the next browser.`);
340
+ }
341
+ }
342
+ // Installs outside the well-known paths: Playwright's own channel lookup.
343
+ for (const channel of ["chrome", "msedge"]) {
344
+ if (tried.has(`channel:${channel}`))
345
+ continue;
346
+ try {
347
+ return { browser: await launch({ headless: false, channel, args }), engine: "channel" };
348
+ }
349
+ catch { /* not installed */ }
350
+ }
351
+ if (opts.downloadChromium && canDownloadBundledChromium({ hosted: opts.hosted, npx: opts.npx })) {
352
+ await opts.downloadChromium();
353
+ return { browser: await launch({ headless: false, args }), engine: "chromium" };
354
+ }
355
+ throw noInstalledBrowserError();
356
+ }
261
357
  function packageRootGuess() {
262
358
  // dist/ layout: this file compiles to dist/browserProvisioner.js; the
263
359
  // package root (with node_modules/playwright) is one level up. In the