756582322f8a53fef19e8c24ba902e353ba3623f chmalee Thu Aug 27 14:38:34 2026 -0700 uiTest: shared browser UI test harness, plus an hgTracks example, refs #38188 Co-Authored-By: Claude Opus 5 (1M context) diff --git src/hg/utils/uiTest/lib/pw.js src/hg/utils/uiTest/lib/pw.js new file mode 100644 index 00000000000..236c2dd92b3 --- /dev/null +++ src/hg/utils/uiTest/lib/pw.js @@ -0,0 +1,137 @@ +// pw.js -- find the shared Playwright install and load it. +// +// Resolution order, first hit wins: +// +// 1. UITEST_PW_PREFIX +// 2. pw.prefix from the conf file +// 3. /hive/groups/browser/uiTest/pw the shared install, owned by braney +// 4. $HOME/.uiTestPw a laptop or a non-hgwdev machine +// 5. bare require('playwright') whatever node can already see +// +// The shared install is flat: node_modules/ and browsers/ sit directly under the +// prefix. Its README.md carries the pin and the recipe for moving it. +// +// Two things this module does on purpose: +// +// Playwright is loaded by ABSOLUTE PATH, not by setting NODE_PATH. NODE_PATH +// loses to any node_modules directory in an ancestor of the working directory, +// and hgwdev has a stale /tmp/node_modules/playwright owned by someone else, so +// anything run from under /tmp with NODE_PATH set loads that instead and dies. +// An absolute-path require does not consult ancestor node_modules at all, so a +// uiTest run works from any directory. +// +// PLAYWRIGHT_BROWSERS_PATH is set before the require, because the browsers are a +// sibling of node_modules rather than inside it. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { infraError } = require('./env'); + +const SHARED = '/hive/groups/browser/uiTest/pw'; +const PRIVATE = path.join(os.homedir(), '.uiTestPw'); + +let cached = null; + +function usable(prefix) { + if (!prefix) { + return false; + } + return fs.existsSync(path.join(prefix, 'node_modules', 'playwright')); +} + +function readPin(prefix) { + try { + return fs.readFileSync(path.join(prefix, 'pw.pin'), 'utf8').trim(); + } catch (e) { + return null; + } +} + +function versionAt(modDir) { + try { + return JSON.parse(fs.readFileSync(path.join(modDir, 'package.json'), 'utf8')).version; + } catch (e) { + return null; + } +} + +function resolve(env) { + // Returns {prefix, source, playwright, version, pin, browsersPath, warning}. + // The prefix is null when we fell through to a bare require. + if (cached) { + return cached; + } + const tried = []; + const order = [ + [env && env.pwPrefix, 'UITEST_PW_PREFIX or pw.prefix'], + [SHARED, 'the shared install'], + [PRIVATE, 'a private install'], + ]; + for (const [prefix, source] of order) { + if (!prefix) { + continue; + } + tried.push(prefix); + if (!usable(prefix)) { + continue; + } + const browsers = path.join(prefix, 'browsers'); + if (fs.existsSync(browsers)) { + process.env.PLAYWRIGHT_BROWSERS_PATH = browsers; + } + const modDir = path.join(prefix, 'node_modules', 'playwright'); + const version = versionAt(modDir); + const pin = readPin(prefix); + let warning = null; + if (pin && version && pin !== version) { + warning = `playwright ${version} does not match the pin ${pin} in ` + + `${prefix}/pw.pin -- proceeding, but say so if something looks odd`; + } + cached = { + prefix: prefix, source: source, playwright: require(modDir), + version: version, pin: pin, + browsersPath: fs.existsSync(browsers) ? browsers : null, + warning: warning, + }; + return cached; + } + // Last resort: whatever this node process can already see. + try { + const pw = require('playwright'); + cached = { + prefix: null, source: 'a bare require', playwright: pw, + version: null, pin: null, browsersPath: null, warning: null, + }; + return cached; + } catch (e) { + throw infraError( + `cannot find playwright. Looked in:\n ${tried.join('\n ')}\n` + + `The shared install is ${SHARED} -- read its README.md for how it is ` + + `built and who owns it. Point somewhere else with UITEST_PW_PREFIX or ` + + `a pw.prefix line in your conf file.`); + } +} + +function chromium(env) { + return resolve(env).playwright.chromium; +} + +function subprocessEnv(env) { + // The environment a docent subprocess needs. Docent uses a bare require, so + // it does need NODE_PATH -- run it with an explicit cwd that is not under + // /tmp and the stale copy there cannot reach it. + const r = resolve(env); + if (!r.prefix) { + return {}; + } + return { + PW_DIR: r.prefix, + NODE_PATH: path.join(r.prefix, 'node_modules'), + ...(r.browsersPath ? { PLAYWRIGHT_BROWSERS_PATH: r.browsersPath } : {}), + }; +} + +module.exports = { resolve, chromium, subprocessEnv, SHARED, PRIVATE, usable, readPin };