| // Copyright 2025 The Chromium Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import childProcess from 'node:child_process'; | |
| import fs from 'node:fs/promises'; | |
| import os from 'node:os'; | |
| import path from 'node:path'; | |
| import { performance } from 'node:perf_hooks'; | |
| import { | |
| autoninjaPyPath, | |
| gnPyPath, | |
| rootPath, | |
| vpython3ExecutablePath, | |
| } from './devtools_paths.js'; | |
| /** | |
| * Errors returned from `spawn()` will have additional `stderr` and `stdout` | |
| * properties, similar to what we'd get from `child_process.execFile()`. | |
| */ | |
| class SpawnError extends Error { | |
| /** | |
| * Constructor for errors generated from `spawn()`. | |
| * | |
| * @param {string} message The actual error message. | |
| * @param {string} stderr The child process' error output. | |
| * @param {string} stdout The child process' regular output. | |
| */ | |
| constructor(message, stderr, stdout) { | |
| super(message); | |
| this.stderr = stderr; | |
| this.stdout = stdout; | |
| } | |
| } | |
| /** | |
| * Promisified wrapper around `child_process.spawn()`. | |
| * | |
| * In addition to forwarding the `options` to `child_process.spawn()`, it'll also | |
| * set the `shell` option to `true`, to ensure that on Windows we can correctly | |
| * invoke `.bat` files (necessary for the Python3 wrapper script). | |
| * | |
| * @param {string} command The command to run. | |
| * @param {Array<string>} args List of string arguments to pass to the `command`. | |
| * @param {Object} options Passed directly to `child_process.spawn()`. | |
| * @returns {Promise<{stdout: string, stderr: string}>} | |
| */ | |
| function spawn(command, args, options = {}) { | |
| return new Promise((resolve, reject) => { | |
| let child; | |
| if (os.platform() === 'win32') { | |
| child = childProcess.spawn( | |
| process.env.ComSpec ?? 'cmd.exe', | |
| ['/c', command, ...args], | |
| options, | |
| ); | |
| } else { | |
| child = childProcess.spawn(command, args, options); | |
| } | |
| let stdout = ''; | |
| let stderr = ''; | |
| child.stdout.on('data', data => { | |
| stdout += data.toString(); | |
| }); | |
| child.stderr.on('data', data => { | |
| stderr += data.toString(); | |
| }); | |
| child.on('exit', (code, signal) => { | |
| if (signal) { | |
| reject( | |
| new SpawnError( | |
| `Process terminated due to signal ${signal}`, | |
| stderr, | |
| stdout, | |
| ), | |
| ); | |
| } else if (code) { | |
| reject( | |
| new SpawnError(`Process exited with code ${code}`, stderr, stdout), | |
| ); | |
| } else { | |
| resolve({ stdout, stderr }); | |
| } | |
| }); | |
| child.on('error', reject); | |
| }); | |
| } | |
| /** | |
| * Representation of the feature set that is configured for Chrome. This | |
| * keeps track of enabled and disabled features and generates the correct | |
| * combination of `--enable-features` / `--disable-features` command line | |
| * flags. | |
| * | |
| * There are unit tests for this in `./devtools_build.test.mjs`. | |
| */ | |
| export class FeatureSet { | |
| #disabled = new Set(); | |
| #enabled = new Map(); | |
| /** | |
| * Disables the given `feature`. | |
| * | |
| * @param feature the name of the feature to disable. | |
| */ | |
| disable(feature) { | |
| this.#disabled.add(feature); | |
| this.#enabled.delete(feature); | |
| } | |
| /** | |
| * Enables the given `feature`, and optionally adds the `parameters` to it. | |
| * For example: | |
| * ```js | |
| * featureSet.enable('DevToolsFreestyler', {patching: true}); | |
| * ``` | |
| * The parameters are additive. | |
| * | |
| * @param feature the name of the feature to enable. | |
| * @param parameters the additional parameters to pass to it, in | |
| * the form of key/value pairs. | |
| */ | |
| enable(feature, parameters = {}) { | |
| this.#disabled.delete(feature); | |
| if (!this.#enabled.has(feature)) { | |
| this.#enabled.set(feature, Object.create(null)); | |
| } | |
| for (const [key, value] of Object.entries(parameters)) { | |
| this.#enabled.get(feature)[key] = value; | |
| } | |
| } | |
| /** | |
| * Merge the other `featureSet` into this. | |
| * | |
| * @param featureSet the other `FeatureSet` to apply. | |
| */ | |
| merge(featureSet) { | |
| for (const feature of featureSet.#disabled) { | |
| this.disable(feature); | |
| } | |
| for (const [feature, parameters] of featureSet.#enabled) { | |
| this.enable(feature, parameters); | |
| } | |
| } | |
| /** | |
| * Yields the command line parameters to pass to the invocation of | |
| * a Chrome binary for achieving the state of the feature set. | |
| */ | |
| *[Symbol.iterator]() { | |
| const disabledFeatures = [...this.#disabled]; | |
| if (disabledFeatures.length) { | |
| yield `--disable-features=${disabledFeatures.sort().join(',')}`; | |
| } | |
| const enabledFeatures = [...this.#enabled].map(([feature, parameters]) => { | |
| parameters = Object.entries(parameters); | |
| if (parameters.length) { | |
| parameters = parameters.map(([key, value]) => `${key}/${value}`); | |
| feature = `${feature}:${parameters.sort().join('/')}`; | |
| } | |
| return feature; | |
| }); | |
| if (enabledFeatures.length) { | |
| yield `--enable-features=${enabledFeatures.sort().join(',')}`; | |
| } | |
| } | |
| static parse(text) { | |
| if (!text) { | |
| return []; | |
| } | |
| const features = []; | |
| for (const str of text.split(',')) { | |
| const parts = str.split(':'); | |
| if (parts.length < 1 || parts.length > 2) { | |
| throw new Error(`Invalid feature declaration '${str}'`); | |
| } | |
| const feature = parts[0]; | |
| const parameters = Object.create(null); | |
| if (parts.length > 1) { | |
| const args = parts[1].split('/'); | |
| if (args.length % 2 !== 0) { | |
| throw new Error( | |
| `Invalid parameters '${parts[1]}' for feature ${feature}`, | |
| ); | |
| } | |
| for (let i = 0; i < args.length; i += 2) { | |
| const key = args[i + 0]; | |
| const value = args[i + 1]; | |
| parameters[key] = value; | |
| } | |
| } | |
| features.push({ feature, parameters }); | |
| } | |
| return features; | |
| } | |
| } | |
| /** | |
| * Constructs a human readable error message for the given build `error`. | |
| * | |
| * @param error the `Error` from the failed `autoninja` invocation. | |
| * @param outDir the absolute path to the `target` out directory. | |
| * @param target the target relative to `//out`. | |
| * @returns the human readable error message. | |
| */ | |
| function buildErrorMessageForNinja(error, outDir, target) { | |
| const { message, stderr, stdout } = error; | |
| if (stderr) { | |
| // Anything that went to stderr has precedence. | |
| return `Failed to build \`${target}' in \`${outDir}' | |
| ${stdout} | |
| ${stderr} | |
| `; | |
| } | |
| if (stdout) { | |
| // Check for `tsc` errors in the stdout. | |
| const tscErrors = [ | |
| ...stdout.matchAll(/^[^\s].*\(\d+,\d+\): error TS\d+:\s+.*$/gm), | |
| ].map(([tscError]) => tscError); | |
| if (tscErrors.length) { | |
| return `TypeScript compilation failed for \`${target}' | |
| ${tscErrors.join('\n')} | |
| `; | |
| } | |
| // At the very least we strip `ninja: Something, something` lines from the | |
| // standard output, since that's not particularly helpful. | |
| const output = stdout.replaceAll(/^ninja: [^\n]+\n+/gm, '').trim(); | |
| return `Failed to build \`${target}' in \`${outDir}' | |
| ${output} | |
| `; | |
| } | |
| return `Failed to build \`${target}' in \`${outDir}' (${message})`; | |
| } | |
| /** @enum {string} */ | |
| export const BuildStep = { | |
| GN: 'gn', | |
| AUTONINJA: 'autoninja', | |
| }; | |
| export class BuildError extends Error { | |
| /** | |
| * Constructs a new `BuildError` with the given parameters. | |
| * | |
| * @param step the build step that failed. | |
| * @param {object} options additional options for the `BuildError`. | |
| * @param options.cause the actual cause for the build error. | |
| * @param options.outDir the absolute path to the `target` out directory. | |
| * @param options.target the target relative to `//out`. | |
| */ | |
| constructor(step, options) { | |
| const { cause, outDir, target } = options; | |
| const message = | |
| step === BuildStep.GN | |
| ? `\`gn' failed to initialize out directory ${outDir}` | |
| : buildErrorMessageForNinja(cause, outDir, target); | |
| super(message, { cause }); | |
| this.step = step; | |
| this.name = 'BuildError'; | |
| this.target = target; | |
| this.outDir = outDir; | |
| } | |
| } | |
| /** | |
| * @typedef BuildResult | |
| * @type {object} | |
| * @property {number} time - wall clock time (in seconds) for the build. | |
| */ | |
| /** | |
| * @param target the target relative to `//out`. | |
| * @returns the GN args for the `target`. | |
| */ | |
| export async function prepareBuild(target) { | |
| const outDir = path.join(rootPath(), 'out', target); | |
| // Prepare the build directory first. | |
| const outDirStat = await fs.stat(outDir).catch(() => null); | |
| if (!outDirStat?.isDirectory()) { | |
| // Use GN to (optionally create and) initialize the |outDir|. | |
| try { | |
| const gnExe = vpython3ExecutablePath(); | |
| const gnArgs = [gnPyPath(), '-q', 'gen', outDir]; | |
| await spawn(gnExe, gnArgs); | |
| } catch (cause) { | |
| throw new BuildError(BuildStep.GN, { cause, outDir, target }); | |
| } | |
| } | |
| return await gnArgsForTarget(target); | |
| } | |
| /** @type Map<string, Promise<Map<string, string>>> */ | |
| const gnArgsCache = new Map(); | |
| /** | |
| * @param {string} target | |
| */ | |
| export function gnArgsForTarget(target) { | |
| let gnArgs = gnArgsCache.get(target); | |
| if (!gnArgs) { | |
| gnArgs = (async () => { | |
| const outDir = path.join(rootPath(), 'out', target); | |
| try { | |
| const cwd = rootPath(); | |
| const gnExe = vpython3ExecutablePath(); | |
| const gnArgs = [ | |
| gnPyPath(), | |
| '-q', | |
| 'args', | |
| outDir, | |
| '--json', | |
| '--list', | |
| '--short', | |
| ]; | |
| const { stdout } = await spawn(gnExe, gnArgs, { cwd }); | |
| return new Map( | |
| JSON.parse(stdout).map(arg => [ | |
| arg.name, | |
| arg.current?.value ?? arg.default?.value, | |
| ]), | |
| ); | |
| } catch { | |
| return new Map(); | |
| } | |
| })(); | |
| gnArgsCache.set(target, gnArgs); | |
| } | |
| return gnArgs; | |
| } | |
| /** | |
| * @param {string} target | |
| * @param {{ signal?: AbortSignal, buildTargets?: string[] }} [options] | |
| * @returns {Promise<BuildResult>} a `BuildResult` with statistics for the build. | |
| */ | |
| export async function build(target, options = {}) { | |
| const startTime = performance.now(); | |
| const outDir = path.join(rootPath(), 'out', target); | |
| try { | |
| const autoninjaExe = vpython3ExecutablePath(); | |
| const autoninjaArgs = [autoninjaPyPath(), '-C', outDir, ...(options.buildTargets ?? [])]; | |
| await spawn(autoninjaExe, autoninjaArgs, { | |
| signal: options.signal, | |
| }); | |
| } catch (cause) { | |
| if (cause.name === 'AbortError') { | |
| throw cause; | |
| } | |
| throw new BuildError(BuildStep.AUTONINJA, { cause, outDir, target }); | |
| } | |
| // Report the build result. | |
| const time = (performance.now() - startTime) / 1000; | |
| return { time }; | |
| } |