Benedikt Meurer | 1022e32 | 2025-03-28 13:39:58 | [diff] [blame] | 1 | // Copyright 2025 The Chromium Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 5 | import childProcess from 'node:child_process'; |
| 6 | import fs from 'node:fs/promises'; |
| 7 | import path from 'node:path'; |
Benedikt Meurer | b308e62 | 2025-04-17 11:24:29 | [diff] [blame] | 8 | import {performance} from 'node:perf_hooks'; |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 9 | import util from 'node:util'; |
| 10 | |
Nikolay Vitkov | a5686a8 | 2025-04-08 09:06:00 | [diff] [blame] | 11 | import { |
Benedikt Meurer | edac67e | 2025-04-24 11:15:07 | [diff] [blame] | 12 | autoninjaPyPath, |
| 13 | gnPyPath, |
Nikolay Vitkov | a5686a8 | 2025-04-08 09:06:00 | [diff] [blame] | 14 | rootPath, |
Benedikt Meurer | edac67e | 2025-04-24 11:15:07 | [diff] [blame] | 15 | vpython3ExecutablePath, |
Nikolay Vitkov | a5686a8 | 2025-04-08 09:06:00 | [diff] [blame] | 16 | } from './devtools_paths.js'; |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 17 | |
| 18 | const execFile = util.promisify(childProcess.execFile); |
| 19 | |
Benedikt Meurer | 1022e32 | 2025-03-28 13:39:58 | [diff] [blame] | 20 | /** |
| 21 | * Representation of the feature set that is configured for Chrome. This |
| 22 | * keeps track of enabled and disabled features and generates the correct |
| 23 | * combination of `--enable-features` / `--disable-features` command line |
| 24 | * flags. |
| 25 | * |
| 26 | * There are unit tests for this in `./devtools_build.test.mjs`. |
| 27 | */ |
| 28 | export class FeatureSet { |
| 29 | #disabled = new Set(); |
| 30 | #enabled = new Map(); |
| 31 | |
| 32 | /** |
| 33 | * Disables the given `feature`. |
| 34 | * |
| 35 | * @param {string} feature the name of the feature to disable. |
| 36 | */ |
| 37 | disable(feature) { |
| 38 | this.#disabled.add(feature); |
| 39 | this.#enabled.delete(feature); |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Enables the given `feature`, and optionally adds the `parameters` to it. |
| 44 | * For example: |
| 45 | * ```js |
| 46 | * featureSet.enable('DevToolsFreestyler', {patching: true}); |
| 47 | * ``` |
| 48 | * The parameters are additive. |
| 49 | * |
| 50 | * @param {string} feature the name of the feature to enable. |
| 51 | * @param {object} parameters the additional parameters to pass to it, in |
| 52 | * the form of key/value pairs. |
| 53 | */ |
| 54 | enable(feature, parameters = {}) { |
| 55 | this.#disabled.delete(feature); |
| 56 | if (!this.#enabled.has(feature)) { |
| 57 | this.#enabled.set(feature, Object.create(null)); |
| 58 | } |
| 59 | for (const [key, value] of Object.entries(parameters)) { |
| 60 | this.#enabled.get(feature)[key] = value; |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * Merge the other `featureSet` into this. |
| 66 | * |
| 67 | * @param featureSet the other `FeatureSet` to apply. |
| 68 | */ |
| 69 | merge(featureSet) { |
| 70 | for (const feature of featureSet.#disabled) { |
| 71 | this.disable(feature); |
| 72 | } |
| 73 | for (const [feature, parameters] of featureSet.#enabled) { |
| 74 | this.enable(feature, parameters); |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Yields the command line parameters to pass to the invocation of |
| 80 | * a Chrome binary for achieving the state of the feature set. |
| 81 | */ |
Benedikt Meurer | b308e62 | 2025-04-17 11:24:29 | [diff] [blame] | 82 | * [Symbol.iterator]() { |
Benedikt Meurer | 1022e32 | 2025-03-28 13:39:58 | [diff] [blame] | 83 | const disabledFeatures = [...this.#disabled]; |
| 84 | if (disabledFeatures.length) { |
| 85 | yield `--disable-features=${disabledFeatures.sort().join(',')}`; |
| 86 | } |
| 87 | const enabledFeatures = [...this.#enabled].map(([feature, parameters]) => { |
| 88 | parameters = Object.entries(parameters); |
| 89 | if (parameters.length) { |
| 90 | parameters = parameters.map(([key, value]) => `${key}/${value}`); |
| 91 | feature = `${feature}:${parameters.sort().join('/')}`; |
| 92 | } |
| 93 | return feature; |
| 94 | }); |
| 95 | if (enabledFeatures.length) { |
| 96 | yield `--enable-features=${enabledFeatures.sort().join(',')}`; |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | static parse(text) { |
Nikolay Vitkov | 28c7c02 | 2025-04-17 15:04:27 | [diff] [blame] | 101 | if (!text) { |
| 102 | return []; |
| 103 | } |
Benedikt Meurer | 1022e32 | 2025-03-28 13:39:58 | [diff] [blame] | 104 | const features = []; |
| 105 | for (const str of text.split(',')) { |
| 106 | const parts = str.split(':'); |
| 107 | if (parts.length < 1 || parts.length > 2) { |
| 108 | throw new Error(`Invalid feature declaration '${str}'`); |
| 109 | } |
| 110 | const feature = parts[0]; |
| 111 | const parameters = Object.create(null); |
| 112 | if (parts.length > 1) { |
| 113 | const args = parts[1].split('/'); |
| 114 | if (args.length % 2 !== 0) { |
Nikolay Vitkov | a5686a8 | 2025-04-08 09:06:00 | [diff] [blame] | 115 | throw new Error( |
Benedikt Meurer | b308e62 | 2025-04-17 11:24:29 | [diff] [blame] | 116 | `Invalid parameters '${parts[1]}' for feature ${feature}`, |
Nikolay Vitkov | a5686a8 | 2025-04-08 09:06:00 | [diff] [blame] | 117 | ); |
Benedikt Meurer | 1022e32 | 2025-03-28 13:39:58 | [diff] [blame] | 118 | } |
| 119 | for (let i = 0; i < args.length; i += 2) { |
| 120 | const key = args[i + 0]; |
| 121 | const value = args[i + 1]; |
| 122 | parameters[key] = value; |
| 123 | } |
| 124 | } |
Benedikt Meurer | b308e62 | 2025-04-17 11:24:29 | [diff] [blame] | 125 | features.push({feature, parameters}); |
Benedikt Meurer | 1022e32 | 2025-03-28 13:39:58 | [diff] [blame] | 126 | } |
| 127 | return features; |
| 128 | } |
| 129 | } |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 130 | |
Benedikt Meurer | 867b0b4 | 2025-04-17 16:31:19 | [diff] [blame] | 131 | /** |
| 132 | * Constructs a human readable error message for the given build `error`. |
| 133 | * |
| 134 | * @param {Error} error the `Error` from the failed `autoninja` invocation. |
| 135 | * @param {string} outDir the absolute path to the `target` out directory. |
| 136 | * @param {string} target the targe relative to `//out`. |
| 137 | * @return {string} the human readable error message. |
| 138 | */ |
| 139 | function buildErrorMessageForNinja(error, outDir, target) { |
| 140 | const {message, stderr, stdout} = error; |
| 141 | if (stderr) { |
| 142 | // Anything that went to stderr has precedence. |
| 143 | return `Failed to build \`${target}' in \`${outDir}' |
| 144 | |
| 145 | ${stderr} |
| 146 | `; |
| 147 | } |
| 148 | if (stdout) { |
| 149 | // Check for `tsc` or `esbuild` errors in the stdout. |
| 150 | const tscErrors = [...stdout.matchAll(/^[^\s].*\(\d+,\d+\): error TS\d+:\s+.*$/gm)].map(([tscError]) => tscError); |
| 151 | if (!tscErrors.length) { |
| 152 | // We didn't find any `tsc` errors, but maybe there are `esbuild` errors. |
| 153 | // Transform these into the `tsc` format (with a made up error code), so |
| 154 | // we can report all TypeScript errors consistently in `tsc` format (which |
| 155 | // is well-known and understood by tools). |
| 156 | const esbuildErrors = stdout.matchAll(/^✘ \[ERROR\] ([^\n]+)\n\n\s+\.\.\/\.\.\/(.+):(\d+):(\d+):/gm); |
| 157 | for (const [, message, filename, line, column] of esbuildErrors) { |
| 158 | tscErrors.push(`${filename}(${line},${column}): error TS0000: ${message}`); |
| 159 | } |
| 160 | } |
| 161 | if (tscErrors.length) { |
| 162 | return `TypeScript compilation failed for \`${target}' |
| 163 | |
| 164 | ${tscErrors.join('\n')} |
| 165 | `; |
| 166 | } |
| 167 | |
| 168 | // At the very least we strip `ninja: Something, something` lines from the |
| 169 | // standard output, since that's not particularly helpful. |
| 170 | const output = stdout.replaceAll(/^ninja: [^\n]+\n+/mg, '').trim(); |
| 171 | return `Failed to build \`${target}' in \`${outDir}' |
| 172 | |
| 173 | ${output} |
| 174 | `; |
| 175 | } |
| 176 | return `Failed to build \`${target}' in \`${outDir}' (${message.substring(0, message.indexOf('\n'))})`; |
| 177 | } |
| 178 | |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 179 | export const BuildStep = { |
Benedikt Meurer | 867b0b4 | 2025-04-17 16:31:19 | [diff] [blame] | 180 | GN: 'gn', |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 181 | AUTONINJA: 'autoninja', |
| 182 | }; |
| 183 | |
| 184 | export class BuildError extends Error { |
| 185 | /** |
| 186 | * Constructs a new `BuildError` with the given parameters. |
| 187 | * |
| 188 | * @param {BuildStep} step the build step that failed. |
| 189 | * @param {Object} options additional options for the `BuildError`. |
| 190 | * @param {Error} options.cause the actual cause for the build error. |
| 191 | * @param {string} options.outDir the absolute path to the `target` out directory. |
| 192 | * @param {string} options.target the target relative to `//out`. |
| 193 | */ |
| 194 | constructor(step, options) { |
Benedikt Meurer | b308e62 | 2025-04-17 11:24:29 | [diff] [blame] | 195 | const {cause, outDir, target} = options; |
Benedikt Meurer | 867b0b4 | 2025-04-17 16:31:19 | [diff] [blame] | 196 | const message = step === BuildStep.GN ? `\`gn' failed to initialize out directory ${outDir}` : |
| 197 | buildErrorMessageForNinja(cause, outDir, target); |
| 198 | super(message, {cause}); |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 199 | this.step = step; |
Benedikt Meurer | 867b0b4 | 2025-04-17 16:31:19 | [diff] [blame] | 200 | this.name = 'BuildError'; |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 201 | this.target = target; |
| 202 | this.outDir = outDir; |
| 203 | } |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 204 | } |
| 205 | |
| 206 | /** |
| 207 | * @typedef BuildResult |
| 208 | * @type {object} |
| 209 | * @property {number} time - wall clock time (in seconds) for the build. |
| 210 | */ |
| 211 | |
| 212 | /** |
| 213 | * @param {string} target |
Nikolay Vitkov | a5686a8 | 2025-04-08 09:06:00 | [diff] [blame] | 214 | * @return {Promise<void>} |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 215 | */ |
Nikolay Vitkov | a5686a8 | 2025-04-08 09:06:00 | [diff] [blame] | 216 | export async function prepareBuild(target) { |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 217 | const outDir = path.join(rootPath(), 'out', target); |
| 218 | |
| 219 | // Prepare the build directory first. |
| 220 | const outDirStat = await fs.stat(outDir).catch(() => null); |
| 221 | if (!outDirStat?.isDirectory()) { |
| 222 | // Use GN to (optionally create and) initialize the |outDir|. |
| 223 | try { |
Benedikt Meurer | edac67e | 2025-04-24 11:15:07 | [diff] [blame] | 224 | const gnExe = vpython3ExecutablePath(); |
| 225 | const gnArgs = [gnPyPath(), '-q', 'gen', outDir]; |
Nikolay Vitkov | a5686a8 | 2025-04-08 09:06:00 | [diff] [blame] | 226 | await execFile(gnExe, gnArgs); |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 227 | } catch (cause) { |
Benedikt Meurer | b308e62 | 2025-04-17 11:24:29 | [diff] [blame] | 228 | throw new BuildError(BuildStep.GN, {cause, outDir, target}); |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 229 | } |
| 230 | } |
Nikolay Vitkov | a5686a8 | 2025-04-08 09:06:00 | [diff] [blame] | 231 | } |
| 232 | |
| 233 | /** |
| 234 | * @param {string} target |
| 235 | * @param {AbortSignal=} signal |
| 236 | * @return {Promise<BuildResult>} a `BuildResult` with statistics for the build. |
| 237 | */ |
| 238 | export async function build(target, signal) { |
| 239 | const startTime = performance.now(); |
| 240 | const outDir = path.join(rootPath(), 'out', target); |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 241 | |
| 242 | // Build just the devtools-frontend resources in |outDir|. This is important |
| 243 | // since we might be running in a full Chromium checkout and certainly don't |
| 244 | // want to build all of Chromium first. |
| 245 | try { |
Benedikt Meurer | edac67e | 2025-04-24 11:15:07 | [diff] [blame] | 246 | const autoninjaExe = vpython3ExecutablePath(); |
| 247 | const autoninjaArgs = [autoninjaPyPath(), '-C', outDir, 'devtools_all_files']; |
Benedikt Meurer | b308e62 | 2025-04-17 11:24:29 | [diff] [blame] | 248 | await execFile(autoninjaExe, autoninjaArgs, {signal}); |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 249 | } catch (cause) { |
| 250 | if (cause.name === 'AbortError') { |
| 251 | throw cause; |
| 252 | } |
Benedikt Meurer | b308e62 | 2025-04-17 11:24:29 | [diff] [blame] | 253 | throw new BuildError(BuildStep.AUTONINJA, {cause, outDir, target}); |
Benedikt Meurer | 8d2ec0b | 2025-04-03 09:43:24 | [diff] [blame] | 254 | } |
| 255 | |
| 256 | // Report the build result. |
| 257 | const time = (performance.now() - startTime) / 1000; |
Benedikt Meurer | b308e62 | 2025-04-17 11:24:29 | [diff] [blame] | 258 | return {time}; |
Benedikt Meurer | 8ae0adc | 2025-04-17 08:32:24 | [diff] [blame] | 259 | } |