// Copyright 2021 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. const fs = require('fs'); const http = require('http'); const path = require('path'); const parseURL = require('url').parse; const {argv} = require('yargs'); const {createInstrumenter} = require('istanbul-lib-instrument'); constĀ convertSourceMap = require('convert-source-map'); const defaultIstanbulSchema = require('@istanbuljs/schema'); const {getTestRunnerConfigSetting} = require('../test/test_config_helpers.js'); const serverPort = parseInt(process.env.PORT, 10) || 8090; const target = argv.target || process.env.TARGET || 'Default'; /** * This configures the base of the URLs that are injected into each component * doc example to load. By default it's /, so that we load /front_end/..., but * this can be configured if you have a different file structure. */ const sharedResourcesBase = argv.sharedResourcesBase || getTestRunnerConfigSetting('component-server-shared-resources-path', '/'); /** * The server assumes that examples live in * devtoolsRoot/out/Target/gen/front_end/ui/components/docs, but if you need to add a * prefix you can pass this argument. Passing `foo` will redirect the server to * look in devtoolsRoot/out/Target/gen/foo/front_end/ui/components/docs. */ const componentDocsBaseArg = argv.componentDocsBase || process.env.COMPONENT_DOCS_BASE || getTestRunnerConfigSetting('component-server-base-path', ''); /** * When you run npm run components-server we run the script as is from scripts/, * but when this server is run as part of a test suite it's run from * out/Default/gen/scripts, so we have to do a bit of path mangling to figure * out where we are. */ const isRunningInGen = __dirname.includes(path.join('out', path.sep, target)); let pathToOutTargetDir = __dirname; /** * If we are in the gen directory, we need to find the out/Default folder to use * as our base to find files from. We could do this with path.join(x, '..', * '..') until we get the right folder, but that's brittle. It's better to * search up for out/Default to be robust to any folder structures. */ while (isRunningInGen && !pathToOutTargetDir.endsWith(`out${path.sep}${target}`)) { pathToOutTargetDir = path.resolve(pathToOutTargetDir, '..'); } /* If we are not running in out/Default, we'll assume the script is running from the repo root, and navigate to {CWD}/out/Target */ const pathToBuiltOutTargetDirectory = isRunningInGen ? pathToOutTargetDir : path.resolve(path.join(process.cwd(), 'out', target)); const devtoolsRootFolder = path.resolve(path.join(pathToBuiltOutTargetDirectory, 'gen')); const componentDocsBaseFolder = path.join(devtoolsRootFolder, componentDocsBaseArg); if (!fs.existsSync(devtoolsRootFolder)) { console.error(`ERROR: Generated front_end folder (${devtoolsRootFolder}) does not exist.`); console.log( 'The components server works from the built Ninja output; you may need to run Ninja to update your built DevTools.'); console.log('If you build to a target other than default, you need to pass --target=X as an argument'); process.exit(1); } const server = http.createServer(requestHandler); server.listen(serverPort); server.once('listening', () => { if (process.send) { process.send(serverPort); } console.log(`Started components server at http://localhost:${serverPort}\n`); console.log(`ui/components/docs location: ${ path.relative(process.cwd(), path.join(componentDocsBaseFolder, 'front_end', 'ui', 'components', 'docs'))}`); }); server.once('error', error => { if (process.send) { process.send('ERROR'); } throw error; }); function createComponentIndexFile(componentPath, componentExamples) { const componentName = componentPath.replace('/front_end/ui/components/docs/', '').replace(/_/g, ' ').replace('/', ''); // clang-format off return ` DevTools component: ${componentName}

${componentName} Back to index

${componentExamples.map(example => { const fullPath = path.join(componentPath, example); return `
${example.replace('.html', '').replace(/-|_/g, ' ')}
`; }).join('\n')} `; // clang-format on } function createServerIndexFile(componentNames) { // clang-format off return ` DevTools components

DevTools components

