Ctrl K
ComunidadGuías…

lib/browser-launch.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// lib/browser-launch.mjs — which Chromium-family browser Playwright drives.
//
// Recent Playwright Chromium builds need a current OS. Where the bundled binary
// is present but cannot start (macOS 12, for instance, which recent builds no
// longer support), every browser-backed feature fails with no fallback — PDF
// rendering, the portal scanners, liveness checks, JD extraction. Most such
// machines already have a Chromium-family browser installed, and Playwright can
// drive any of them through `executablePath`.
//
// PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH already named that binary for the PDF
// path (generate-pdf.mjs, lib/pdf-server.mjs). This module is that same
// override, made reusable: one resolver every launch site shares, which also
// reads the data-root .env and can auto-detect an installed browser.
//
// Resolution precedence (first that applies wins):
//   1. The caller already set executablePath or channel → left untouched.
//   2. PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH — the shell first, then the
//      data-root .env (most commands never load .env, so this module reads it).
//   3. CAREER_OPS_BROWSER_AUTODETECT is truthy → the first installed
//      Brave/Chrome/Edge/Chromium at its standard location for the platform.
//   4. Nothing resolves → the options are returned unchanged, so the bundled
//      Chromium is launched exactly as before.
//
// Auto-detect is opt-in rather than automatic on purpose: a machine with a
// working bundled Chromium must keep using it, and flag-gating keeps the choice
// deterministic instead of varying with whatever else is installed.

import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join, resolve as resolvePath } from 'node:path';
import { parseEnv } from 'node:util';
import { getCareerOpsRoot } from '../path-resolver.mjs';

// Standard install locations, in preference order. Brave leads because it is
// the one most likely to still publish builds for an OS the bundled Chromium
// has dropped.
const CANDIDATES = {
  darwin: [
    '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
    '/Applications/Chromium.app/Contents/MacOS/Chromium',
  ],
  linux: [
    '/usr/bin/brave-browser',
    '/usr/bin/google-chrome',
    '/usr/bin/google-chrome-stable',
    '/usr/bin/chromium',
    '/usr/bin/chromium-browser',
    '/usr/bin/microsoft-edge',
  ],
  win32: [
    'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
    'C:\\Program Files (x86)\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
    'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
    'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
    'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
  ],
};

const EXECUTABLE_ENV = 'PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH';
const AUTODETECT_ENV = 'CAREER_OPS_BROWSER_AUTODETECT';
const BROWSER_ENV_NAMES = [EXECUTABLE_ENV, AUTODETECT_ENV];
const TRUTHY = new Set(['1', 'true', 'yes', 'on']);

// Preserve only the two browser settings before a caller loads dotenv. On
// Windows, process.env lookups are case-insensitive but spreading it into a
// plain object is not, so normalize any differently-cased key to the canonical
// name while the snapshot is taken.
export function snapshotBrowserProcessEnv(processEnv = process.env, platform = process.platform) {
  const snapshot = {};
  const entries = platform === 'win32' ? Object.entries(processEnv) : [];
  for (const name of BROWSER_ENV_NAMES) {
    if (Object.prototype.hasOwnProperty.call(processEnv, name)) {
      snapshot[name] = processEnv[name];
      continue;
    }
    if (platform === 'win32') {
      const match = entries.find(([key]) => key.toUpperCase() === name);
      if (match) snapshot[name] = match[1];
    }
  }
  return snapshot;
}

// The data-root .env, parsed once. Only scan.mjs, the evaluators,
// openai-tailor.mjs and openrouter-runner.mjs load a .env, so a setting that
// lives only there would be invisible to every other launch site. Parsed rather
// than applied to process.env (the way doctor.mjs reads it) so a value here can
// never shadow a real shell export of the same name.
let __envFileCache = new Map(); // project root -> parsed object (possibly {})
function envFile(projectRoot = getCareerOpsRoot()) {
  if (__envFileCache.has(projectRoot)) return __envFileCache.get(projectRoot);
  let parsed = {};
  try {
    const contents = readFileSync(join(projectRoot, '.env'), 'utf8');
    // Node 22.13 can retain an inline comment on the final assignment when a
    // .env file has no trailing newline. Normalize the parser input so the
    // minimum supported Node release reads that last value consistently.
    parsed = parseEnv(contents.endsWith('\n') ? contents : `${contents}\n`);
  } catch {
    // No .env, or unreadable — an empty cache is the right answer either way.
  }
  __envFileCache.set(projectRoot, parsed);
  return parsed;
}

