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) <noreply@anthropic.com>

diff --git src/hg/utils/uiTest/tests/t03-pw.js src/hg/utils/uiTest/tests/t03-pw.js
new file mode 100644
index 00000000000..9e6e696dac7
--- /dev/null
+++ src/hg/utils/uiTest/tests/t03-pw.js
@@ -0,0 +1,77 @@
+// Finding the shared Playwright install.
+//
+// The part worth pinning down is that we load playwright by ABSOLUTE PATH rather
+// than 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 a NODE_PATH-based
+// resolver breaks for anything run from under /tmp. This test does its work with
+// its own working directory moved into a temp tree that has exactly that trap in
+// it, so the day someone "simplifies" pw.js back to NODE_PATH, this fails.
+
+'use strict';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const t = require('./assert');
+const pw = require('../lib/pw');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uiTestPw-'));
+
+// --- usable() wants node_modules/playwright under the prefix ---
+t.ok(!pw.usable(null), 'no prefix is not usable');
+t.ok(!pw.usable(path.join(tmp, 'nothing')), 'an empty directory is not usable');
+const fake = path.join(tmp, 'fake');
+fs.mkdirSync(path.join(fake, 'node_modules', 'playwright'), { recursive: true });
+t.ok(pw.usable(fake), 'a prefix with node_modules/playwright is usable');
+
+// --- pw.pin is read from the prefix, not from the kent tree ---
+t.is(pw.readPin(fake), null, 'no pw.pin reads as null');
+fs.writeFileSync(path.join(fake, 'pw.pin'), '1.62.1\n');
+t.is(pw.readPin(fake), '1.62.1', 'pw.pin is read and trimmed');
+
+// --- the shared install, where the real answer lives ---
+if (pw.usable(pw.SHARED)) {
+    const pin = pw.readPin(pw.SHARED);
+    t.ok(!!pin, `the shared install at ${pw.SHARED} has a pw.pin (${pin})`);
+    t.ok(fs.existsSync(path.join(pw.SHARED, 'browsers')),
+        'the shared install has browsers/ beside node_modules');
+
+    // The trap: work from a directory whose ancestor holds a decoy
+    // node_modules/playwright, and confirm we still load the shared one.
+    const trap = path.join(tmp, 'trap');
+    fs.mkdirSync(path.join(trap, 'node_modules', 'playwright'), { recursive: true });
+    fs.writeFileSync(path.join(trap, 'node_modules', 'playwright', 'package.json'),
+        '{"name":"playwright","version":"0.0.0-decoy","main":"index.js"}');
+    fs.writeFileSync(path.join(trap, 'node_modules', 'playwright', 'index.js'),
+        'throw new Error("the decoy was loaded");');
+    const here = process.cwd();
+    const work = path.join(trap, 'work');
+    fs.mkdirSync(work);
+    process.chdir(work);
+    process.env.NODE_PATH = path.join(trap, 'node_modules');
+    let r = null;
+    try {
+        r = pw.resolve({ pwPrefix: null });
+    } catch (e) {
+        t.ok(false, `resolving from under a decoy node_modules threw: ${e.message}`);
+    } finally {
+        process.chdir(here);
+        delete process.env.NODE_PATH;
+    }
+    if (r) {
+        t.is(r.prefix, pw.SHARED, 'resolved to the shared install, not the decoy beside us');
+        t.ok(r.version !== '0.0.0-decoy', 'the decoy playwright was not loaded');
+        t.is(process.env.PLAYWRIGHT_BROWSERS_PATH, path.join(pw.SHARED, 'browsers'),
+            'PLAYWRIGHT_BROWSERS_PATH was set before the require');
+        const sub = pw.subprocessEnv({});
+        t.is(sub.PW_DIR, pw.SHARED, 'a docent subprocess is pointed at the same prefix');
+        t.is(sub.NODE_PATH, path.join(pw.SHARED, 'node_modules'),
+            'and given the NODE_PATH docent needs, since docent uses a bare require');
+    }
+} else {
+    console.log(`SKIP  the shared install is not at ${pw.SHARED} on this machine`);
+}
+
+fs.rmSync(tmp, { recursive: true, force: true });
+t.done('t03-pw');