`; // clang-format on } async function getExamplesForPath(filePath) { const componentDirectory = path.join(componentDocsBaseFolder, filePath); const allFiles = await fs.promises.readdir(componentDirectory); const htmlExampleFiles = allFiles.filter(file => { return path.extname(file) === '.html'; }); return createComponentIndexFile(filePath, htmlExampleFiles); } function respondWithHtml(response, html) { response.setHeader('Content-Type', 'text/html; charset=utf-8'); response.writeHead(200); response.write(html, 'utf8'); response.end(); } function send404(response, message) { response.writeHead(404); response.write(message, 'utf8'); response.end(); } async function checkFileExists(filePath) { try { const errorsAccessingFile = await fs.promises.access(filePath, fs.constants.R_OK); return !errorsAccessingFile; } catch (e) { return false; } } const EXCLUDED_COVERAGE_FOLDERS = new Set(['third_party', 'ui/components/docs', 'Images']); /** * @param {string} filePath * @returns {boolean} */ function isIncludedForCoverageComputation(filePath) { for (const excludedFolder of EXCLUDED_COVERAGE_FOLDERS) { if (filePath.startsWith(`/front_end/${excludedFolder}/`)) { return false; } } return true; } const COVERAGE_INSTRUMENTER = createInstrumenter({ esModules: true, parserPlugins: [ ...defaultIstanbulSchema.instrumenter.properties.parserPlugins.default, 'topLevelAwait', ], }); const instrumentedSourceCacheForFilePaths = new Map(); const SHOULD_GATHER_COVERAGE_INFORMATION = process.env.COVERAGE === '1'; /** * @param {http.IncomingMessage} request * @param {http.ServerResponse} response */ async function requestHandler(request, response) { const filePath = parseURL(request.url).pathname; if (filePath === '/favicon.ico') { send404(response, '404, no favicon'); return; } if (filePath === '/' || filePath === '/index.html') { const components = await fs.promises.readdir(path.join(componentDocsBaseFolder, 'front_end', 'ui', 'components', 'docs')); const html = createServerIndexFile(components.filter(filePath => { const stats = fs.lstatSync(path.join(componentDocsBaseFolder, 'front_end', 'ui', 'components', 'docs', filePath)); // Filter out some build config files (tsconfig, d.ts, etc), and just list the directories. return stats.isDirectory(); })); respondWithHtml(response, html); } else if (filePath.startsWith('/front_end/ui/components/docs') && path.extname(filePath) === '') { // This means it's a component path like /breadcrumbs. const componentHtml = await getExamplesForPath(filePath); respondWithHtml(response, componentHtml); return; } else if (/ui\/components\/docs\/(.+)\/(.+)\.html/.test(filePath)) { /** This conditional checks if we are viewing an individual example's HTML * file. e.g. localhost:8090/front_end/ui/components/docs/data_grid/basic.html For each * example we inject themeColors.css into the page so all CSS variables * that components use are available. */ /** * We also let the user provide a different base path for any shared * resources that we load. But if this is provided along with the * componentDocsBaseArg, and the two are the same, we don't want to use the * shared resources base, as it's part of the componentDocsBaseArg and * therefore the URL is already correct. * * If we didn't get a componentDocsBaseArg or we did and it's different to * the sharedResourcesBase, we use sharedResourcesBase. */ const baseUrlForSharedResource = componentDocsBaseArg && componentDocsBaseArg.endsWith(sharedResourcesBase) ? '/' : `/${sharedResourcesBase}`; const fileContents = await fs.promises.readFile(path.join(componentDocsBaseFolder, filePath), {encoding: 'utf8'}); const themeColoursLink = ``; const inspectorCommonLink = ``; const toggleDarkModeScript = ``; const newFileContents = fileContents.replace('', `${themeColoursLink}\n${inspectorCommonLink}\n`) .replace('', toggleDarkModeScript + '\n'); respondWithHtml(response, newFileContents); } else { // This means it's an asset like a JS file or an image. let fullPath = path.join(componentDocsBaseFolder, filePath); if (fullPath.endsWith(path.join('locales', 'en-US.json'))) { // Rewrite this path so we can load up the locale in the component-docs fullPath = path.join(componentDocsBaseFolder, 'front_end', 'core', 'i18n', 'locales', 'en-US.json'); } if (!fullPath.startsWith(devtoolsRootFolder) && !fileIsInTestFolder) { console.error(`Path ${fullPath} is outside the DevTools Frontend root dir.`); process.exit(1); } const fileExists = await checkFileExists(fullPath); if (!fileExists) { send404(response, '404, File not found'); return; } let encoding = 'utf8'; if (fullPath.endsWith('.js')) { response.setHeader('Content-Type', 'text/javascript; charset=utf-8'); } else if (fullPath.endsWith('.css')) { response.setHeader('Content-Type', 'text/css; charset=utf-8'); } else if (fullPath.endsWith('.wasm')) { response.setHeader('Content-Type', 'application/wasm'); encoding = 'binary'; } else if (fullPath.endsWith('.svg')) { response.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); } else if (fullPath.endsWith('.png')) { response.setHeader('Content-Type', 'image/png'); encoding = 'binary'; } else if (fullPath.endsWith('.jpg')) { response.setHeader('Content-Type', 'image/jpg'); encoding = 'binary'; } else if (fullPath.endsWith('.avif')) { response.setHeader('Content-Type', 'image/avif'); encoding = 'binary'; } else if (fullPath.endsWith('.gz')) { response.setHeader('Content-Type', 'application/gzip'); encoding = 'binary'; } let fileContents = await fs.promises.readFile(fullPath, encoding); const isComputingCoverageRequest = request.headers['devtools-compute-coverage'] === '1'; if (SHOULD_GATHER_COVERAGE_INFORMATION && fullPath.endsWith('.js') && filePath.startsWith('/front_end/') && isIncludedForCoverageComputation(filePath)) { const previouslyGeneratedInstrumentedSource = instrumentedSourceCacheForFilePaths.get(fullPath); if (previouslyGeneratedInstrumentedSource) { fileContents = previouslyGeneratedInstrumentedSource; } else { if (!isComputingCoverageRequest) { response.writeHead(400); response.write(`Invalid coverage request. Attempted to load ${request.url}.`, 'utf8'); response.end(); console.error( `Invalid coverage request. Attempted to load ${request.url} which was not available in the ` + 'code coverage instrumentation cache. Make sure that you call `preloadForCodeCoverage` in the describe block ' + 'of your interactions test, before declaring any tests.\n'); return; } fileContents = await new Promise(async (resolve, reject) => { let sourceMap = convertSourceMap.fromSource(fileContents); if (!sourceMap) { sourceMap = convertSourceMap.fromMapFileSource(fileContents, path.dirname(fullPath)); } COVERAGE_INSTRUMENTER.instrument(fileContents, fullPath, (error, instrumentedSource) => { if (error) { reject(error); } else { resolve(instrumentedSource); } }, sourceMap ? sourceMap.sourcemap : undefined); }); instrumentedSourceCacheForFilePaths.set(fullPath, fileContents); } } response.writeHead(200); response.write(fileContents, encoding); response.end(); } }