Source: runner/config.js

/**
 * config.js — Runner environment configuration & artifact directory setup
 *
 * Centralises all env-driven constants used by the test runner pipeline so
 * they are defined in one place and importable by any sub-module.
 *
 * Also provides {@link launchBrowser} — the single place to launch Chromium
 * with the shared config (headless, args, executablePath). All modules that
 * need a browser (crawlBrowser, stateExplorer, testRunner) use this instead
 * of calling `chromium.launch()` directly.
 *
 * Artifact directories are created eagerly on import (idempotent).
 */

import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import { chromium, firefox, webkit, devices } from "playwright";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// ── Browser / viewport ────────────────────────────────────────────────────────
export const BROWSER_HEADLESS   = process.env.BROWSER_HEADLESS !== "false";
export const VIEWPORT_WIDTH     = parseInt(process.env.VIEWPORT_WIDTH, 10) || 1280;
export const VIEWPORT_HEIGHT    = parseInt(process.env.VIEWPORT_HEIGHT, 10) || 720;
export const NAVIGATION_TIMEOUT = parseInt(process.env.NAVIGATION_TIMEOUT, 10) || 30000;
export const API_TEST_TIMEOUT  = parseInt(process.env.API_TEST_TIMEOUT, 10) || 30000;
// Per-test timeout guard — if a single browser test exceeds this, it is
// forcibly aborted so it doesn't block the worker slot indefinitely.
// Defaults to 120s (generous enough for complex flows, strict enough to
// prevent runaway tests from hanging overnight runs).
export const BROWSER_TEST_TIMEOUT = parseInt(process.env.BROWSER_TEST_TIMEOUT, 10) || 120000;

// ── DIF-002: Cross-browser support ────────────────────────────────────────────
// Centralised browser selector. Accepts `"chromium"`, `"firefox"`, or `"webkit"`.
// Invalid or empty values fall back to chromium — the safe default, since it's
// the only browser with guaranteed CDP / screencast / shadow-DOM support
// (used by the crawler, live browser view, and recorder).
//
// Per-run override flows through `runTests({ browser })` → `launchBrowser({ browser })`,
// mirroring how `device` / `locale` / `timezoneId` are already threaded.
export const BROWSER_PRESETS = [
  { label: "Chromium (default)", value: "chromium" },
  { label: "Firefox",            value: "firefox"  },
  { label: "WebKit (Safari)",    value: "webkit"   },
];
const BROWSER_ENGINES = { chromium, firefox, webkit };
export const DEFAULT_BROWSER = (() => {
  const raw = (process.env.BROWSER_DEFAULT || "chromium").toLowerCase();
  return BROWSER_ENGINES[raw] ? raw : "chromium";
})();

/**
 * Resolve a browser name string to a Playwright BrowserType.
 * Unknown values fall back to chromium so a typo doesn't crash a run.
 * Non-string truthy inputs (numbers, objects, booleans) are treated as
 * unknown rather than throwing — the route layer can pass `req.body.browser`
 * straight through without a `typeof` guard.
 *
 * @param {*} [name] - One of `"chromium"`, `"firefox"`, `"webkit"`. Case-insensitive. Non-string values fall back to chromium.
 * @returns {{ engine: Object, name: string }} The Playwright BrowserType and its canonical name.
 */
export function resolveBrowser(name) {
  const key = typeof name === "string" ? name.toLowerCase() : "";
  if (BROWSER_ENGINES[key]) return { engine: BROWSER_ENGINES[key], name: key };
  return { engine: BROWSER_ENGINES[DEFAULT_BROWSER], name: DEFAULT_BROWSER };
}

// ── Shared Chromium launch args ───────────────────────────────────────────────
// Only applied when launching chromium — firefox and webkit reject these
// flags. Centralised so crawlBrowser, stateExplorer, and testRunner all use
// the same config when they do launch chromium.
export const BROWSER_ARGS = [
  "--no-sandbox",
  "--disable-setuid-sandbox",
  "--disable-dev-shm-usage",
];

/**
 * Launch a browser with the shared config.
 * All modules that need a browser should call this instead of
 * `chromium.launch()` / `firefox.launch()` / `webkit.launch()` directly, so
 * launch args, env overrides, and the cross-browser selector stay in one
 * place.
 *
 * The browser-specific `executablePath` env var is only applied when its
 * engine is selected — e.g. `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` has no
 * effect when launching firefox, where Playwright bundles its own binary.
 *
 * @param {Object} [overrides]        — Playwright LaunchOptions merged on top
 * @param {string} [overrides.browser] — `"chromium" | "firefox" | "webkit"`
 * @returns {Promise<Object>} Playwright Browser instance
 */