// A setting from the shell first, then the data-root .env. A variable PRESENT
// in the shell wins even when empty, and an empty shell value reads as "off"
// without falling through to .env — so a shell export can switch off what a
// .env turns on, which is how the test suite neutralises a developer's own
// machine configuration.
function settingWithSource(name, projectRoot, processEnv = process.env) {
  if (Object.prototype.hasOwnProperty.call(processEnv, name)) {
    const value = processEnv[name];
    return {
      value: value != null && value.trim() !== '' ? value.trim() : null,
      source: 'shell',
    };
  }
  const fromFile = envFile(projectRoot)[name];
  return {
    value: fromFile != null && fromFile.trim() !== '' ? fromFile.trim() : null,
    source: 'file',
  };
}

function setting(name, projectRoot, processEnv) {
  return settingWithSource(name, projectRoot, processEnv).value;
}

/**
 * The Chromium-family executable career-ops should launch, or null to use
 * Playwright's bundled Chromium.
 *
 * Deliberately NOT memoised: sharedPdfSocketPath() keys the painter socket on
 * this result, and changing the override within one process must change that
 * key (see tests/pdf-shared-executable-path.test.mjs). Re-reading the
 * environment is a few property lookups, and the file read behind it is cached
 * separately; the existsSync probes only run when auto-detect is switched on.
 *
 * @param {{
 *   platform?: string,
 *   pathExists?: (path: string) => boolean,
 *   projectRoot?: string,
 *   processEnv?: Record<string, string | undefined>,
 * }} [probe] - Optional probe seam for deterministic tests.
 * @returns {{ path: string, source: 'env' | 'auto' } | null}
 */
export function resolveBrowserExecutable({
  platform = process.platform,
  pathExists = existsSync,
  projectRoot = getCareerOpsRoot(),
  processEnv = process.env,
} = {}) {
  const explicit = settingWithSource(EXECUTABLE_ENV, projectRoot, processEnv);
  if (explicit.value) {
    // A path in the data-root .env belongs to that data root, independently of
    // the directory a command happened to start from. Keep shell exports
    // untouched for backwards compatibility: relative shell paths have always
    // been interpreted by Playwright from the process cwd.
    const path = explicit.source === 'file' && !isAbsolute(explicit.value)
      ? resolvePath(projectRoot, explicit.value)
      : explicit.value;
    return { path, source: 'env' };
  }

  if (TRUTHY.has(String(setting(AUTODETECT_ENV, projectRoot, processEnv) ?? '').toLowerCase())) {
    const candidate = (CANDIDATES[platform] || []).find(p => pathExists(p));
    if (candidate) return { path: candidate, source: 'auto' };
  }
  return null;
}

/**
 * Playwright launch options with the resolved `executablePath` merged in.
 *
 * A caller that has already chosen a browser (executablePath or channel set) is
 * returned untouched, and when nothing resolves the options come back
 * unchanged — so the default path stays byte-identical to the bundled Chromium.
 *
 * @template {Record<string, any>} T
 * @param {T} [base] - e.g. { headless: true }
 * @param {{ platform?: string, pathExists?: (path: string) => boolean, projectRoot?: string, processEnv?: Record<string, string | undefined> }} [probe]
 * @returns {T | (T & { executablePath: string })}
 */
export function browserLaunchOptions(base = /** @type {any} */ ({}), probe = {}) {
  if (base && (base.executablePath || base.channel)) return base;
  const found = resolveBrowserExecutable(probe);
  return found ? { ...base, executablePath: found.path } : base;
}

/** Test-only: drop the cached .env parse so a test can vary the data root. */
export function __resetBrowserLaunchCacheForTest() {
  __envFileCache = new Map();
}