|
| 1 | +/** |
| 2 | + * Chromium pinned-build fallback (T-0109). |
| 3 | + * |
| 4 | + * Playwright pins one exact chromium / chromium_headless_shell revision (see |
| 5 | + * playwright-core/browsers.json). When that pinned build is absent from the |
| 6 | + * ms-playwright cache, chromium.launch() throws: |
| 7 | + * |
| 8 | + * browserType.launch: Executable doesn't exist at |
| 9 | + * <cache>/chromium_headless_shell-<rev>/.../chrome-headless-shell |
| 10 | + * |
| 11 | + * …even when several OTHER chromium builds sit cached right beside it. That was |
| 12 | + * the T-0083 incident: pinned -1217 missing while -1140/-1187/-1200/-1208/ |
| 13 | + * -1223/-1228/-1234 were all present, yet the run died fatal with no fallback. |
| 14 | + * |
| 15 | + * This module enumerates viable alternatives so the resolver can retry: |
| 16 | + * 1. already-cached ms-playwright chromium builds (newest revision first) |
| 17 | + * 2. system chromium-family channels (chrome, msedge — Playwright-native) |
| 18 | + * |
| 19 | + * If nothing launches, chromiumInstallHint() produces an actionable error |
| 20 | + * naming the exact install command and what was searched. |
| 21 | + * |
| 22 | + * Explicit user choices (BROWSER_EXECUTABLE_PATH / BROWSER_CHANNEL) are NOT |
| 23 | + * handled here — the resolver only invokes this for the default / implicit |
| 24 | + * chromium path, so an explicit choice's failure always surfaces verbatim. |
| 25 | + * |
| 26 | + * Track: adopt-lightpanda |
| 27 | + */ |
| 28 | + |
| 29 | +import fs from 'fs'; |
| 30 | +import os from 'os'; |
| 31 | +import path from 'path'; |
| 32 | + |
| 33 | +// Per-platform relative executable path inside a cached |
| 34 | +// `chromium_headless_shell-<rev>` directory. Mirrors Playwright's |
| 35 | +// EXECUTABLE_PATHS for "chromium-headless-shell" (registry/index.js). |
| 36 | +const HEADLESS_SHELL_REL = { |
| 37 | + 'darwin-arm64': ['chrome-headless-shell-mac-arm64', 'chrome-headless-shell'], |
| 38 | + 'darwin-x64': ['chrome-headless-shell-mac-x64', 'chrome-headless-shell'], |
| 39 | + 'linux-x64': ['chrome-headless-shell-linux64', 'chrome-headless-shell'], |
| 40 | + 'linux-arm64': ['chrome-linux', 'headless_shell'], |
| 41 | + 'win32-x64': ['chrome-headless-shell-win64', 'chrome-headless-shell.exe'], |
| 42 | +}; |
| 43 | + |
| 44 | +/** |
| 45 | + * Root of the Playwright browser cache. Honors PLAYWRIGHT_BROWSERS_PATH, |
| 46 | + * otherwise the OS default cache dir + "ms-playwright" (matching Playwright's |
| 47 | + * defaultRegistryDirectory). |
| 48 | + * |
| 49 | + * @param {object} [env] |
| 50 | + * @param {NodeJS.Platform} [platform] |
| 51 | + * @returns {string} |
| 52 | + */ |
| 53 | +export function msPlaywrightRoot(env = process.env, platform = os.platform()) { |
| 54 | + const override = env.PLAYWRIGHT_BROWSERS_PATH; |
| 55 | + if (override && override !== '0') return override; |
| 56 | + |
| 57 | + if (platform === 'darwin') { |
| 58 | + return path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'); |
| 59 | + } |
| 60 | + if (platform === 'win32') { |
| 61 | + const localAppData = env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); |
| 62 | + return path.join(localAppData, 'ms-playwright'); |
| 63 | + } |
| 64 | + // linux + others: XDG_CACHE_HOME or ~/.cache |
| 65 | + const xdg = env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); |
| 66 | + return path.join(xdg, 'ms-playwright'); |
| 67 | +} |
| 68 | + |
| 69 | +/** |
| 70 | + * Is this the Playwright pinned-build "Executable doesn't exist" launch error |
| 71 | + * (the exact failure this module rescues)? Kept narrow so unrelated launch |
| 72 | + * failures (crashes, connection refusals) are NOT swallowed by the fallback. |
| 73 | + * |
| 74 | + * @param {unknown} err |
| 75 | + * @returns {boolean} |
| 76 | + */ |
| 77 | +export function isMissingBrowserError(err) { |
| 78 | + const msg = err && err.message ? String(err.message) : ''; |
| 79 | + return msg.includes("Executable doesn't exist"); |
| 80 | +} |
| 81 | + |
| 82 | +/** |
| 83 | + * Enumerate cached chromium_headless_shell builds present on disk, newest |
| 84 | + * revision first. Each entry: { revision, executablePath }. |
| 85 | + * |
| 86 | + * @param {string} root ms-playwright cache root |
| 87 | + * @param {object} [opts] |
| 88 | + * @param {NodeJS.Platform} [opts.platform] |
| 89 | + * @param {string} [opts.arch] |
| 90 | + * @param {(dir: string) => string[]} [opts.readdir] |
| 91 | + * @param {(p: string) => boolean} [opts.exists] |
| 92 | + * @returns {{ revision: number, executablePath: string }[]} |
| 93 | + */ |
| 94 | +export function listCachedChromiumBuilds( |
| 95 | + root, |
| 96 | + { platform = os.platform(), arch = os.arch(), readdir = defaultReaddir, exists = fs.existsSync } = {}, |
| 97 | +) { |
| 98 | + const rel = HEADLESS_SHELL_REL[`${platform}-${arch}`]; |
| 99 | + if (!rel) return []; |
| 100 | + |
| 101 | + let entries; |
| 102 | + try { |
| 103 | + entries = readdir(root); |
| 104 | + } catch { |
| 105 | + return []; |
| 106 | + } |
| 107 | + |
| 108 | + const builds = []; |
| 109 | + for (const name of entries) { |
| 110 | + const m = /^chromium_headless_shell-(\d+)$/.exec(name); |
| 111 | + if (!m) continue; |
| 112 | + const revision = Number(m[1]); |
| 113 | + const executablePath = path.join(root, name, ...rel); |
| 114 | + let present = false; |
| 115 | + try { |
| 116 | + present = exists(executablePath); |
| 117 | + } catch { |
| 118 | + present = false; |
| 119 | + } |
| 120 | + if (present) builds.push({ revision, executablePath }); |
| 121 | + } |
| 122 | + |
| 123 | + builds.sort((a, b) => b.revision - a.revision); |
| 124 | + return builds; |
| 125 | +} |
| 126 | + |
| 127 | +function defaultReaddir(dir) { |
| 128 | + return fs.readdirSync(dir); |
| 129 | +} |
| 130 | + |
| 131 | +/** |
| 132 | + * Build the ordered list of fallback launch candidates for the implicit |
| 133 | + * chromium path. Cached ms-playwright builds come first (exact same Chromium |
| 134 | + * family, no external dependency), then system channels Playwright resolves |
| 135 | + * natively. |
| 136 | + * |
| 137 | + * Each candidate: { source, executablePath?, channel?, revision?, label } |
| 138 | + * |
| 139 | + * @param {object} [opts] |
| 140 | + * @param {object} [opts.env] |
| 141 | + * @param {NodeJS.Platform} [opts.platform] |
| 142 | + * @param {string} [opts.arch] |
| 143 | + * @param {string} [opts.cacheRoot] |
| 144 | + * @param {(dir: string) => string[]} [opts.readdir] |
| 145 | + * @param {(p: string) => boolean} [opts.exists] |
| 146 | + * @returns {Array<object>} |
| 147 | + */ |
| 148 | +export function buildFallbackCandidates({ |
| 149 | + env = process.env, |
| 150 | + platform = os.platform(), |
| 151 | + arch = os.arch(), |
| 152 | + cacheRoot, |
| 153 | + readdir, |
| 154 | + exists, |
| 155 | +} = {}) { |
| 156 | + const root = cacheRoot ?? msPlaywrightRoot(env, platform); |
| 157 | + const candidates = []; |
| 158 | + |
| 159 | + for (const build of listCachedChromiumBuilds(root, { platform, arch, readdir, exists })) { |
| 160 | + candidates.push({ |
| 161 | + source: 'ms-playwright-cache', |
| 162 | + executablePath: build.executablePath, |
| 163 | + revision: build.revision, |
| 164 | + label: `ms-playwright chromium build ${build.revision}`, |
| 165 | + }); |
| 166 | + } |
| 167 | + |
| 168 | + // Playwright-native system channels — resolved by `channel:` at launch time, |
| 169 | + // so we don't probe paths here; a launch attempt is the probe. |
| 170 | + for (const channel of ['chrome', 'msedge']) { |
| 171 | + candidates.push({ |
| 172 | + source: 'system-channel', |
| 173 | + channel, |
| 174 | + label: `system ${channel}`, |
| 175 | + }); |
| 176 | + } |
| 177 | + |
| 178 | + return candidates; |
| 179 | +} |
| 180 | + |
| 181 | +/** |
| 182 | + * Build an actionable error message for the "nothing available" terminal case. |
| 183 | + * |
| 184 | + * @param {object} args |
| 185 | + * @param {string[]} args.searched human-readable list of what was tried |
| 186 | + * @param {string} [args.pinned] the missing pinned build path/rev, if known |
| 187 | + * @returns {string} |
| 188 | + */ |
| 189 | +export function chromiumInstallHint({ searched = [], pinned } = {}) { |
| 190 | + const lines = [ |
| 191 | + 'No usable Chromium browser found.', |
| 192 | + pinned |
| 193 | + ? `Playwright's pinned build is missing (${pinned}) and no cached or system fallback launched.` |
| 194 | + : `Playwright's pinned Chromium build is missing and no cached or system fallback launched.`, |
| 195 | + '', |
| 196 | + 'Install the pinned build with:', |
| 197 | + ' npx playwright install chromium', |
| 198 | + '', |
| 199 | + 'Or set BROWSER_CHANNEL=chrome (or BROWSER_EXECUTABLE_PATH=<path>) to use a system browser.', |
| 200 | + ]; |
| 201 | + if (searched.length > 0) { |
| 202 | + lines.push('', 'Searched:'); |
| 203 | + for (const s of searched) lines.push(` - ${s}`); |
| 204 | + } |
| 205 | + return lines.join('\n'); |
| 206 | +} |
0 commit comments