diff --git a/scripts/setup/README-cloud.md b/scripts/setup/README-cloud.md index 4fe35b2..0607294 100644 --- a/scripts/setup/README-cloud.md +++ b/scripts/setup/README-cloud.md @@ -69,3 +69,42 @@ The cloud-only .claude/hooks/cloud-session-path.sh SessionStart hook persists th PATH through CLAUDE_ENV_FILE for subsequent session commands. It identifies its own repository, shell-quotes the PATH assignment, and does nothing on the Mac. It changes no permissions and writes no credentials. A missing cloud env-file reports unavailable. + +## Browser capability in a cloud session + +Check the actual session before claiming that a browser is unavailable: + +```sh +node scripts/setup/cloud-browser.mjs doctor +node scripts/setup/cloud-browser.mjs render http://localhost:3000/ /tmp/app-page.png +``` + +Use the running app's actual port in that command. + +The helper discovers Playwright in the declared package roots or pnpm store, then +uses its installed browser or a preinstalled browser cache, including the cloud +image's `/opt/pw-browsers`. No package or browser download runs. `doctor` reports +paths only: launching remains untested. `render` launches, checks the HTTP response +and saves a screenshot. Open that screenshot to inspect the visible page. A rendered +page does not prove a control works; follow the target repository's verification +workflow and drive the actual control with an assertion on the resulting behaviour. +Use hermetic test data for E2E. A tiny localhost HTML fixture proves browser capability, +not TAP behaviour. This helper does not start an app or provision its data. + +`CLOUD_BROWSER=firefox` or `CLOUD_BROWSER=webkit` selects another installed engine; +`CLOUD_BROWSER_EXECUTABLE` supplies a known executable when discovery needs help. +An alternate cached executable can differ from the Playwright package's expected +version, so a launch error remains `unavailable` (exit 2), never a successful check. +The helper accepts a localhost starting URL only. The page may still load remote +assets or redirect; review the final URL and screenshot. Use the available browser +connector or an explicit Playwright script for public-site checks. + +A verified cloud session on 08 September 2026 successfully +clicked a localhost HTML button, observed its DOM change and captured before/after +screenshots with preinstalled Chromium. Public Chromium browsing in that same session +failed at the browser relay with `ws_closed_mid_exchange`, although curl returned 200. +That is a transport failure, not evidence of an approval denial, and curl does not +prove browser navigation. Recheck the current session's browser and proxy helper +instructions before diagnosing it. Do not disable certificate verification or alter +permissions in response to that error. Report the precise failed route and any +separately verified route; a prior session's success is not current capability proof. diff --git a/scripts/setup/cloud-browser.mjs b/scripts/setup/cloud-browser.mjs new file mode 100644 index 0000000..6fd174a --- /dev/null +++ b/scripts/setup/cloud-browser.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** Discover existing browser tools; never install packages or load credentials. */ +import { + existsSync, + readdirSync, + accessSync, + constants, + mkdirSync, + realpathSync, + readFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +function packageRoots() { + const contractPath = join(repository, 'scripts/setup/cloud-contract.json'); + const projects = existsSync(contractPath) + ? (JSON.parse(readFileSync(contractPath, 'utf8')).projects ?? []) + : []; + return [ + ...new Set([ + process.cwd(), + repository, + ...projects.map(project => resolve(repository, project.path)), + ]), + ]; +} + +export function findPlaywright(roots = packageRoots()) { + const failures = []; + for (const root of roots) { + const require = createRequire(join(resolve(root), 'package.json')); + for (const name of ['playwright', '@playwright/test', 'playwright-core']) { + try { + const modulePath = require.resolve(name); + return { api: require(modulePath), modulePath }; + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') failures.push(`${name}: ${error.message}`); + } + } + const store = join(root, 'node_modules/.pnpm'); + if (!existsSync(store)) continue; + for (const entry of readdirSync(store).sort().reverse()) { + if (!/^(playwright|playwright-core|@playwright\+test)@/.test(entry)) continue; + for (const name of ['playwright', 'playwright-core', '@playwright/test']) { + const candidate = join(store, entry, 'node_modules', name); + try { + const modulePath = require.resolve(candidate); + return { api: require(modulePath), modulePath }; + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') failures.push(`${name}: ${error.message}`); + } + } + } + } + throw new Error( + `Playwright unavailable in current repository dependencies${failures.length ? `: ${failures.join('; ')}` : ''}` + ); +} + +function executable(path) { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +export function findBrowser(browserType, name, extraRoots = []) { + if (process.env.CLOUD_BROWSER_EXECUTABLE) { + const path = resolve(process.env.CLOUD_BROWSER_EXECUTABLE); + if (!executable(path)) throw new Error('CLOUD_BROWSER_EXECUTABLE is not executable'); + return path; + } + const expected = browserType.executablePath(); + if (executable(expected)) return expected; + const roots = [ + ...extraRoots, + process.env.PLAYWRIGHT_BROWSERS_PATH, + '/opt/pw-browsers', + join(homedir(), '.cache/ms-playwright'), + join(homedir(), 'Library/Caches/ms-playwright'), + ].filter(Boolean); + const names = + name === 'chromium' + ? ['chrome', 'chromium', 'headless_shell'] + : name === 'firefox' + ? ['firefox'] + : ['pw_run.sh']; + function search(root, depth) { + if (!existsSync(root) || depth < 0) return undefined; + for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => + b.name.localeCompare(a.name) + )) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + const found = search(path, depth - 1); + if (found) return found; + } else if (names.includes(entry.name) && executable(path)) return path; + } + } + for (const root of roots) { + const found = search(root, 4); + if (found) return found; + } + throw new Error( + `${name} executable unavailable; expected ${expected}. No installation attempted.` + ); +} + +export function localhostURL(value) { + const url = new URL(value); + if ( + !['http:', 'https:'].includes(url.protocol) || + !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || + url.username || + url.password + ) { + throw new Error('render requires an HTTP localhost URL without credentials'); + } + return url.href; +} + +async function main(args) { + const [command = 'doctor', value, output = '/tmp/cloud-browser.png'] = args; + if (!['doctor', 'render'].includes(command)) + throw new Error( + 'Usage: node scripts/setup/cloud-browser.mjs doctor | render http://localhost:PORT/path /tmp/page.png' + ); + const target = command === 'render' ? localhostURL(value) : undefined; + const name = process.env.CLOUD_BROWSER || 'chromium'; + if (!['chromium', 'firefox', 'webkit'].includes(name)) + throw new Error('CLOUD_BROWSER must be chromium, firefox or webkit'); + const { api, modulePath } = findPlaywright(); + const executablePath = findBrowser(api[name], name); + const capability = { browser: name, modulePath, executablePath }; + if (command === 'doctor') { + console.log( + JSON.stringify( + { status: 'available', ...capability, launch: 'untested', application: 'untested' }, + null, + 2 + ) + ); + return; + } + const browser = await api[name].launch({ executablePath, headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + const response = await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30000 }); + if (!response || !response.ok()) + throw new Error(`Local page returned ${response?.status() ?? 'no HTTP response'}`); + // Local apps may request remote assets. This command does not change proxy settings. + const screenshot = resolve(output); + mkdirSync(dirname(screenshot), { recursive: true }); + await page.screenshot({ path: screenshot, fullPage: true }); + console.log( + JSON.stringify( + { + status: 'rendered', + ...capability, + url: page.url(), + httpStatus: response.status(), + title: await page.title(), + screenshot, + application: 'untested: no control was driven', + }, + null, + 2 + ) + ); + } finally { + await browser.close(); + } +} + +if (process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url) { + main(process.argv.slice(2)).catch(error => { + console.error( + JSON.stringify({ + status: 'unavailable', + error: error.message.replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/g, '$1[redacted]@'), + }) + ); + process.exitCode = 2; + }); +}