1 |
|
2 |
|
3 |
|
4 |
|
5 | const fs = require('fs');
|
6 | const http = require('http');
|
7 | const path = require('path');
|
8 | const parseURL = require('url').parse;
|
9 | const {argv} = require('yargs');
|
10 |
|
11 | const {createInstrumenter} = require('istanbul-lib-instrument');
|
12 | const convertSourceMap = require('convert-source-map');
|
13 | const defaultIstanbulSchema = require('@istanbuljs/schema');
|
14 |
|
15 | const {getTestRunnerConfigSetting} = require('../test/test_config_helpers.js');
|
16 |
|
17 | const serverPort = parseInt(process.env.PORT, 10) || 8090;
|
18 | const target = argv.target || process.env.TARGET || 'Default';
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 | const sharedResourcesBase =
|
26 | argv.sharedResourcesBase || getTestRunnerConfigSetting('component-server-shared-resources-path', '/');
|
27 |
|
28 |
|
29 |
|
30 |
|
31 |
|
32 |
|
33 |
|
34 | const componentDocsBaseArg = argv.componentDocsBase || process.env.COMPONENT_DOCS_BASE ||
|
35 | getTestRunnerConfigSetting('component-server-base-path', '');
|
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 |
|
42 |
|
43 | const isRunningInGen = __dirname.includes(path.join('out', path.sep, target));
|
44 |
|
45 | let pathToOutTargetDir = __dirname;
|
46 |
|
47 |
|
48 |
|
49 |
|
50 |
|
51 |
|
52 | while (isRunningInGen && !pathToOutTargetDir.endsWith(`out${path.sep}${target}`)) {
|
53 | pathToOutTargetDir = path.resolve(pathToOutTargetDir, '..');
|
54 | }
|
55 |
|
56 |
|
57 | const pathToBuiltOutTargetDirectory =
|
58 | isRunningInGen ? pathToOutTargetDir : path.resolve(path.join(process.cwd(), 'out', target));
|
59 |
|
60 | const devtoolsRootFolder = path.resolve(path.join(pathToBuiltOutTargetDirectory, 'gen'));
|
61 | const componentDocsBaseFolder = path.join(devtoolsRootFolder, componentDocsBaseArg);
|
62 |
|
63 | if (!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 |
|
71 | const server = http.createServer(requestHandler);
|
72 | server.listen(serverPort);
|
73 | server.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 |
|
82 | server.once('error', error => {
|
83 | if (process.send) {
|
84 | process.send('ERROR');
|
85 | }
|
86 | throw error;
|
87 | });
|
88 |
|
89 | function createComponentIndexFile(componentPath, componentExamples) {
|
90 | const componentName = componentPath.replace('/front_end/ui/components/docs/', '').replace(/_/g, ' ').replace('/', '');
|
91 |
|
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 |
|
143 | }
|
144 |
|
145 | function createServerIndexFile(componentNames) {
|
146 |
|
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 |
|
175 | }
|
176 |
|
177 | async 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 |
|
187 | function 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 |
|
194 | function send404(response, message) {
|
195 | response.writeHead(404);
|
196 | response.write(message, 'utf8');
|
197 | response.end();
|
198 | }
|
199 |
|
200 | async 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 |
|
209 | const EXCLUDED_COVERAGE_FOLDERS = new Set(['third_party', 'ui/components/docs', 'Images']);
|
210 |
|
211 |
|
212 |
|
213 |
|
214 |
|
215 | function 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 |
|
225 | const COVERAGE_INSTRUMENTER = createInstrumenter({
|
226 | esModules: true,
|
227 | parserPlugins: [
|
228 | ...defaultIstanbulSchema.instrumenter.properties.parserPlugins.default,
|
229 | 'topLevelAwait',
|
230 | ],
|
231 | });
|
232 |
|
233 | const instrumentedSourceCacheForFilePaths = new Map();
|
234 |
|
235 | const SHOULD_GATHER_COVERAGE_INFORMATION = process.env.COVERAGE === '1';
|
236 |
|
237 |
|
238 |
|
239 |
|
240 |
|
241 | async 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 |
|
254 | return stats.isDirectory();
|
255 | }));
|
256 | respondWithHtml(response, html);
|
257 | } else if (filePath.startsWith('/front_end/ui/components/docs') && path.extname(filePath) === '') {
|
258 |
|
259 | const componentHtml = await getExamplesForPath(filePath);
|
260 | respondWithHtml(response, componentHtml);
|
261 | return;
|
262 | } else if (/ui\/components\/docs\/(.+)\/(.+)\.html/.test(filePath)) {
|
263 | |
264 |
|
265 |
|
266 |
|
267 |
|
268 |
|
269 | |
270 |
|
271 |
|
272 |
|
273 |
|
274 |
|
275 |
|
276 |
|
277 |
|
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 |
|
294 | let fullPath = path.join(componentDocsBaseFolder, filePath);
|
295 | if (fullPath.endsWith(path.join('locales', 'en-US.json')) &&
|
296 | !componentDocsBaseFolder.includes(sharedResourcesBase)) {
|
297 | |
298 |
|
299 |
|
300 |
|
301 |
|
302 |
|
303 |
|
304 |
|
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 | }
|