• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2021 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15'use strict';
16
17// This script takes care of:
18// - The build process for the whole UI and the chrome extension.
19// - The HTTP dev-server with live-reload capabilities.
20// The reason why this is a hand-rolled script rather than a conventional build
21// system is keeping incremental build fast and maintaining the set of
22// dependencies contained.
23// The only way to keep incremental build fast (i.e. O(seconds) for the
24// edit-one-line -> reload html cycles) is to run both the TypeScript compiler
25// and the rollup bundler in --watch mode. Any other attempt, leads to O(10s)
26// incremental-build times.
27// This script allows mixing build tools that support --watch mode (tsc and
28// rollup) and auto-triggering-on-file-change rules via node-watch.
29// When invoked without any argument (e.g., for production builds), this script
30// just runs all the build tasks serially. It doesn't to do any mtime-based
31// check, it always re-runs all the tasks.
32// When invoked with --watch, it mounts a pipeline of tasks based on node-watch
33// and runs them together with tsc --watch and rollup --watch.
34// The output directory structure is carefully crafted so that any change to UI
35// sources causes cascading triggers of the next steps.
36// The overall build graph looks as follows:
37// +----------------+      +-----------------------------+
38// | protos/*.proto |----->| pbjs out/tsc/gen/protos.js  |--+
39// +----------------+      +-----------------------------+  |
40//                         +-----------------------------+  |
41//                         | pbts out/tsc/gen/protos.d.ts|<-+
42//                         +-----------------------------+
43//                             |
44//                             V      +-------------------------+
45// +---------+              +-----+   |  out/tsc/frontend/*.js  |
46// | ui/*.ts |------------->| tsc |-> +-------------------------+   +--------+
47// +---------+              +-----+   | out/tsc/controller/*.js |-->| rollup |
48//                            ^       +-------------------------+   +--------+
49//                +------------+      |   out/tsc/engine/*.js   |       |
50// +-----------+  |*.wasm.js   |      +-------------------------+       |
51// |ninja *.cc |->|*.wasm.d.ts |                                        |
52// +-----------+  |*.wasm      |-----------------+                      |
53//                +------------+                 |                      |
54//                                               V                      V
55// +-----------+  +------+    +------------------------------------------------+
56// | ui/*.scss |->| scss |--->|              Final out/dist/ dir               |
57// +-----------+  +------+    +------------------------------------------------+
58// +----------------------+   | +----------+ +---------+ +--------------------+|
59// | src/assets/*.png     |   | | assets/  | |*.wasm.js| | frontend_bundle.js ||
60// +----------------------+   | |  *.css   | |*.wasm   | +--------------------+|
61// | buildtools/typefaces |-->| |  *.png   | +---------+ |  engine_bundle.js  ||
62// +----------------------+   | |  *.woff2 |             +--------------------+|
63// | buildtools/legacy_tv |   | |  tv.html |             |traceconv_bundle.js ||
64// +----------------------+   | +----------+             +--------------------+|
65//                            +------------------------------------------------+
66
67const argparse = require('argparse');
68const child_process = require('child_process');
69const crypto = require('crypto');
70const fs = require('fs');
71const http = require('http');
72const path = require('path');
73const fswatch = require('node-watch');  // Like fs.watch(), but works on Linux.
74const pjoin = path.join;
75
76const ROOT_DIR = path.dirname(__dirname);  // The repo root.
77const VERSION_SCRIPT = pjoin(ROOT_DIR, 'tools/write_version_header.py');
78const GEN_IMPORTS_SCRIPT = pjoin(ROOT_DIR, 'tools/gen_ui_imports');
79
80const cfg = {
81  watch: false,
82  verbose: false,
83  debug: false,
84  startHttpServer: false,
85  httpServerListenHost: '127.0.0.1',
86  httpServerListenPort: 10000,
87  wasmModules: ['trace_processor', 'trace_to_text'],
88
89  // The fields below will be changed by main() after cmdline parsing.
90  // Directory structure:
91  // out/xxx/    -> outDir         : Root build dir, for both ninja/wasm and UI.
92  //   ui/       -> outUiDir       : UI dir. All outputs from this script.
93  //    tsc/     -> outTscDir      : Transpiled .ts -> .js.
94  //      gen/   -> outGenDir      : Auto-generated .ts/.js (e.g. protos).
95  //    dist/    -> outDistRootDir : Only index.html and service_worker.js
96  //      v1.2/  -> outDistDir     : JS bundles and assets
97  //    chrome_extension/          : Chrome extension.
98  outDir: pjoin(ROOT_DIR, 'out/ui'),
99  version: '',  // v1.2.3, derived from the CHANGELOG + git.
100  outUiDir: '',
101  outDistRootDir: '',
102  outTscDir: '',
103  outGenDir: '',
104  outDistDir: '',
105  outExtDir: '',
106};
107
108const RULES = [
109  {r: /ui\/src\/assets\/index.html/, f: copyIndexHtml},
110  {r: /ui\/src\/assets\/((.*)[.]png)/, f: copyAssets},
111  {r: /buildtools\/typefaces\/(.+[.]woff2)/, f: copyAssets},
112  {r: /buildtools\/catapult_trace_viewer\/(.+(js|html))/, f: copyAssets},
113  {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
114  {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
115  {r: /ui\/src\/chrome_extension\/.*/, f: copyExtensionAssets},
116  {r: /.*\/dist\/.+\/(?!manifest\.json).*/, f: genServiceWorkerManifestJson},
117  {r: /.*\/dist\/.*/, f: notifyLiveServer},
118];
119
120let tasks = [];
121let tasksTot = 0, tasksRan = 0;
122let httpWatches = [];
123let tStart = Date.now();
124let subprocesses = [];
125
126async function main() {
127  const parser = new argparse.ArgumentParser();
128  parser.addArgument('--out', {help: 'Output directory'});
129  parser.addArgument(['--watch', '-w'], {action: 'storeTrue'});
130  parser.addArgument(['--serve', '-s'], {action: 'storeTrue'});
131  parser.addArgument('--serve-host', {help: '--serve bind host'});
132  parser.addArgument('--serve-port', {help: '--serve bind port', type: 'int'});
133  parser.addArgument(['--verbose', '-v'], {action: 'storeTrue'});
134  parser.addArgument(['--no-build', '-n'], {action: 'storeTrue'});
135  parser.addArgument(['--no-wasm', '-W'], {action: 'storeTrue'});
136  parser.addArgument(['--run-unittests', '-t'], {action: 'storeTrue'});
137  parser.addArgument(['--run-integrationtests', '-T'], {action: 'storeTrue'});
138  parser.addArgument(['--debug', '-d'], {action: 'storeTrue'});
139  parser.addArgument(['--interactive', '-i'], {action: 'storeTrue'});
140  parser.addArgument(['--rebaseline', '-r'], {action: 'storeTrue'});
141  parser.addArgument(['--no-depscheck'], {action: 'storeTrue'});
142
143  const args = parser.parseArgs();
144  const clean = !args.no_build;
145  cfg.outDir = path.resolve(ensureDir(args.out || cfg.outDir));
146  cfg.outUiDir = ensureDir(pjoin(cfg.outDir, 'ui'), clean);
147  cfg.outExtDir = ensureDir(pjoin(cfg.outUiDir, 'chrome_extension'));
148  cfg.outDistRootDir = ensureDir(pjoin(cfg.outUiDir, 'dist'));
149  const proc = exec('python3', [VERSION_SCRIPT, '--stdout'], {stdout: 'pipe'});
150  cfg.version = proc.stdout.toString().trim();
151  cfg.outDistDir = ensureDir(pjoin(cfg.outDistRootDir, cfg.version));
152  cfg.outTscDir = ensureDir(pjoin(cfg.outUiDir, 'tsc'));
153  cfg.outGenDir = ensureDir(pjoin(cfg.outUiDir, 'tsc/gen'));
154  cfg.watch = !!args.watch;
155  cfg.verbose = !!args.verbose;
156  cfg.debug = !!args.debug;
157  cfg.startHttpServer = args.serve;
158  if (args.serve_host) {
159    cfg.httpServerListenHost = args.serve_host
160  }
161  if (args.serve_port) {
162    cfg.httpServerListenPort = args.serve_port
163  }
164  if (args.interactive) {
165    process.env.PERFETTO_UI_TESTS_INTERACTIVE = '1';
166  }
167  if (args.rebaseline) {
168    process.env.PERFETTO_UI_TESTS_REBASELINE = '1';
169  }
170
171  process.on('SIGINT', () => {
172    console.log('\nSIGINT received. Killing all child processes and exiting');
173    for (const proc of subprocesses) {
174      if (proc) proc.kill('SIGINT');
175    }
176    process.exit(130);  // 130 -> Same behavior of bash when killed by SIGINT.
177  });
178
179  if (!args.no_depscheck) {
180    // Check that deps are current before starting.
181    const installBuildDeps = pjoin(ROOT_DIR, 'tools/install-build-deps');
182    const checkDepsPath = pjoin(cfg.outDir, '.check_deps');
183    let args = [installBuildDeps, `--check-only=${checkDepsPath}`, '--ui'];
184
185    if (process.platform === "darwin") {
186      const result = child_process.spawnSync("arch", ["-arm64", "true"]);
187      const isArm64Capable = result.status === 0;
188      if (isArm64Capable) {
189        const archArgs = [
190          "arch",
191          "-arch",
192          "arm64",
193        ];
194        args = archArgs.concat(args);
195      }
196    }
197    const cmd = args.shift();
198    exec(cmd, args);
199  }
200
201  console.log('Entering', cfg.outDir);
202  process.chdir(cfg.outDir);
203
204  updateSymlinks();  // Links //ui/out -> //out/xxx/ui/
205
206  // Enqueue empty task. This is needed only for --no-build --serve. The HTTP
207  // server is started when the task queue reaches quiescence, but it takes at
208  // least one task for that.
209  addTask(() => {});
210
211  if (!args.no_build) {
212    buildWasm(args.no_wasm);
213    scanDir('ui/src/assets');
214    scanDir('ui/src/chrome_extension');
215    scanDir('buildtools/typefaces');
216    scanDir('buildtools/catapult_trace_viewer');
217    generateImports('ui/src/tracks', 'all_tracks.ts');
218    compileProtos();
219    genVersion();
220    transpileTsProject('ui');
221    transpileTsProject('ui/src/service_worker');
222    bundleJs('rollup.config.js');
223    genServiceWorkerManifestJson();
224
225    // Watches the /dist. When changed:
226    // - Notifies the HTTP live reload clients.
227    // - Regenerates the ServiceWorker file map.
228    scanDir(cfg.outDistRootDir);
229  }
230
231
232  // We should enter the loop only in watch mode, where tsc and rollup are
233  // asynchronous because they run in watch mode.
234  const tStart = Date.now();
235  while (!isDistComplete()) {
236    const secs = Math.ceil((Date.now() - tStart) / 1000);
237    process.stdout.write(`Waiting for first build to complete... ${secs} s\r`);
238    await new Promise(r => setTimeout(r, 500));
239  }
240  if (cfg.watch) console.log('\nFirst build completed!');
241
242  if (cfg.startHttpServer) {
243    startServer();
244  }
245  if (args.run_unittests) {
246    runTests('jest.unittest.config.js');
247  }
248  if (args.run_integrationtests) {
249    runTests('jest.integrationtest.config.js');
250  }
251}
252
253// -----------
254// Build rules
255// -----------
256
257function runTests(cfgFile) {
258  const args = [
259    '--rootDir',
260    cfg.outTscDir,
261    '--verbose',
262    '--runInBand',
263    '--detectOpenHandles',
264    '--forceExit',
265    '--projects',
266    pjoin(ROOT_DIR, 'ui/config', cfgFile)
267  ];
268  if (cfg.watch) {
269    args.push('--watchAll');
270    addTask(execNode, ['jest', args, {async: true}]);
271  } else {
272    addTask(execNode, ['jest', args]);
273  }
274}
275
276function copyIndexHtml(src) {
277  const index_html = () => {
278    let html = fs.readFileSync(src).toString();
279    // First copy the index.html as-is into the dist/v1.2.3/ directory. This is
280    // only used for archival purporses, so one can open
281    // ui.perfetto.dev/v1.2.3/ to skip the auto-update and channel logic.
282    fs.writeFileSync(pjoin(cfg.outDistDir, 'index.html'), html);
283
284    // Then copy it into the dist/ root by patching the version code.
285    // TODO(primiano): in next CLs, this script should take a
286    // --release_map=xxx.json argument, to populate this with multiple channels.
287    const versionMap = JSON.stringify({'stable': cfg.version});
288    const bodyRegex = /data-perfetto_version='[^']*'/;
289    html = html.replace(bodyRegex, `data-perfetto_version='${versionMap}'`);
290    fs.writeFileSync(pjoin(cfg.outDistRootDir, 'index.html'), html);
291  };
292  addTask(index_html);
293}
294
295function copyAssets(src, dst) {
296  addTask(cp, [src, pjoin(cfg.outDistDir, 'assets', dst)]);
297}
298
299function compileScss() {
300  const src = pjoin(ROOT_DIR, 'ui/src/assets/perfetto.scss');
301  const dst = pjoin(cfg.outDistDir, 'perfetto.css');
302  // In watch mode, don't exit(1) if scss fails. It can easily happen by
303  // having a typo in the css. It will still print an error.
304  const noErrCheck = !!cfg.watch;
305  addTask(execNode, ['node-sass', ['--quiet', src, dst], {noErrCheck}]);
306}
307
308function compileProtos() {
309  const dstJs = pjoin(cfg.outGenDir, 'protos.js');
310  const dstTs = pjoin(cfg.outGenDir, 'protos.d.ts');
311  const inputs = [
312    'protos/perfetto/trace_processor/trace_processor.proto',
313    'protos/perfetto/common/trace_stats.proto',
314    'protos/perfetto/common/tracing_service_capabilities.proto',
315    'protos/perfetto/config/perfetto_config.proto',
316    'protos/perfetto/ipc/consumer_port.proto',
317    'protos/perfetto/ipc/wire_protocol.proto',
318    'protos/perfetto/metrics/metrics.proto',
319  ];
320  const pbjsArgs = [
321    '--force-number',
322    '-t',
323    'static-module',
324    '-w',
325    'commonjs',
326    '-p',
327    ROOT_DIR,
328    '-o',
329    dstJs
330  ].concat(inputs);
331  addTask(execNode, ['pbjs', pbjsArgs]);
332  const pbtsArgs = ['-p', ROOT_DIR, '-o', dstTs, dstJs];
333  addTask(execNode, ['pbts', pbtsArgs]);
334}
335
336function generateImports(dir, name) {
337  // We have to use the symlink (ui/src/gen) rather than cfg.outGenDir
338  // below since we want to generate the correct relative imports. For example:
339  // ui/src/frontend/foo.ts
340  //    import '../gen/all_plugins.ts';
341  // ui/src/gen/all_plugins.ts (aka ui/out/tsc/gen/all_plugins.ts)
342  //    import '../frontend/some_plugin.ts';
343  const dstTs = pjoin(ROOT_DIR, 'ui/src/gen', name);
344  const inputDir = pjoin(ROOT_DIR, dir);
345  const args = [GEN_IMPORTS_SCRIPT, inputDir, '--out', dstTs];
346  addTask(exec, ['python3', args]);
347}
348
349// Generates a .ts source that defines the VERSION and SCM_REVISION constants.
350function genVersion() {
351  const cmd = 'python3';
352  const args =
353      [VERSION_SCRIPT, '--ts_out', pjoin(cfg.outGenDir, 'perfetto_version.ts')];
354  addTask(exec, [cmd, args]);
355}
356
357function updateSymlinks() {
358  // /ui/out -> /out/ui.
359  mklink(cfg.outUiDir, pjoin(ROOT_DIR, 'ui/out'));
360
361  // /out/ui/test/data -> /test/data (For UI tests).
362  mklink(
363      pjoin(ROOT_DIR, 'test/data'),
364      pjoin(ensureDir(pjoin(cfg.outDir, 'test')), 'data'));
365
366  // Creates a out/dist_version -> out/dist/v1.2.3 symlink, so rollup config
367  // can point to that without having to know the current version number.
368  mklink(
369      path.relative(cfg.outUiDir, cfg.outDistDir),
370      pjoin(cfg.outUiDir, 'dist_version'));
371
372  mklink(
373      pjoin(ROOT_DIR, 'ui/node_modules'), pjoin(cfg.outTscDir, 'node_modules'))
374}
375
376// Invokes ninja for building the {trace_processor, trace_to_text} Wasm modules.
377// It copies the .wasm directly into the out/dist/ dir, and the .js/.ts into
378// out/tsc/, so the typescript compiler and the bundler can pick them up.
379function buildWasm(skipWasmBuild) {
380  if (!skipWasmBuild) {
381    const gnArgs = ['gen', `--args=is_debug=${cfg.debug}`, cfg.outDir];
382    addTask(exec, [pjoin(ROOT_DIR, 'tools/gn'), gnArgs]);
383
384    const ninjaArgs = ['-C', cfg.outDir];
385    ninjaArgs.push(...cfg.wasmModules.map(x => `${x}_wasm`));
386    addTask(exec, [pjoin(ROOT_DIR, 'tools/ninja'), ninjaArgs]);
387  }
388
389  const wasmOutDir = pjoin(cfg.outDir, 'wasm');
390  for (const wasmMod of cfg.wasmModules) {
391    // The .wasm file goes directly into the dist dir (also .map in debug)
392    for (const ext of ['.wasm'].concat(cfg.debug ? ['.wasm.map'] : [])) {
393      const src = `${wasmOutDir}/${wasmMod}${ext}`;
394      addTask(cp, [src, pjoin(cfg.outDistDir, wasmMod + ext)]);
395    }
396    // The .js / .ts go into intermediates, they will be bundled by rollup.
397    for (const ext of ['.js', '.d.ts']) {
398      const fname = `${wasmMod}${ext}`;
399      addTask(cp, [pjoin(wasmOutDir, fname), pjoin(cfg.outGenDir, fname)]);
400    }
401  }
402}
403
404// This transpiles all the sources (frontend, controller, engine, extension) in
405// one go. The only project that has a dedicated invocation is service_worker.
406function transpileTsProject(project) {
407  const args = ['--project', pjoin(ROOT_DIR, project)];
408  if (cfg.watch) {
409    args.push('--watch', '--preserveWatchOutput');
410    addTask(execNode, ['tsc', args, {async: true}]);
411  } else {
412    addTask(execNode, ['tsc', args]);
413  }
414}
415
416// Creates the three {frontend, controller, engine}_bundle.js in one invocation.
417function bundleJs(cfgName) {
418  const rcfg = pjoin(ROOT_DIR, 'ui/config', cfgName);
419  const args = ['-c', rcfg, '--no-indent'];
420  args.push(...(cfg.verbose ? [] : ['--silent']));
421  if (cfg.watch) {
422    // --waitForBundleInput is so that we can run tsc --watch and rollup --watch
423    // together, without having to wait that tsc completes the first build.
424    args.push('--watch', '--waitForBundleInput', '--no-watch.clearScreen');
425    addTask(execNode, ['rollup', args, {async: true}]);
426  } else {
427    addTask(execNode, ['rollup', args]);
428  }
429}
430
431function genServiceWorkerManifestJson() {
432  function make_manifest() {
433    const manifest = {resources: {}};
434    // When building the subresource manifest skip source maps, the manifest
435    // itself and the copy of the index.html which is copied under /v1.2.3/.
436    // The root /index.html will be fetched by service_worker.js separately.
437    const skipRegex = /(\.map|manifest\.json|index.html)$/;
438    walk(cfg.outDistDir, absPath => {
439      const contents = fs.readFileSync(absPath);
440      const relPath = path.relative(cfg.outDistDir, absPath);
441      const b64 = crypto.createHash('sha256').update(contents).digest('base64');
442      manifest.resources[relPath] = 'sha256-' + b64;
443    }, skipRegex);
444    const manifestJson = JSON.stringify(manifest, null, 2);
445    fs.writeFileSync(pjoin(cfg.outDistDir, 'manifest.json'), manifestJson);
446  }
447  addTask(make_manifest, []);
448}
449
450function startServer() {
451  console.log(
452      'Starting HTTP server on',
453      `http://${cfg.httpServerListenHost}:${cfg.httpServerListenPort}`);
454  http.createServer(function(req, res) {
455        console.debug(req.method, req.url);
456        let uri = req.url.split('?', 1)[0];
457        if (uri.endsWith('/')) {
458          uri += 'index.html';
459        }
460
461        if (uri === '/live_reload') {
462          // Implements the Server-Side-Events protocol.
463          const head = {
464            'Content-Type': 'text/event-stream',
465            'Connection': 'keep-alive',
466            'Cache-Control': 'no-cache'
467          };
468          res.writeHead(200, head);
469          const arrayIdx = httpWatches.length;
470          // We never remove from the array, the delete leaves an undefined item
471          // around. It makes keeping track of the index easier at the cost of a
472          // small leak.
473          httpWatches.push(res);
474          req.on('close', () => delete httpWatches[arrayIdx]);
475          return;
476        }
477
478        let absPath = path.normalize(path.join(cfg.outDistRootDir, uri));
479        // We want to be able to use the data in '/test/' for e2e tests.
480        // However, we don't want do create a symlink into the 'dist/' dir,
481        // because 'dist/' gets shipped on the production server.
482        if (uri.startsWith('/test/')) {
483          absPath = pjoin(ROOT_DIR, uri);
484        }
485
486        // Don't serve contents outside of the project root (b/221101533).
487        if (path.relative(ROOT_DIR, absPath).startsWith('..')) {
488          res.writeHead(403);
489          res.end('403 Forbidden - Request path outside of the repo root');
490          return;
491        }
492
493        fs.readFile(absPath, function(err, data) {
494          if (err) {
495            res.writeHead(404);
496            res.end(JSON.stringify(err));
497            return;
498          }
499
500          const mimeMap = {
501            'html': 'text/html',
502            'css': 'text/css',
503            'js': 'application/javascript',
504            'wasm': 'application/wasm',
505          };
506          const ext = uri.split('.').pop();
507          const cType = mimeMap[ext] || 'octect/stream';
508          const head = {
509            'Content-Type': cType,
510            'Content-Length': data.length,
511            'Last-Modified': fs.statSync(absPath).mtime.toUTCString(),
512            'Cache-Control': 'no-cache',
513          };
514          res.writeHead(200, head);
515          res.write(data);
516          res.end();
517        });
518      })
519      .listen(cfg.httpServerListenPort, cfg.httpServerListenHost);
520}
521
522function isDistComplete() {
523  const requiredArtifacts = [
524    'frontend_bundle.js',
525    'engine_bundle.js',
526    'traceconv_bundle.js',
527    'trace_processor.wasm',
528    'perfetto.css',
529  ];
530  const relPaths = new Set();
531  walk(cfg.outDistDir, absPath => {
532    relPaths.add(path.relative(cfg.outDistDir, absPath));
533  });
534  for (const fName of requiredArtifacts) {
535    if (!relPaths.has(fName)) return false;
536  }
537  return true;
538}
539
540// Called whenever a change in the out/dist directory is detected. It sends a
541// Server-Side-Event to the live_reload.ts script.
542function notifyLiveServer(changedFile) {
543  for (const cli of httpWatches) {
544    if (cli === undefined) continue;
545    cli.write(
546        'data: ' + path.relative(cfg.outDistRootDir, changedFile) + '\n\n');
547  }
548}
549
550function copyExtensionAssets() {
551  addTask(cp, [
552    pjoin(ROOT_DIR, 'ui/src/assets/logo-128.png'),
553    pjoin(cfg.outExtDir, 'logo-128.png')
554  ]);
555  addTask(cp, [
556    pjoin(ROOT_DIR, 'ui/src/chrome_extension/manifest.json'),
557    pjoin(cfg.outExtDir, 'manifest.json')
558  ]);
559}
560
561// -----------------------
562// Task chaining functions
563// -----------------------
564
565function addTask(func, args) {
566  const task = new Task(func, args);
567  for (const t of tasks) {
568    if (t.identity === task.identity) {
569      return;
570    }
571  }
572  tasks.push(task);
573  setTimeout(runTasks, 0);
574}
575
576function runTasks() {
577  const snapTasks = tasks.splice(0);  // snap = std::move(tasks).
578  tasksTot += snapTasks.length;
579  for (const task of snapTasks) {
580    const DIM = '\u001b[2m';
581    const BRT = '\u001b[37m';
582    const RST = '\u001b[0m';
583    const ms = (new Date(Date.now() - tStart)).toISOString().slice(17, -1);
584    const ts = `[${DIM}${ms}${RST}]`;
585    const descr = task.description.substr(0, 80);
586    console.log(`${ts} ${BRT}${++tasksRan}/${tasksTot}${RST}\t${descr}`);
587    task.func.apply(/*this=*/ undefined, task.args);
588  }
589}
590
591// Executes all the RULES that match the given |absPath|.
592function scanFile(absPath) {
593  console.assert(fs.existsSync(absPath));
594  console.assert(path.isAbsolute(absPath));
595  const normPath = path.relative(ROOT_DIR, absPath);
596  for (const rule of RULES) {
597    const match = rule.r.exec(normPath);
598    if (!match || match[0] !== normPath) continue;
599    const captureGroup = match.length > 1 ? match[1] : undefined;
600    rule.f(absPath, captureGroup);
601  }
602}
603
604// Walks the passed |dir| recursively and, for each file, invokes the matching
605// RULES. If --watch is used, it also installs a fswatch() and re-triggers the
606// matching RULES on each file change.
607function scanDir(dir, regex) {
608  const filterFn = regex ? absPath => regex.test(absPath) : () => true;
609  const absDir = path.isAbsolute(dir) ? dir : pjoin(ROOT_DIR, dir);
610  // Add a fs watch if in watch mode.
611  if (cfg.watch) {
612    fswatch(absDir, {recursive: true}, (_eventType, filePath) => {
613      if (!filterFn(filePath)) return;
614      if (cfg.verbose) {
615        console.log('File change detected', _eventType, filePath);
616      }
617      if (fs.existsSync(filePath)) {
618        scanFile(filePath, filterFn);
619      }
620    });
621  }
622  walk(absDir, f => {
623    if (filterFn(f)) scanFile(f);
624  });
625}
626
627function exec(cmd, args, opts) {
628  opts = opts || {};
629  opts.stdout = opts.stdout || 'inherit';
630  if (cfg.verbose) console.log(`${cmd} ${args.join(' ')}\n`);
631  const spwOpts = {cwd: cfg.outDir, stdio: ['ignore', opts.stdout, 'inherit']};
632  const checkExitCode = (code, signal) => {
633    if (signal === 'SIGINT' || signal === 'SIGTERM') return;
634    if (code !== 0 && !opts.noErrCheck) {
635      console.error(`${cmd} ${args.join(' ')} failed with code ${code}`);
636      process.exit(1);
637    }
638  };
639  if (opts.async) {
640    const proc = child_process.spawn(cmd, args, spwOpts);
641    const procIndex = subprocesses.length;
642    subprocesses.push(proc);
643    return new Promise((resolve, _reject) => {
644      proc.on('exit', (code, signal) => {
645        delete subprocesses[procIndex];
646        checkExitCode(code, signal);
647        resolve();
648      });
649    });
650  } else {
651    const spawnRes = child_process.spawnSync(cmd, args, spwOpts);
652    checkExitCode(spawnRes.status, spawnRes.signal);
653    return spawnRes;
654  }
655}
656
657function execNode(module, args, opts) {
658  const modPath = pjoin(ROOT_DIR, 'ui/node_modules/.bin', module);
659  const nodeBin = pjoin(ROOT_DIR, 'tools/node');
660  args = [modPath].concat(args || []);
661  return exec(nodeBin, args, opts);
662}
663
664// ------------------------------------------
665// File system & subprocess utility functions
666// ------------------------------------------
667
668class Task {
669  constructor(func, args) {
670    this.func = func;
671    this.args = args || [];
672    // |identity| is used to dedupe identical tasks in the queue.
673    this.identity = JSON.stringify([this.func.name, this.args]);
674  }
675
676  get description() {
677    const ret = this.func.name.startsWith('exec') ? [] : [this.func.name];
678    const flattenedArgs = [].concat.apply([], this.args);
679    for (const arg of flattenedArgs) {
680      const argStr = `${arg}`;
681      if (argStr.startsWith('/')) {
682        ret.push(path.relative(cfg.outDir, arg));
683      } else {
684        ret.push(argStr);
685      }
686    }
687    return ret.join(' ');
688  }
689}
690
691function walk(dir, callback, skipRegex) {
692  for (const child of fs.readdirSync(dir)) {
693    const childPath = pjoin(dir, child);
694    const stat = fs.lstatSync(childPath);
695    if (skipRegex !== undefined && skipRegex.test(child)) continue;
696    if (stat.isDirectory()) {
697      walk(childPath, callback, skipRegex);
698    } else if (!stat.isSymbolicLink()) {
699      callback(childPath);
700    }
701  }
702}
703
704function ensureDir(dirPath, clean) {
705  const exists = fs.existsSync(dirPath);
706  if (exists && clean) {
707    console.log('rm', dirPath);
708    fs.rmSync(dirPath, {recursive: true});
709  }
710  if (!exists || clean) fs.mkdirSync(dirPath, {recursive: true});
711  return dirPath;
712}
713
714function cp(src, dst) {
715  ensureDir(path.dirname(dst));
716  if (cfg.verbose) {
717    console.log(
718        'cp', path.relative(ROOT_DIR, src), '->', path.relative(ROOT_DIR, dst));
719  }
720  fs.copyFileSync(src, dst);
721}
722
723function mklink(src, dst) {
724  // If the symlink already points to the right place don't touch it. This is
725  // to avoid changing the mtime of the ui/ dir when unnecessary.
726  if (fs.existsSync(dst)) {
727    if (fs.lstatSync(dst).isSymbolicLink() && fs.readlinkSync(dst) === src) {
728      return;
729    } else {
730      fs.unlinkSync(dst);
731    }
732  }
733  fs.symlinkSync(src, dst);
734}
735
736main();
737