UNPKG

15.1 kBJavaScriptView Raw
1// Copyright 2021 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
5const fs = require('fs');
6const http = require('http');
7const path = require('path');
8const parseURL = require('url').parse;
9const {argv} = require('yargs');
10
11const {createInstrumenter} = require('istanbul-lib-instrument');
12const convertSourceMap = require('convert-source-map');
13const defaultIstanbulSchema = require('@istanbuljs/schema');
14
15const {getTestRunnerConfigSetting} = require('../test/test_config_helpers.js');
16
17const serverPort = parseInt(process.env.PORT, 10) || 8090;
18const target = argv.target || process.env.TARGET || 'Default';
19
20/**
21 * This configures the base of the URLs that are injected into each component
22 * doc example to load. By default it's /, so that we load /front_end/..., but
23 * this can be configured if you have a different file structure.
24 */
25const sharedResourcesBase =
26 argv.sharedResourcesBase || getTestRunnerConfigSetting('component-server-shared-resources-path', '/');
27
28/**
29 * The server assumes that examples live in
30 * devtoolsRoot/out/Target/gen/front_end/ui/components/docs, but if you need to add a
31 * prefix you can pass this argument. Passing `foo` will redirect the server to
32 * look in devtoolsRoot/out/Target/gen/foo/front_end/ui/components/docs.
33 */
34const componentDocsBaseArg = argv.componentDocsBase || process.env.COMPONENT_DOCS_BASE ||
35 getTestRunnerConfigSetting('component-server-base-path', '');
36
37/**
38 * When you run npm run components-server we run the script as is from scripts/,
39 * but when this server is run as part of a test suite it's run from
40 * out/Default/gen/scripts, so we have to do a bit of path mangling to figure
41 * out where we are.
42 */
43const isRunningInGen = __dirname.includes(path.join('out', path.sep, target));
44
45let pathToOutTargetDir = __dirname;
46/**
47 * If we are in the gen directory, we need to find the out/Default folder to use
48 * as our base to find files from. We could do this with path.join(x, '..',
49 * '..') until we get the right folder, but that's brittle. It's better to
50 * search up for out/Default to be robust to any folder structures.
51 */
52while (isRunningInGen && !pathToOutTargetDir.endsWith(`out${path.sep}${target}`)) {
53 pathToOutTargetDir = path.resolve(pathToOutTargetDir, '..');
54}
55
56/* 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 */
57const pathToBuiltOutTargetDirectory =
58 isRunningInGen ? pathToOutTargetDir : path.resolve(path.join(process.cwd(), 'out', target));
59
60const devtoolsRootFolder = path.resolve(path.join(pathToBuiltOutTargetDirectory, 'gen'));
61const componentDocsBaseFolder = path.join(devtoolsRootFolder, componentDocsBaseArg);
62
63if (!fs.existsSync(devtoolsRootFolder)) {
64 console.error(`ERROR: Generated front_end folder (${devtoolsRootFolder}) does not exist.`);
65 console.log(
66 'The components server works from the built Ninja output; you may need to run Ninja to update your built DevTools.');
67 console.log('If you build to a target other than default, you need to pass --target=X as an argument');
68 process.exit(1);
69}
70
71const server = http.createServer(requestHandler);
72server.listen(serverPort);
73server.once('listening', () => {
74 if (process.send) {
75 process.send(serverPort);
76 }
77 console.log(`Started components server at http://localhost:${serverPort}\n`);
78 console.log(`ui/components/docs location: ${
79 path.relative(process.cwd(), path.join(componentDocsBaseFolder, 'front_end', 'ui', 'components', 'docs'))}`);
80});
81
82server.once('error', error => {
83 if (process.send) {
84 process.send('ERROR');
85 }
86 throw error;
87});
88
89function createComponentIndexFile(componentPath, componentExamples) {
90 const componentName = componentPath.replace('/front_end/ui/components/docs/', '').replace(/_/g, ' ').replace('/', '');
91 // clang-format off
92 return `<!DOCTYPE html>
93 <html>
94 <head>
95 <meta charset="UTF-8" />
96 <meta name="viewport" content="width=device-width" />
97 <title>DevTools component: ${componentName}</title>
98 <style>
99 h1 { text-transform: capitalize; }
100
101 .example {
102 padding: 5px;
103 margin: 10px;
104 }
105
106 a:link,
107 a:visited {
108 color: blue;
109 text-decoration: underline;
110 }
111
112 a:hover {
113 text-decoration: none;
114 }
115 .example summary {
116 font-size: 20px;
117 }
118
119 .back-link {
120 padding-left: 5px;
121 font-size: 16px;
122 font-style: italic;
123 }
124
125 iframe { display: block; width: 100%; height: 400px; }
126 </style>
127 </head>
128 <body>
129 <h1>
130 ${componentName}
131 <a class="back-link" href="/">Back to index</a>
132 </h1>
133 ${componentExamples.map(example => {
134 const fullPath = path.join(componentPath, example);
135 return `<details class="example">
136 <summary><a href="${fullPath}">${example.replace('.html', '').replace(/-|_/g, ' ')}</a></summary>
137 <iframe src="${fullPath}"></iframe>
138 </details>`;
139 }).join('\n')}
140 </body>
141 </html>`;
142 // clang-format on
143}
144
145function createServerIndexFile(componentNames) {
146 // clang-format off
147 return `<!DOCTYPE html>
148 <html>
149 <head>
150 <meta charset="UTF-8" />
151 <meta name="viewport" content="width=device-width" />
152 <title>DevTools components</title>
153 <style>
154 a:link, a:visited {
155 color: blue;
156 text-transform: capitalize;
157 text-decoration: none;
158 }
159 a:hover {
160 text-decoration: underline;
161 }
162 </style>
163 </head>
164 <body>
165 <h1>DevTools components</h1>
166 <ul>
167 ${componentNames.map(name => {
168 const niceName = name.replace(/_/g, ' ');
169 return `<li><a href='/front_end/ui/components/docs/${name}'>${niceName}</a></li>`;
170 }).join('\n')}
171 </ul>
172 </body>
173 </html>`;
174 // clang-format on
175}
176
177async function getExamplesForPath(filePath) {
178 const componentDirectory = path.join(componentDocsBaseFolder, filePath);
179 const allFiles = await fs.promises.readdir(componentDirectory);
180 const htmlExampleFiles = allFiles.filter(file => {
181 return path.extname(file) === '.html';
182 });
183
184 return createComponentIndexFile(filePath, htmlExampleFiles);
185}
186
187function respondWithHtml(response, html) {
188 response.setHeader('Content-Type', 'text/html; charset=utf-8');
189 response.writeHead(200);
190 response.write(html, 'utf8');
191 response.end();
192}
193
194function send404(response, message) {
195 response.writeHead(404);
196 response.write(message, 'utf8');
197 response.end();
198}
199
200async function checkFileExists(filePath) {
201 try {
202 const errorsAccessingFile = await fs.promises.access(filePath, fs.constants.R_OK);
203 return !errorsAccessingFile;
204 } catch (e) {
205 return false;
206 }
207}
208
209const EXCLUDED_COVERAGE_FOLDERS = new Set(['third_party', 'ui/components/docs', 'Images']);
210
211/**
212 * @param {string} filePath
213 * @returns {boolean}
214 */
215function isIncludedForCoverageComputation(filePath) {
216 for (const excludedFolder of EXCLUDED_COVERAGE_FOLDERS) {
217 if (filePath.startsWith(`/front_end/${excludedFolder}/`)) {
218 return false;
219 }
220 }
221
222 return true;
223}
224
225const COVERAGE_INSTRUMENTER = createInstrumenter({
226 esModules: true,
227 parserPlugins: [
228 ...defaultIstanbulSchema.instrumenter.properties.parserPlugins.default,
229 'topLevelAwait',
230 ],
231});
232
233const instrumentedSourceCacheForFilePaths = new Map();
234
235const SHOULD_GATHER_COVERAGE_INFORMATION = process.env.COVERAGE === '1';
236
237/**
238 * @param {http.IncomingMessage} request
239 * @param {http.ServerResponse} response
240 */
241async function requestHandler(request, response) {
242 const filePath = parseURL(request.url).pathname;
243 if (filePath === '/favicon.ico') {
244 send404(response, '404, no favicon');
245 return;
246 }
247
248 if (filePath === '/' || filePath === '/index.html') {
249 const components =
250 await fs.promises.readdir(path.join(componentDocsBaseFolder, 'front_end', 'ui', 'components', 'docs'));
251 const html = createServerIndexFile(components.filter(filePath => {
252 const stats = fs.lstatSync(path.join(componentDocsBaseFolder, 'front_end', 'ui', 'components', 'docs', filePath));
253 // Filter out some build config files (tsconfig, d.ts, etc), and just list the directories.
254 return stats.isDirectory();
255 }));
256 respondWithHtml(response, html);
257 } else if (filePath.startsWith('/front_end/ui/components/docs') && path.extname(filePath) === '') {
258 // This means it's a component path like /breadcrumbs.
259 const componentHtml = await getExamplesForPath(filePath);
260 respondWithHtml(response, componentHtml);
261 return;
262 } else if (/ui\/components\/docs\/(.+)\/(.+)\.html/.test(filePath)) {
263 /** This conditional checks if we are viewing an individual example's HTML
264 * file. e.g. localhost:8090/front_end/ui/components/docs/data_grid/basic.html For each
265 * example we inject themeColors.css into the page so all CSS variables
266 * that components use are available.
267 */
268
269 /**
270 * We also let the user provide a different base path for any shared
271 * resources that we load. But if this is provided along with the
272 * componentDocsBaseArg, and the two are the same, we don't want to use the
273 * shared resources base, as it's part of the componentDocsBaseArg and
274 * therefore the URL is already correct.
275 *
276 * If we didn't get a componentDocsBaseArg or we did and it's different to
277 * the sharedResourcesBase, we use sharedResourcesBase.
278 */
279 const baseUrlForSharedResource =
280 componentDocsBaseArg && componentDocsBaseArg.endsWith(sharedResourcesBase) ? '/' : `/${sharedResourcesBase}`;
281 const fileContents = await fs.promises.readFile(path.join(componentDocsBaseFolder, filePath), {encoding: 'utf8'});
282 const themeColoursLink = `<link rel="stylesheet" href="${
283 path.join(baseUrlForSharedResource, 'front_end', 'ui', 'legacy', 'themeColors.css')}" type="text/css" />`;
284 const inspectorCommonLink = `<link rel="stylesheet" href="${
285 path.join(baseUrlForSharedResource, 'front_end', 'ui', 'legacy', 'inspectorCommon.css')}" type="text/css" />`;
286 const toggleDarkModeScript = `<script type="module" src="${
287 path.join(baseUrlForSharedResource, 'front_end', 'ui', 'components', 'docs', 'component_docs.js')}"></script>`;
288 const newFileContents = fileContents.replace('</head>', `${themeColoursLink}\n${inspectorCommonLink}\n</head>`)
289 .replace('</body>', toggleDarkModeScript + '\n</body>');
290 respondWithHtml(response, newFileContents);
291
292 } else {
293 // This means it's an asset like a JS file or an image.
294 let fullPath = path.join(componentDocsBaseFolder, filePath);
295 if (fullPath.endsWith(path.join('locales', 'en-US.json')) &&
296 !componentDocsBaseFolder.includes(sharedResourcesBase)) {
297 /**
298 * If the path is for locales/en-US.json we special case the loading of that to fix the path so it works properly in the server.
299 * We also make sure that we take into account the shared resources base;
300 * but if the base folder already contains the shared resources base, we don't
301 * add it to the path, because otherwise that would cause the shared resources
302 * base to be duplicated in the fullPath.
303 */
304 // Rewrite this path so we can load up the locale in the component-docs
305 let prefix = componentDocsBaseFolder;
306 if (sharedResourcesBase && !componentDocsBaseFolder.includes(sharedResourcesBase)) {
307 prefix = path.join(componentDocsBaseFolder, sharedResourcesBase);
308 }
309 fullPath = path.join(prefix, 'front_end', 'core', 'i18n', 'locales', 'en-US.json');
310 }
311
312 if (!fullPath.startsWith(devtoolsRootFolder) && !fileIsInTestFolder) {
313 console.error(`Path ${fullPath} is outside the DevTools Frontend root dir.`);
314 process.exit(1);
315 }
316
317 const fileExists = await checkFileExists(fullPath);
318
319 if (!fileExists) {
320 send404(response, '404, File not found');
321 return;
322 }
323
324 let encoding = 'utf8';
325 if (fullPath.endsWith('.js')) {
326 response.setHeader('Content-Type', 'text/javascript; charset=utf-8');
327 } else if (fullPath.endsWith('.css')) {
328 response.setHeader('Content-Type', 'text/css; charset=utf-8');
329 } else if (fullPath.endsWith('.wasm')) {
330 response.setHeader('Content-Type', 'application/wasm');
331 encoding = 'binary';
332 } else if (fullPath.endsWith('.svg')) {
333 response.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
334 } else if (fullPath.endsWith('.png')) {
335 response.setHeader('Content-Type', 'image/png');
336 encoding = 'binary';
337 } else if (fullPath.endsWith('.jpg')) {
338 response.setHeader('Content-Type', 'image/jpg');
339 encoding = 'binary';
340 } else if (fullPath.endsWith('.avif')) {
341 response.setHeader('Content-Type', 'image/avif');
342 encoding = 'binary';
343 } else if (fullPath.endsWith('.gz')) {
344 response.setHeader('Content-Type', 'application/gzip');
345 encoding = 'binary';
346 }
347
348 let fileContents = await fs.promises.readFile(fullPath, encoding);
349 const isComputingCoverageRequest = request.headers['devtools-compute-coverage'] === '1';
350
351 if (SHOULD_GATHER_COVERAGE_INFORMATION && fullPath.endsWith('.js') && filePath.startsWith('/front_end/') &&
352 isIncludedForCoverageComputation(filePath)) {
353 const previouslyGeneratedInstrumentedSource = instrumentedSourceCacheForFilePaths.get(fullPath);
354
355 if (previouslyGeneratedInstrumentedSource) {
356 fileContents = previouslyGeneratedInstrumentedSource;
357 } else {
358 if (!isComputingCoverageRequest) {
359 response.writeHead(400);
360 response.write(`Invalid coverage request. Attempted to load ${request.url}.`, 'utf8');
361 response.end();
362
363 console.error(
364 `Invalid coverage request. Attempted to load ${request.url} which was not available in the ` +
365 'code coverage instrumentation cache. Make sure that you call `preloadForCodeCoverage` in the describe block ' +
366 'of your interactions test, before declaring any tests.\n');
367 return;
368 }
369
370 fileContents = await new Promise(async (resolve, reject) => {
371 let sourceMap = convertSourceMap.fromSource(fileContents);
372 if (!sourceMap) {
373 sourceMap = convertSourceMap.fromMapFileSource(fileContents, path.dirname(fullPath));
374 }
375
376 COVERAGE_INSTRUMENTER.instrument(fileContents, fullPath, (error, instrumentedSource) => {
377 if (error) {
378 reject(error);
379 } else {
380 resolve(instrumentedSource);
381 }
382 }, sourceMap ? sourceMap.sourcemap : undefined);
383 });
384
385 instrumentedSourceCacheForFilePaths.set(fullPath, fileContents);
386 }
387 }
388
389 response.writeHead(200);
390 response.write(fileContents, encoding);
391 response.end();
392 }
393}