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/browser.js src/hg/utils/uiTest/lib/browser.js new file mode 100644 index 00000000000..d6cd1513cab --- /dev/null +++ src/hg/utils/uiTest/lib/browser.js @@ -0,0 +1,85 @@ +// browser.js -- launching a browser, making a context, tracing, screenshots. +// +// This layer must stay code that could drive any website. It knows nothing about +// the Genome Browser: no CGI names, no selectors, no cart, no login. Anything +// site-specific belongs in site.js one layer up. +// +// Every determinism knob is applied here, once, so no test has to remember it: a +// fixed viewport, an sRGB colour profile, a pinned locale and timezone. Two runs +// on two machines then see the same page. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const pw = require('./pw'); + +// Fixed and generous: wide enough that the button bars do not wrap, tall enough +// that a normal page needs no scrolling to be photographed. +const VIEWPORT = { width: 1280, height: 1024 }; +const LOCALE = 'en-US'; +const TIMEZONE = 'America/Los_Angeles'; + +async function launch(env) { + const chromium = pw.chromium(env); + return chromium.launch({ + headless: !env.headed, + slowMo: env.slowMo || 0, + args: ['--force-color-profile=srgb'], + }); +} + +async function context(browser, env, opts) { + const o = opts || {}; + const ctx = await browser.newContext({ + viewport: VIEWPORT, + locale: LOCALE, + timezoneId: TIMEZONE, + acceptDownloads: true, + ...(o.storageState ? { storageState: o.storageState } : {}), + }); + ctx.setDefaultTimeout(env.timeout); + ctx.setDefaultNavigationTimeout(env.timeout); + for (const script of (o.initScripts || [])) { + await ctx.addInitScript(script); + } + // sources: false so a trace never carries a copy of the kent tree's + // JavaScript around with it. + if (o.trace !== false) { + await ctx.tracing.start({ screenshots: true, snapshots: true, sources: false }); + } + return ctx; +} + +async function stopTracing(ctx, file) { + // With a path the trace is written; with none it is thrown away. So tracing + // is free on a run where everything passed. + try { + if (file) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + await ctx.tracing.stop({ path: file }); + } else { + await ctx.tracing.stop(); + } + } catch (e) { + // A context that already closed has nothing to stop, and there are + // other ways this can fail -- an artifacts directory that is full or + // unwritable, say. Report the reason so a missing trace, the most + // useful thing in the artifacts directory, does not go missing with no + // sign why. Never let cleanup turn a passing run into a failing one. + console.error(`uiTest: could not write trace: ${e.message}`); + } +} + +async function shot(page, file) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + await page.screenshot({ path: file, fullPage: true }); + return file; + } catch (e) { + console.error(`uiTest: could not save screenshot ${file}: ${e.message}`); + return null; + } +} + +module.exports = { launch, context, stopTracing, shot, VIEWPORT, LOCALE, TIMEZONE };