export async function launchBrowser(overrides = {}) {
  const { browser: browserName, ...playwrightOpts } = overrides;
  const { engine, name } = resolveBrowser(browserName);
  const launchOpts = {
    headless: BROWSER_HEADLESS,
    ...playwrightOpts,
  };
  // Chromium-specific: sandbox / shm flags + env executable path.
  // Firefox and webkit reject `--no-sandbox` and have their own bundled
  // binaries so we don't expose PLAYWRIGHT_*_EXECUTABLE_PATH for them here.
  if (name === "chromium") {
    launchOpts.executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined;
    launchOpts.args = playwrightOpts.args || BROWSER_ARGS;
  }
  return engine.launch(launchOpts);
}

// ── Parallel execution ────────────────────────────────────────────────────────
// Default number of concurrent browser contexts for test execution.
// Overridden per-run by Test Dials `parallelWorkers` setting.
// 1 = sequential (legacy behaviour), max 10.
export const DEFAULT_PARALLEL_WORKERS = Math.max(1, Math.min(10,
  parseInt(process.env.PARALLEL_WORKERS, 10) || 1
));

// ── Conversation limits ───────────────────────────────────────────────────────
// Maximum number of user↔assistant turn pairs to keep in the AI chat context
// window. When the conversation exceeds this, older turns in the middle are
// trimmed — keeping the first message (initial context) and the most recent
// turns. This prevents unbounded token growth without extra LLM calls.
export const MAX_CONVERSATION_TURNS = parseInt(process.env.MAX_CONVERSATION_TURNS, 10) || 20;

// ── Artifact paths ────────────────────────────────────────────────────────────
export const ARTIFACTS_DIR = path.join(__dirname, "..", "..", "artifacts");
export const VIDEOS_DIR    = path.join(ARTIFACTS_DIR, "videos");
export const TRACES_DIR    = path.join(ARTIFACTS_DIR, "traces");
export const SHOTS_DIR     = path.join(ARTIFACTS_DIR, "screenshots");
export const BASELINES_DIR = path.join(ARTIFACTS_DIR, "baselines");
export const DIFFS_DIR     = path.join(ARTIFACTS_DIR, "diffs");

[ARTIFACTS_DIR, VIDEOS_DIR, TRACES_DIR, SHOTS_DIR, BASELINES_DIR, DIFFS_DIR].forEach((d) => {
  if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
});

// ── DIF-001: Visual regression ────────────────────────────────────────────────
// Pixel difference threshold above which a step is flagged as a visual regression.
// Expressed as a fraction of total pixels (0.02 = 2%). Per-pixel match tolerance
// is governed by VISUAL_DIFF_PIXEL_TOLERANCE (pixelmatch `threshold` arg, 0..1).
// Parse a float env var with a default, accepting `0` (which `|| default`
// would reject) but rejecting `NaN` from non-numeric strings (which would
// silently disable regression detection because `diffRatio > NaN` is always
// false).
function parseFloatEnv(raw, def) {
  if (raw == null || raw === "") return def;
  const n = parseFloat(raw);
  return Number.isFinite(n) ? n : def;
}
export const VISUAL_DIFF_THRESHOLD       = parseFloatEnv(process.env.VISUAL_DIFF_THRESHOLD, 0.02);
export const VISUAL_DIFF_PIXEL_TOLERANCE = parseFloatEnv(process.env.VISUAL_DIFF_PIXEL_TOLERANCE, 0.1);

// ── DIF-003: Device emulation ─────────────────────────────────────────────────
// Playwright ships 50+ device profiles. We expose a curated subset for the
// run config dropdown plus accept any name from `playwright.devices`.

/**
 * Curated device profiles for the UI dropdown.
 * Each entry maps a display label to its Playwright `devices` key.
 * @type {Array<{label: string, value: string}>}
 */
export const DEVICE_PRESETS = [
  { label: "Desktop (default)",       value: "" },
  { label: "iPhone 14",              value: "iPhone 14" },
  { label: "iPhone 14 Pro Max",      value: "iPhone 14 Pro Max" },
  { label: "iPhone 12",              value: "iPhone 12" },
  { label: "iPad (gen 7)",           value: "iPad (gen 7)" },
  { label: "iPad Pro 11",            value: "iPad Pro 11" },
  { label: "Galaxy S9+",             value: "Galaxy S9+" },
  { label: "Pixel 7",               value: "Pixel 7" },
  { label: "Pixel 5",               value: "Pixel 5" },
  { label: "Galaxy Tab S4",         value: "Galaxy Tab S4" },
  { label: "Desktop Chrome HiDPI",  value: "Desktop Chrome HiDPI" },
  { label: "Desktop Firefox HiDPI", value: "Desktop Firefox HiDPI" },
];

/**
 * Resolve a device name to a Playwright device descriptor.
 * Returns `null` for empty/unknown names (caller should use default context).
 *
 * @param {string} deviceName - A key from `playwright.devices` (e.g. `"iPhone 14"`).
 * @returns {Object|null} Playwright device descriptor with viewport, userAgent, etc.
 */
export function resolveDevice(deviceName) {
  if (!deviceName) return null;
  const descriptor = devices[deviceName];
  if (!descriptor) return null;
  return descriptor;
}