diff options
Diffstat (limited to 'remote/test/puppeteer/utils')
23 files changed, 4127 insertions, 0 deletions
diff --git a/remote/test/puppeteer/utils/ESTreeWalker.js b/remote/test/puppeteer/utils/ESTreeWalker.js new file mode 100644 index 0000000000..1c6c6d4782 --- /dev/null +++ b/remote/test/puppeteer/utils/ESTreeWalker.js @@ -0,0 +1,135 @@ +// Copyright (c) 2014 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. + +/** + * @unrestricted + */ +class ESTreeWalker { + /** + * @param {function(!ESTree.Node):(!Object|undefined)} beforeVisit + * @param {function(!ESTree.Node)=} afterVisit + */ + constructor(beforeVisit, afterVisit) { + this._beforeVisit = beforeVisit; + this._afterVisit = afterVisit || new Function(); + } + + /** + * @param {!ESTree.Node} ast + */ + walk(ast) { + this._innerWalk(ast, null); + } + + /** + * @param {!ESTree.Node} node + * @param {?ESTree.Node} parent + */ + _innerWalk(node, parent) { + if (!node) return; + node.parent = parent; + + if (this._beforeVisit.call(null, node) === ESTreeWalker.SkipSubtree) { + this._afterVisit.call(null, node); + return; + } + + const walkOrder = ESTreeWalker._walkOrder[node.type]; + if (!walkOrder) return; + + if (node.type === 'TemplateLiteral') { + const templateLiteral = /** @type {!ESTree.TemplateLiteralNode} */ (node); + const expressionsLength = templateLiteral.expressions.length; + for (let i = 0; i < expressionsLength; ++i) { + this._innerWalk(templateLiteral.quasis[i], templateLiteral); + this._innerWalk(templateLiteral.expressions[i], templateLiteral); + } + this._innerWalk( + templateLiteral.quasis[expressionsLength], + templateLiteral + ); + } else { + for (let i = 0; i < walkOrder.length; ++i) { + const entity = node[walkOrder[i]]; + if (Array.isArray(entity)) this._walkArray(entity, node); + else this._innerWalk(entity, node); + } + } + + this._afterVisit.call(null, node); + } + + /** + * @param {!Array.<!ESTree.Node>} nodeArray + * @param {?ESTree.Node} parentNode + */ + _walkArray(nodeArray, parentNode) { + for (let i = 0; i < nodeArray.length; ++i) + this._innerWalk(nodeArray[i], parentNode); + } +} + +/** @typedef {!Object} ESTreeWalker.SkipSubtree */ +ESTreeWalker.SkipSubtree = {}; + +/** @enum {!Array.<string>} */ +ESTreeWalker._walkOrder = { + AwaitExpression: ['argument'], + ArrayExpression: ['elements'], + ArrowFunctionExpression: ['params', 'body'], + AssignmentExpression: ['left', 'right'], + AssignmentPattern: ['left', 'right'], + BinaryExpression: ['left', 'right'], + BlockStatement: ['body'], + BreakStatement: ['label'], + CallExpression: ['callee', 'arguments'], + CatchClause: ['param', 'body'], + ClassBody: ['body'], + ClassDeclaration: ['id', 'superClass', 'body'], + ClassExpression: ['id', 'superClass', 'body'], + ConditionalExpression: ['test', 'consequent', 'alternate'], + ContinueStatement: ['label'], + DebuggerStatement: [], + DoWhileStatement: ['body', 'test'], + EmptyStatement: [], + ExpressionStatement: ['expression'], + ForInStatement: ['left', 'right', 'body'], + ForOfStatement: ['left', 'right', 'body'], + ForStatement: ['init', 'test', 'update', 'body'], + FunctionDeclaration: ['id', 'params', 'body'], + FunctionExpression: ['id', 'params', 'body'], + Identifier: [], + IfStatement: ['test', 'consequent', 'alternate'], + LabeledStatement: ['label', 'body'], + Literal: [], + LogicalExpression: ['left', 'right'], + MemberExpression: ['object', 'property'], + MethodDefinition: ['key', 'value'], + NewExpression: ['callee', 'arguments'], + ObjectExpression: ['properties'], + ObjectPattern: ['properties'], + ParenthesizedExpression: ['expression'], + Program: ['body'], + Property: ['key', 'value'], + ReturnStatement: ['argument'], + SequenceExpression: ['expressions'], + Super: [], + SwitchCase: ['test', 'consequent'], + SwitchStatement: ['discriminant', 'cases'], + TaggedTemplateExpression: ['tag', 'quasi'], + TemplateElement: [], + TemplateLiteral: ['quasis', 'expressions'], + ThisExpression: [], + ThrowStatement: ['argument'], + TryStatement: ['block', 'handler', 'finalizer'], + UnaryExpression: ['argument'], + UpdateExpression: ['argument'], + VariableDeclaration: ['declarations'], + VariableDeclarator: ['id', 'init'], + WhileStatement: ['test', 'body'], + WithStatement: ['object', 'body'], + YieldExpression: ['argument'], +}; + +module.exports = ESTreeWalker; diff --git a/remote/test/puppeteer/utils/apply_next_version.js b/remote/test/puppeteer/utils/apply_next_version.js new file mode 100644 index 0000000000..cd62839d2a --- /dev/null +++ b/remote/test/puppeteer/utils/apply_next_version.js @@ -0,0 +1,32 @@ +const path = require('path'); +const fs = require('fs'); +const execSync = require('child_process').execSync; + +// Compare current HEAD to upstream main SHA. +// If they are not equal - refuse to publish since +// we're not tip-of-tree. +const upstream_sha = execSync( + `git ls-remote https://github.com/puppeteer/puppeteer --tags main | cut -f1` +).toString('utf8'); +const current_sha = execSync(`git rev-parse HEAD`).toString('utf8'); +if (upstream_sha.trim() !== current_sha.trim()) { + console.log('REFUSING TO PUBLISH: this is not tip-of-tree!'); + process.exit(1); + return; +} + +const package = require('../package.json'); +let version = package.version; +const dashIndex = version.indexOf('-'); +if (dashIndex !== -1) version = version.substring(0, dashIndex); +version += '-next.' + Date.now(); +console.log('Setting version to ' + version); +package.version = version; +fs.writeFileSync( + path.join(__dirname, '..', 'package.json'), + JSON.stringify(package, undefined, 2) + '\n' +); + +console.log( + 'IMPORTANT: you should update the pinned version of devtools-protocol to match the new revision.' +); diff --git a/remote/test/puppeteer/utils/bisect.js b/remote/test/puppeteer/utils/bisect.js new file mode 100755 index 0000000000..d81fdec8f5 --- /dev/null +++ b/remote/test/puppeteer/utils/bisect.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const URL = require('url'); +const debug = require('debug'); +const pptr = require('..'); +const browserFetcher = pptr.createBrowserFetcher(); +const path = require('path'); +const fs = require('fs'); +const { fork } = require('child_process'); + +const COLOR_RESET = '\x1b[0m'; +const COLOR_RED = '\x1b[31m'; +const COLOR_GREEN = '\x1b[32m'; +const COLOR_YELLOW = '\x1b[33m'; + +const argv = require('minimist')(process.argv.slice(2), {}); + +const help = ` +Usage: + node bisect.js --good <revision> --bad <revision> <script> + +Parameters: + --good revision that is known to be GOOD + --bad revision that is known to be BAD + <script> path to the script that returns non-zero code for BAD revisions and 0 for good + +Example: + node utils/bisect.js --good 577361 --bad 599821 simple.js +`; + +if (argv.h || argv.help) { + console.log(help); + process.exit(0); +} + +if (typeof argv.good !== 'number') { + console.log( + COLOR_RED + 'ERROR: expected --good argument to be a number' + COLOR_RESET + ); + console.log(help); + process.exit(1); +} + +if (typeof argv.bad !== 'number') { + console.log( + COLOR_RED + 'ERROR: expected --bad argument to be a number' + COLOR_RESET + ); + console.log(help); + process.exit(1); +} + +const scriptPath = path.resolve(argv._[0]); +if (!fs.existsSync(scriptPath)) { + console.log( + COLOR_RED + + 'ERROR: Expected to be given a path to a script to run' + + COLOR_RESET + ); + console.log(help); + process.exit(1); +} + +(async (scriptPath, good, bad) => { + const span = Math.abs(good - bad); + console.log( + `Bisecting ${COLOR_YELLOW}${span}${COLOR_RESET} revisions in ${COLOR_YELLOW}~${ + span.toString(2).length + }${COLOR_RESET} iterations` + ); + + while (true) { + const middle = Math.round((good + bad) / 2); + const revision = await findDownloadableRevision(middle, good, bad); + if (!revision || revision === good || revision === bad) break; + let info = browserFetcher.revisionInfo(revision); + const shouldRemove = !info.local; + info = await downloadRevision(revision); + const exitCode = await runScript(scriptPath, info); + if (shouldRemove) await browserFetcher.remove(revision); + let outcome; + if (exitCode) { + bad = revision; + outcome = COLOR_RED + 'BAD' + COLOR_RESET; + } else { + good = revision; + outcome = COLOR_GREEN + 'GOOD' + COLOR_RESET; + } + const span = Math.abs(good - bad); + let fromText = ''; + let toText = ''; + if (good < bad) { + fromText = COLOR_GREEN + good + COLOR_RESET; + toText = COLOR_RED + bad + COLOR_RESET; + } else { + fromText = COLOR_RED + bad + COLOR_RESET; + toText = COLOR_GREEN + good + COLOR_RESET; + } + console.log( + `- ${COLOR_YELLOW}r${revision}${COLOR_RESET} was ${outcome}. Bisecting [${fromText}, ${toText}] - ${COLOR_YELLOW}${span}${COLOR_RESET} revisions and ${COLOR_YELLOW}~${ + span.toString(2).length + }${COLOR_RESET} iterations` + ); + } + + const [fromSha, toSha] = await Promise.all([ + revisionToSha(Math.min(good, bad)), + revisionToSha(Math.max(good, bad)), + ]); + console.log( + `RANGE: https://chromium.googlesource.com/chromium/src/+log/${fromSha}..${toSha}` + ); +})(scriptPath, argv.good, argv.bad); + +function runScript(scriptPath, revisionInfo) { + const log = debug('bisect:runscript'); + log('Running script'); + const child = fork(scriptPath, [], { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env: { + ...process.env, + PUPPETEER_EXECUTABLE_PATH: revisionInfo.executablePath, + }, + }); + return new Promise((resolve, reject) => { + child.on('error', (err) => reject(err)); + child.on('exit', (code) => resolve(code)); + }); +} + +async function downloadRevision(revision) { + const log = debug('bisect:download'); + log(`Downloading ${revision}`); + let progressBar = null; + let lastDownloadedBytes = 0; + return await browserFetcher.download( + revision, + (downloadedBytes, totalBytes) => { + if (!progressBar) { + const ProgressBar = require('progress'); + progressBar = new ProgressBar( + `- downloading Chromium r${revision} - ${toMegabytes( + totalBytes + )} [:bar] :percent :etas `, + { + complete: '=', + incomplete: ' ', + width: 20, + total: totalBytes, + } + ); + } + const delta = downloadedBytes - lastDownloadedBytes; + lastDownloadedBytes = downloadedBytes; + progressBar.tick(delta); + } + ); + function toMegabytes(bytes) { + const mb = bytes / 1024 / 1024; + return `${Math.round(mb * 10) / 10} Mb`; + } +} + +async function findDownloadableRevision(rev, from, to) { + const log = debug('bisect:findrev'); + const min = Math.min(from, to); + const max = Math.max(from, to); + log(`Looking around ${rev} from [${min}, ${max}]`); + if (await browserFetcher.canDownload(rev)) return rev; + let down = rev; + let up = rev; + while (min <= down || up <= max) { + const [downOk, upOk] = await Promise.all([ + down > min ? probe(--down) : Promise.resolve(false), + up < max ? probe(++up) : Promise.resolve(false), + ]); + if (downOk) return down; + if (upOk) return up; + } + return null; + + async function probe(rev) { + const result = await browserFetcher.canDownload(rev); + log(` ${rev} - ${result ? 'OK' : 'missing'}`); + return result; + } +} + +async function revisionToSha(revision) { + const json = await fetchJSON( + 'https://cr-rev.appspot.com/_ah/api/crrev/v1/redirect/' + revision + ); + return json.git_sha; +} + +function fetchJSON(url) { + return new Promise((resolve, reject) => { + const agent = url.startsWith('https://') + ? require('https') + : require('http'); + const options = URL.parse(url); + options.method = 'GET'; + options.headers = { + 'Content-Type': 'application/json', + }; + const req = agent.request(options, function (res) { + let result = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (result += chunk)); + res.on('end', () => resolve(JSON.parse(result))); + }); + req.on('error', (err) => reject(err)); + req.end(); + }); +} diff --git a/remote/test/puppeteer/utils/check_availability.js b/remote/test/puppeteer/utils/check_availability.js new file mode 100755 index 0000000000..e24e2b9dc3 --- /dev/null +++ b/remote/test/puppeteer/utils/check_availability.js @@ -0,0 +1,298 @@ +#!/usr/bin/env node +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const https = require('https'); +// run `npm run dev-install` if lib dir is missing +const BrowserFetcher = require('../lib/cjs/puppeteer/node/BrowserFetcher.js') + .BrowserFetcher; + +const SUPPORTER_PLATFORMS = ['linux', 'mac', 'win32', 'win64']; +const fetchers = SUPPORTER_PLATFORMS.map( + (platform) => new BrowserFetcher('', { platform }) +); + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', +}; + +class Table { + /** + * @param {!Array<number>} columnWidths + */ + constructor(columnWidths) { + this.widths = columnWidths; + } + + /** + * @param {!Array<string>} values + */ + drawRow(values) { + assert(values.length === this.widths.length); + let row = ''; + for (let i = 0; i < values.length; ++i) + row += padCenter(values[i], this.widths[i]); + console.log(row); + } +} + +const helpMessage = ` +This script checks availability of prebuilt Chromium snapshots. + +Usage: node check_availability.js [<options>] [<browser version(s)>] + +options + -f full mode checks availability of all the platforms, default mode + -r roll mode checks for the most recent stable Chromium roll candidate + -rb roll mode checks for the most recent beta Chromium roll candidate + -rd roll mode checks for the most recent dev Chromium roll candidate + -h show this help + +browser version(s) + <revision> single revision number means checking for this specific revision + <from> <to> checks all the revisions within a given range, inclusively + +Examples + To check Chromium availability of a certain revision + node check_availability.js [revision] + + To find a Chromium roll candidate for current stable Linux version + node check_availability.js -r + use -rb for beta and -rd for dev versions. + + To check Chromium availability from the latest revision in a descending order + node check_availability.js +`; + +function main() { + const args = process.argv.slice(2); + + if (args.length > 3) { + console.log(helpMessage); + return; + } + + if (args.length === 0) { + checkOmahaProxyAvailability(); + return; + } + + if (args[0].startsWith('-')) { + const option = args[0].substring(1); + switch (option) { + case 'f': + break; + case 'r': + checkRollCandidate('stable'); + return; + case 'rb': + checkRollCandidate('beta'); + return; + case 'rd': + checkRollCandidate('dev'); + return; + default: + console.log(helpMessage); + return; + } + args.splice(0, 1); // remove options arg since we are done with options + } + + if (args.length === 1) { + const revision = parseInt(args[0], 10); + checkRangeAvailability({ + fromRevision: revision, + toRevision: revision, + stopWhenAllAvailable: false, + }); + } else { + const fromRevision = parseInt(args[0], 10); + const toRevision = parseInt(args[1], 10); + checkRangeAvailability({ + fromRevision, + toRevision, + stopWhenAllAvailable: false, + }); + } +} + +async function checkOmahaProxyAvailability() { + const latestRevisions = ( + await Promise.all([ + fetch( + 'https://storage.googleapis.com/chromium-browser-snapshots/Mac/LAST_CHANGE' + ), + fetch( + 'https://storage.googleapis.com/chromium-browser-snapshots/Linux_x64/LAST_CHANGE' + ), + fetch( + 'https://storage.googleapis.com/chromium-browser-snapshots/Win/LAST_CHANGE' + ), + fetch( + 'https://storage.googleapis.com/chromium-browser-snapshots/Win_x64/LAST_CHANGE' + ), + ]) + ).map((s) => parseInt(s, 10)); + const from = Math.max(...latestRevisions); + checkRangeAvailability({ + fromRevision: from, + toRevision: 0, + stopWhenAllAvailable: false, + }); +} +async function checkRollCandidate(channel) { + const omahaResponse = await fetch( + `https://omahaproxy.appspot.com/all.json?channel=${channel}&os=linux` + ); + const linuxInfo = JSON.parse(omahaResponse)[0]; + if (!linuxInfo) { + console.error(`no ${channel} linux information available from omahaproxy`); + return; + } + + const linuxRevision = parseInt( + linuxInfo.versions[0].branch_base_position, + 10 + ); + const currentRevision = parseInt( + require('../lib/cjs/puppeteer/revisions').PUPPETEER_REVISIONS.chromium, + 10 + ); + + checkRangeAvailability({ + fromRevision: linuxRevision, + toRevision: currentRevision, + stopWhenAllAvailable: true, + }); +} + +/** + * @param {*} options + */ +async function checkRangeAvailability({ + fromRevision, + toRevision, + stopWhenAllAvailable, +}) { + const table = new Table([10, 7, 7, 7, 7]); + table.drawRow([''].concat(SUPPORTER_PLATFORMS)); + + const inc = fromRevision < toRevision ? 1 : -1; + const revisionToStop = toRevision + inc; // +inc so the range is fully inclusive + for ( + let revision = fromRevision; + revision !== revisionToStop; + revision += inc + ) { + const allAvailable = await checkAndDrawRevisionAvailability( + table, + '', + revision + ); + if (allAvailable && stopWhenAllAvailable) break; + } +} + +/** + * @param {!Table} table + * @param {string} name + * @param {number} revision + * @returns {boolean} + */ +async function checkAndDrawRevisionAvailability(table, name, revision) { + const promises = fetchers.map((fetcher) => fetcher.canDownload(revision)); + const availability = await Promise.all(promises); + const allAvailable = availability.every((e) => !!e); + const values = [ + name + + ' ' + + (allAvailable ? colors.green + revision + colors.reset : revision), + ]; + for (let i = 0; i < availability.length; ++i) { + const decoration = availability[i] ? '+' : '-'; + const color = availability[i] ? colors.green : colors.red; + values.push(color + decoration + colors.reset); + } + table.drawRow(values); + return allAvailable; +} + +/** + * @param {string} url + * @returns {!Promise<?string>} + */ +function fetch(url) { + let resolve; + const promise = new Promise((x) => (resolve = x)); + https + .get(url, (response) => { + if (response.statusCode !== 200) { + resolve(null); + return; + } + let body = ''; + response.on('data', function (chunk) { + body += chunk; + }); + response.on('end', function () { + resolve(body); + }); + }) + .on('error', function (e) { + console.error('Error fetching json: ' + e); + resolve(null); + }); + return promise; +} + +/** + * @param {number} size + * @returns {string} + */ +function spaceString(size) { + return new Array(size).fill(' ').join(''); +} + +/** + * @param {string} text + * @returns {string} + */ +function filterOutColors(text) { + for (const colorName in colors) { + const color = colors[colorName]; + text = text.replace(color, ''); + } + return text; +} + +/** + * @param {string} text + * @param {number} length + * @returns {string} + */ +function padCenter(text, length) { + const printableCharacters = filterOutColors(text); + if (printableCharacters.length >= length) return text; + const left = Math.floor((length - printableCharacters.length) / 2); + const right = Math.ceil((length - printableCharacters.length) / 2); + return spaceString(left) + text + spaceString(right); +} + +main(); diff --git a/remote/test/puppeteer/utils/doclint/.gitignore b/remote/test/puppeteer/utils/doclint/.gitignore new file mode 100644 index 0000000000..ea1472ec1f --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/remote/test/puppeteer/utils/doclint/Message.js b/remote/test/puppeteer/utils/doclint/Message.js new file mode 100644 index 0000000000..374b60d44d --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/Message.js @@ -0,0 +1,44 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class Message { + /** + * @param {string} type + * @param {string} text + */ + constructor(type, text) { + this.type = type; + this.text = text; + } + + /** + * @param {string} text + * @returns {!Message} + */ + static error(text) { + return new Message('error', text); + } + + /** + * @param {string} text + * @returns {!Message} + */ + static warning(text) { + return new Message('warning', text); + } +} + +module.exports = Message; diff --git a/remote/test/puppeteer/utils/doclint/README.md b/remote/test/puppeteer/utils/doclint/README.md new file mode 100644 index 0000000000..b3f8c366de --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/README.md @@ -0,0 +1,31 @@ +# DocLint + +**Doclint** is a small program that lints Puppeteer's documentation against +Puppeteer's source code. + +Doclint works in a few steps: + +1. Read sources in `lib/` folder, parse AST trees and extract public API + - note that we run DocLint on the outputted JavaScript in `lib/` rather than the source code in `src/`. We will do this until we have migrated `src/` to be exclusively TypeScript and then we can update DocLint to support TypeScript. +2. Read sources in `docs/` folder, render markdown to HTML, use puppeteer to traverse the HTML + and extract described API +3. Compare one API to another + +Doclint is also responsible for general markdown checks, most notably for the table of contents +relevancy. + +## Running + +```bash +npm run doc +``` + +## Tests + +Doclint has its own set of jasmine tests, located at `utils/doclint/test` folder. + +To execute tests, run: + +```bash +npm run test-doclint +``` diff --git a/remote/test/puppeteer/utils/doclint/Source.js b/remote/test/puppeteer/utils/doclint/Source.js new file mode 100644 index 0000000000..0f7c13234f --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/Source.js @@ -0,0 +1,117 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('path'); +const util = require('util'); +const fs = require('fs'); + +const readFileAsync = util.promisify(fs.readFile); +const readdirAsync = util.promisify(fs.readdir); +const writeFileAsync = util.promisify(fs.writeFile); + +const PROJECT_DIR = path.join(__dirname, '..', '..'); + +class Source { + /** + * @param {string} filePath + * @param {string} text + */ + constructor(filePath, text) { + this._filePath = filePath; + this._projectPath = path.relative(PROJECT_DIR, filePath); + this._name = path.basename(filePath); + this._text = text; + this._hasUpdatedText = false; + } + + /** + * @returns {string} + */ + filePath() { + return this._filePath; + } + + /** + * @returns {string} + */ + projectPath() { + return this._projectPath; + } + + /** + * @returns {string} + */ + name() { + return this._name; + } + + /** + * @param {string} text + * @returns {boolean} + */ + setText(text) { + if (text === this._text) return false; + this._hasUpdatedText = true; + this._text = text; + return true; + } + + /** + * @returns {string} + */ + text() { + return this._text; + } + + /** + * @returns {boolean} + */ + hasUpdatedText() { + return this._hasUpdatedText; + } + + async save() { + await writeFileAsync(this.filePath(), this.text()); + } + + /** + * @param {string} filePath + * @returns {!Promise<Source>} + */ + static async readFile(filePath) { + filePath = path.resolve(filePath); + const text = await readFileAsync(filePath, { encoding: 'utf8' }); + return new Source(filePath, text); + } + + /** + * @param {string} dirPath + * @param {string=} extension + * @returns {!Promise<!Array<!Source>>} + */ + static async readdir(dirPath, extension = '') { + const fileNames = await readdirAsync(dirPath); + const filePaths = fileNames + .filter((fileName) => fileName.endsWith(extension)) + .map((fileName) => path.join(dirPath, fileName)) + .filter((filePath) => { + const stats = fs.lstatSync(filePath); + return stats.isDirectory() === false; + }); + return Promise.all(filePaths.map((filePath) => Source.readFile(filePath))); + } +} +module.exports = Source; diff --git a/remote/test/puppeteer/utils/doclint/check_public_api/Documentation.js b/remote/test/puppeteer/utils/doclint/check_public_api/Documentation.js new file mode 100644 index 0000000000..9bbaabfc70 --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/check_public_api/Documentation.js @@ -0,0 +1,157 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class Documentation { + /** + * @param {!Array<!Documentation.Class>} classesArray + */ + constructor(classesArray) { + this.classesArray = classesArray; + /** @type {!Map<string, !Documentation.Class>} */ + this.classes = new Map(); + for (const cls of classesArray) this.classes.set(cls.name, cls); + } +} + +Documentation.Class = class { + /** + * @param {string} name + * @param {!Array<!Documentation.Member>} membersArray + * @param {?string=} extendsName + * @param {string=} comment + */ + constructor(name, membersArray, extendsName = null, comment = '') { + this.name = name; + this.membersArray = membersArray; + /** @type {!Map<string, !Documentation.Member>} */ + this.members = new Map(); + /** @type {!Map<string, !Documentation.Member>} */ + this.properties = new Map(); + /** @type {!Array<!Documentation.Member>} */ + this.propertiesArray = []; + /** @type {!Map<string, !Documentation.Member>} */ + this.methods = new Map(); + /** @type {!Array<!Documentation.Member>} */ + this.methodsArray = []; + /** @type {!Map<string, !Documentation.Member>} */ + this.events = new Map(); + /** @type {!Array<!Documentation.Member>} */ + this.eventsArray = []; + this.comment = comment; + this.extends = extendsName; + for (const member of membersArray) { + this.members.set(member.name, member); + if (member.kind === 'method') { + this.methods.set(member.name, member); + this.methodsArray.push(member); + } else if (member.kind === 'property') { + this.properties.set(member.name, member); + this.propertiesArray.push(member); + } else if (member.kind === 'event') { + this.events.set(member.name, member); + this.eventsArray.push(member); + } + } + } +}; + +Documentation.Member = class { + /** + * @param {string} kind + * @param {string} name + * @param {?Documentation.Type} type + * @param {!Array<!Documentation.Member>} argsArray + */ + constructor( + kind, + name, + type, + argsArray, + comment = '', + returnComment = '', + required = true + ) { + this.kind = kind; + this.name = name; + this.type = type; + this.comment = comment; + this.returnComment = returnComment; + this.argsArray = argsArray; + this.required = required; + /** @type {!Map<string, !Documentation.Member>} */ + this.args = new Map(); + for (const arg of argsArray) this.args.set(arg.name, arg); + } + + /** + * @param {string} name + * @param {!Array<!Documentation.Member>} argsArray + * @param {?Documentation.Type} returnType + * @returns {!Documentation.Member} + */ + static createMethod(name, argsArray, returnType, returnComment, comment) { + return new Documentation.Member( + 'method', + name, + returnType, + argsArray, + comment, + returnComment + ); + } + + /** + * @param {string} name + * @param {!Documentation.Type} type + * @param {string=} comment + * @param {boolean=} required + * @returns {!Documentation.Member} + */ + static createProperty(name, type, comment, required) { + return new Documentation.Member( + 'property', + name, + type, + [], + comment, + undefined, + required + ); + } + + /** + * @param {string} name + * @param {?Documentation.Type=} type + * @param {string=} comment + * @returns {!Documentation.Member} + */ + static createEvent(name, type = null, comment) { + return new Documentation.Member('event', name, type, [], comment); + } +}; + +Documentation.Type = class { + /** + * @param {string} name + * @param {!Array<!Documentation.Member>=} properties + */ + constructor(name, properties = []) { + this.name = name; + this.properties = properties; + } +}; + +module.exports = Documentation; diff --git a/remote/test/puppeteer/utils/doclint/check_public_api/JSBuilder.js b/remote/test/puppeteer/utils/doclint/check_public_api/JSBuilder.js new file mode 100644 index 0000000000..0994dffcff --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/check_public_api/JSBuilder.js @@ -0,0 +1,279 @@ +const ts = require('typescript'); +const path = require('path'); +const Documentation = require('./Documentation'); +module.exports = checkSources; + +/** + * @param {!Array<!import('../Source')>} sources + */ +function checkSources(sources) { + // special treatment for Events.js + const classEvents = new Map(); + const eventsSource = sources.find((source) => source.name() === 'Events.js'); + if (eventsSource) { + const { Events } = require(eventsSource.filePath()); + for (const [className, events] of Object.entries(Events)) + classEvents.set( + className, + Array.from(Object.values(events)) + .filter((e) => typeof e === 'string') + .map((e) => Documentation.Member.createEvent(e)) + ); + } + + const excludeClasses = new Set([]); + const program = ts.createProgram({ + options: { + allowJs: true, + target: ts.ScriptTarget.ES2017, + }, + rootNames: sources.map((source) => source.filePath()), + }); + const checker = program.getTypeChecker(); + const sourceFiles = program.getSourceFiles(); + /** @type {!Array<!Documentation.Class>} */ + const classes = []; + /** @type {!Map<string, string>} */ + const inheritance = new Map(); + + const sourceFilesNoNodeModules = sourceFiles.filter( + (x) => !x.fileName.includes('node_modules') + ); + const sourceFileNamesSet = new Set( + sourceFilesNoNodeModules.map((x) => x.fileName) + ); + sourceFilesNoNodeModules.map((x) => { + if (x.fileName.includes('/lib/')) { + const potentialTSSource = x.fileName + .replace('lib', 'src') + .replace('.js', '.ts'); + if (sourceFileNamesSet.has(potentialTSSource)) { + /* Not going to visit this file because we have the TypeScript src code + * which we'll use instead. + */ + return; + } + } + + visit(x); + }); + + const errors = []; + const documentation = new Documentation( + recreateClassesWithInheritance(classes, inheritance) + ); + + return { errors, documentation }; + + /** + * @param {!Array<!Documentation.Class>} classes + * @param {!Map<string, string>} inheritance + * @returns {!Array<!Documentation.Class>} + */ + function recreateClassesWithInheritance(classes, inheritance) { + const classesByName = new Map(classes.map((cls) => [cls.name, cls])); + return classes.map((cls) => { + const membersMap = new Map(); + for (let wp = cls; wp; wp = classesByName.get(inheritance.get(wp.name))) { + for (const member of wp.membersArray) { + // Member was overridden. + const memberId = member.kind + ':' + member.name; + if (membersMap.has(memberId)) continue; + membersMap.set(memberId, member); + } + } + return new Documentation.Class(cls.name, Array.from(membersMap.values())); + }); + } + + /** + * @param {!ts.Node} node + */ + function visit(node) { + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + const symbol = node.name + ? checker.getSymbolAtLocation(node.name) + : node.symbol; + let className = symbol.getName(); + + if (className === '__class') { + let parent = node; + while (parent.parent) parent = parent.parent; + className = path.basename(parent.fileName, '.js'); + } + if (className && !excludeClasses.has(className)) { + classes.push(serializeClass(className, symbol, node)); + const parentClassName = parentClass(node); + if (parentClassName) inheritance.set(className, parentClassName); + excludeClasses.add(className); + } + } + ts.forEachChild(node, visit); + } + + function parentClass(classNode) { + for (const herigateClause of classNode.heritageClauses || []) { + for (const heritageType of herigateClause.types) { + const parentClassName = heritageType.expression.escapedText; + return parentClassName; + } + } + return null; + } + + function serializeSymbol(symbol, circular = []) { + const type = checker.getTypeOfSymbolAtLocation( + symbol, + symbol.valueDeclaration + ); + const name = symbol.getName(); + if (symbol.valueDeclaration && symbol.valueDeclaration.dotDotDotToken) { + try { + const innerType = serializeType(type.typeArguments[0], circular); + innerType.name = '...' + innerType.name; + return Documentation.Member.createProperty('...' + name, innerType); + } catch (error) { + /** + * DocLint struggles with the paramArgs type on CDPSession.send because + * it uses a complex type from the devtools-protocol method. Doclint + * isn't going to be here for much longer so we'll just silence this + * warning than try to add support which would warrant a huge rewrite. + */ + if (name !== 'paramArgs') throw error; + } + } + return Documentation.Member.createProperty( + name, + serializeType(type, circular) + ); + } + + /** + * @param {!ts.ObjectType} type + */ + function isRegularObject(type) { + if (type.isIntersection()) return true; + if (!type.objectFlags) return false; + if (!('aliasSymbol' in type)) return false; + if (type.getConstructSignatures().length) return false; + if (type.getCallSignatures().length) return false; + if (type.isLiteral()) return false; + if (type.isUnion()) return false; + + return true; + } + + /** + * @param {!ts.Type} type + * @returns {!Documentation.Type} + */ + function serializeType(type, circular = []) { + let typeName = checker.typeToString(type); + if ( + typeName === 'any' || + typeName === '{ [x: string]: string; }' || + typeName === '{}' + ) + typeName = 'Object'; + const nextCircular = [typeName].concat(circular); + + if (isRegularObject(type)) { + let properties = undefined; + if (!circular.includes(typeName)) + properties = type + .getProperties() + .map((property) => serializeSymbol(property, nextCircular)); + return new Documentation.Type('Object', properties); + } + if (type.isUnion() && typeName.includes('|')) { + const types = type.types.map((type) => serializeType(type, circular)); + const name = types.map((type) => type.name).join('|'); + const properties = [].concat(...types.map((type) => type.properties)); + return new Documentation.Type( + name.replace(/false\|true/g, 'boolean'), + properties + ); + } + if (type.typeArguments) { + const properties = []; + const innerTypeNames = []; + for (const typeArgument of type.typeArguments) { + const innerType = serializeType(typeArgument, nextCircular); + if (innerType.properties) properties.push(...innerType.properties); + innerTypeNames.push(innerType.name); + } + if ( + innerTypeNames.length === 0 || + (innerTypeNames.length === 1 && innerTypeNames[0] === 'void') + ) + return new Documentation.Type(type.symbol.name); + return new Documentation.Type( + `${type.symbol.name}<${innerTypeNames.join(', ')}>`, + properties + ); + } + return new Documentation.Type(typeName, []); + } + + /** + * @param {!ts.Symbol} symbol + * @returns {boolean} + */ + function symbolHasPrivateModifier(symbol) { + const modifiers = + (symbol.valueDeclaration && symbol.valueDeclaration.modifiers) || []; + return modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword + ); + } + + /** + * @param {string} className + * @param {!ts.Symbol} symbol + * @returns {} + */ + function serializeClass(className, symbol, node) { + /** @type {!Array<!Documentation.Member>} */ + const members = classEvents.get(className) || []; + + for (const [name, member] of symbol.members || []) { + /* Before TypeScript we denoted private methods with an underscore + * but in TypeScript we use the private keyword + * hence we check for either here. + */ + if (name.startsWith('_') || symbolHasPrivateModifier(member)) continue; + + const memberType = checker.getTypeOfSymbolAtLocation( + member, + member.valueDeclaration + ); + const signature = memberType.getCallSignatures()[0]; + if (signature) members.push(serializeSignature(name, signature)); + else members.push(serializeProperty(name, memberType)); + } + + return new Documentation.Class(className, members); + } + + /** + * @param {string} name + * @param {!ts.Signature} signature + */ + function serializeSignature(name, signature) { + const parameters = signature.parameters.map((s) => serializeSymbol(s)); + const returnType = serializeType(signature.getReturnType()); + return Documentation.Member.createMethod( + name, + parameters, + returnType.name !== 'void' ? returnType : null + ); + } + + /** + * @param {string} name + * @param {!ts.Type} type + */ + function serializeProperty(name, type) { + return Documentation.Member.createProperty(name, serializeType(type)); + } +} diff --git a/remote/test/puppeteer/utils/doclint/check_public_api/MDBuilder.js b/remote/test/puppeteer/utils/doclint/check_public_api/MDBuilder.js new file mode 100644 index 0000000000..46a4e6cfce --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/check_public_api/MDBuilder.js @@ -0,0 +1,402 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const Documentation = require('./Documentation'); +const commonmark = require('commonmark'); + +class MDOutline { + /** + * @param {!Page} page + * @param {string} text + * @returns {!MDOutline} + */ + static async create(page, text) { + // Render markdown as HTML. + const reader = new commonmark.Parser(); + const parsed = reader.parse(text); + const writer = new commonmark.HtmlRenderer(); + const html = writer.render(parsed); + + page.on('console', (msg) => { + console.log(msg.text()); + }); + // Extract headings. + await page.setContent(html); + const { classes, errors } = await page.evaluate(() => { + const classes = []; + const errors = []; + const headers = document.body.querySelectorAll('h3'); + for (let i = 0; i < headers.length; i++) { + const fragment = extractSiblingsIntoFragment( + headers[i], + headers[i + 1] + ); + classes.push(parseClass(fragment)); + } + return { classes, errors }; + + /** + * @param {HTMLLIElement} element + */ + function parseProperty(element) { + const clone = element.cloneNode(true); + const ul = clone.querySelector(':scope > ul'); + const str = parseComment( + extractSiblingsIntoFragment(clone.firstChild, ul) + ); + const name = str + .substring(0, str.indexOf('<')) + .replace(/\`/g, '') + .trim(); + const type = findType(str); + const properties = []; + const comment = str + .substring(str.indexOf('<') + type.length + 2) + .trim(); + // Strings have enum values instead of properties + if (!type.includes('string')) { + for (const childElement of element.querySelectorAll( + ':scope > ul > li' + )) { + const property = parseProperty(childElement); + property.required = property.comment.includes('***required***'); + properties.push(property); + } + } + return { + name, + type, + comment, + properties, + }; + } + + /** + * @param {string} str + * @returns {string} + */ + function findType(str) { + const start = str.indexOf('<') + 1; + let count = 1; + for (let i = start; i < str.length; i++) { + if (str[i] === '<') count++; + if (str[i] === '>') count--; + if (!count) return str.substring(start, i); + } + return 'unknown'; + } + + /** + * @param {DocumentFragment} content + */ + function parseClass(content) { + const members = []; + const headers = content.querySelectorAll('h4'); + const name = content.firstChild.textContent; + let extendsName = null; + let commentStart = content.firstChild.nextSibling; + const extendsElement = content.querySelector('ul'); + if ( + extendsElement && + extendsElement.textContent.trim().startsWith('extends:') + ) { + commentStart = extendsElement.nextSibling; + extendsName = extendsElement.querySelector('a').textContent; + } + const comment = parseComment( + extractSiblingsIntoFragment(commentStart, headers[0]) + ); + for (let i = 0; i < headers.length; i++) { + const fragment = extractSiblingsIntoFragment( + headers[i], + headers[i + 1] + ); + members.push(parseMember(fragment)); + } + return { + name, + comment, + extendsName, + members, + }; + } + + /** + * @param {Node} content + */ + function parseComment(content) { + for (const code of content.querySelectorAll('pre > code')) + code.replaceWith( + '```' + + code.className.substring('language-'.length) + + '\n' + + code.textContent + + '```' + ); + for (const code of content.querySelectorAll('code')) + code.replaceWith('`' + code.textContent + '`'); + for (const strong of content.querySelectorAll('strong')) + strong.replaceWith('**' + parseComment(strong) + '**'); + return content.textContent.trim(); + } + + /** + * @param {string} name + * @param {DocumentFragment} content + */ + function parseMember(content) { + const name = content.firstChild.textContent; + const args = []; + let returnType = null; + + const paramRegex = /^\w+\.[\w$]+\((.*)\)$/; + const matches = paramRegex.exec(name) || ['', '']; + const parameters = matches[1]; + const optionalStartIndex = parameters.indexOf('['); + const optinalParamsStr = + optionalStartIndex !== -1 + ? parameters.substring(optionalStartIndex).replace(/[\[\]]/g, '') + : ''; + const optionalparams = new Set( + optinalParamsStr + .split(',') + .filter((x) => x) + .map((x) => x.trim()) + ); + const ul = content.querySelector('ul'); + for (const element of content.querySelectorAll('h4 + ul > li')) { + if ( + element.matches('li') && + element.textContent.trim().startsWith('<') + ) { + returnType = parseProperty(element); + } else if ( + element.matches('li') && + element.firstChild.matches && + element.firstChild.matches('code') + ) { + const property = parseProperty(element); + property.required = !optionalparams.has(property.name); + args.push(property); + } else if ( + element.matches('li') && + element.firstChild.nodeType === Element.TEXT_NODE && + element.firstChild.textContent.toLowerCase().startsWith('return') + ) { + returnType = parseProperty(element); + const expectedText = 'returns: '; + let actualText = element.firstChild.textContent; + let angleIndex = actualText.indexOf('<'); + let spaceIndex = actualText.indexOf(' '); + angleIndex = angleIndex === -1 ? actualText.length : angleIndex; + spaceIndex = spaceIndex === -1 ? actualText.length : spaceIndex + 1; + actualText = actualText.substring( + 0, + Math.min(angleIndex, spaceIndex) + ); + if (actualText !== expectedText) + errors.push( + `${name} has mistyped 'return' type declaration: expected exactly '${expectedText}', found '${actualText}'.` + ); + } + } + const comment = parseComment( + extractSiblingsIntoFragment(ul ? ul.nextSibling : content) + ); + return { + name, + args, + returnType, + comment, + }; + } + + /** + * @param {!Node} fromInclusive + * @param {!Node} toExclusive + * @returns {!DocumentFragment} + */ + function extractSiblingsIntoFragment(fromInclusive, toExclusive) { + const fragment = document.createDocumentFragment(); + let node = fromInclusive; + while (node && node !== toExclusive) { + const next = node.nextSibling; + fragment.appendChild(node); + node = next; + } + return fragment; + } + }); + return new MDOutline(classes, errors); + } + + constructor(classes, errors) { + this.classes = []; + this.errors = errors; + const classHeading = /^class: (\w+)$/; + const constructorRegex = /^new (\w+)\((.*)\)$/; + const methodRegex = /^(\w+)\.([\w$]+)\((.*)\)$/; + const propertyRegex = /^(\w+)\.(\w+)$/; + const eventRegex = /^event: '(\w+)'$/; + let currentClassName = null; + let currentClassMembers = []; + let currentClassComment = ''; + let currentClassExtends = null; + for (const cls of classes) { + const match = cls.name.match(classHeading); + if (!match) continue; + currentClassName = match[1]; + currentClassComment = cls.comment; + currentClassExtends = cls.extendsName; + for (const member of cls.members) { + if (constructorRegex.test(member.name)) { + const match = member.name.match(constructorRegex); + handleMethod.call(this, member, match[1], 'constructor', match[2]); + } else if (methodRegex.test(member.name)) { + const match = member.name.match(methodRegex); + handleMethod.call(this, member, match[1], match[2], match[3]); + } else if (propertyRegex.test(member.name)) { + const match = member.name.match(propertyRegex); + handleProperty.call(this, member, match[1], match[2]); + } else if (eventRegex.test(member.name)) { + const match = member.name.match(eventRegex); + handleEvent.call(this, member, match[1]); + } + } + flushClassIfNeeded.call(this); + } + + function handleMethod(member, className, methodName, parameters) { + if ( + !currentClassName || + !className || + !methodName || + className.toLowerCase() !== currentClassName.toLowerCase() + ) { + this.errors.push(`Failed to process header as method: ${member.name}`); + return; + } + parameters = parameters.trim().replace(/[\[\]]/g, ''); + if (parameters !== member.args.map((arg) => arg.name).join(', ')) + this.errors.push( + `Heading arguments for "${ + member.name + }" do not match described ones, i.e. "${parameters}" != "${member.args + .map((a) => a.name) + .join(', ')}"` + ); + const args = member.args.map(createPropertyFromJSON); + let returnType = null; + let returnComment = ''; + if (member.returnType) { + const returnProperty = createPropertyFromJSON(member.returnType); + returnType = returnProperty.type; + returnComment = returnProperty.comment; + } + const method = Documentation.Member.createMethod( + methodName, + args, + returnType, + returnComment, + member.comment + ); + currentClassMembers.push(method); + } + + function createPropertyFromJSON(payload) { + const type = new Documentation.Type( + payload.type, + payload.properties.map(createPropertyFromJSON) + ); + const required = payload.required; + return Documentation.Member.createProperty( + payload.name, + type, + payload.comment, + required + ); + } + + function handleProperty(member, className, propertyName) { + if ( + !currentClassName || + !className || + !propertyName || + className.toLowerCase() !== currentClassName.toLowerCase() + ) { + this.errors.push( + `Failed to process header as property: ${member.name}` + ); + return; + } + const type = member.returnType ? member.returnType.type : null; + const properties = member.returnType ? member.returnType.properties : []; + currentClassMembers.push( + createPropertyFromJSON({ + type, + name: propertyName, + properties, + comment: member.comment, + }) + ); + } + + function handleEvent(member, eventName) { + if (!currentClassName || !eventName) { + this.errors.push(`Failed to process header as event: ${member.name}`); + return; + } + currentClassMembers.push( + Documentation.Member.createEvent( + eventName, + member.returnType && createPropertyFromJSON(member.returnType).type, + member.comment + ) + ); + } + + function flushClassIfNeeded() { + if (currentClassName === null) return; + this.classes.push( + new Documentation.Class( + currentClassName, + currentClassMembers, + currentClassExtends, + currentClassComment + ) + ); + currentClassName = null; + currentClassMembers = []; + } + } +} + +/** + * @param {!Page} page + * @param {!Array<!Source>} sources + * @returns {!Promise<{documentation: !Documentation, errors: !Array<string>}>} + */ +module.exports = async function (page, sources) { + const classes = []; + const errors = []; + for (const source of sources) { + const outline = await MDOutline.create(page, source.text()); + classes.push(...outline.classes); + errors.push(...outline.errors); + } + const documentation = new Documentation(classes); + return { documentation, errors }; +}; diff --git a/remote/test/puppeteer/utils/doclint/check_public_api/index.js b/remote/test/puppeteer/utils/doclint/check_public_api/index.js new file mode 100644 index 0000000000..84d5ca0f2a --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/check_public_api/index.js @@ -0,0 +1,977 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const jsBuilder = require('./JSBuilder'); +const mdBuilder = require('./MDBuilder'); +const Documentation = require('./Documentation'); +const Message = require('../Message'); +const { + MODULES_TO_CHECK_FOR_COVERAGE, +} = require('../../../test/coverage-utils'); + +const EXCLUDE_PROPERTIES = new Set([ + 'Browser.create', + 'Headers.fromPayload', + 'Page.create', + 'JSHandle.toString', + 'TimeoutError.name', + /* This isn't an actual property, but a TypeScript generic. + * DocLint incorrectly parses it as a property. + */ + 'ElementHandle.ElementType', +]); + +/** + * @param {!Page} page + * @param {!Array<!Source>} mdSources + * @returns {!Promise<!Array<!Message>>} + */ +module.exports = async function lint(page, mdSources, jsSources) { + const mdResult = await mdBuilder(page, mdSources); + const jsResult = await jsBuilder(jsSources); + const jsDocumentation = filterJSDocumentation(jsResult.documentation); + const mdDocumentation = mdResult.documentation; + + const jsErrors = jsResult.errors; + jsErrors.push(...checkDuplicates(jsDocumentation)); + + const mdErrors = mdResult.errors; + mdErrors.push(...compareDocumentations(mdDocumentation, jsDocumentation)); + mdErrors.push(...checkDuplicates(mdDocumentation)); + mdErrors.push(...checkSorting(mdDocumentation)); + + // Push all errors with proper prefixes + const errors = jsErrors.map((error) => '[JavaScript] ' + error); + errors.push(...mdErrors.map((error) => '[MarkDown] ' + error)); + return errors.map((error) => Message.error(error)); +}; + +/** + * @param {!Documentation} doc + * @returns {!Array<string>} + */ +function checkSorting(doc) { + const errors = []; + for (const cls of doc.classesArray) { + const members = cls.membersArray; + + // Events should go first. + let eventIndex = 0; + for ( + ; + eventIndex < members.length && members[eventIndex].kind === 'event'; + ++eventIndex + ); + for ( + ; + eventIndex < members.length && members[eventIndex].kind !== 'event'; + ++eventIndex + ); + if (eventIndex < members.length) + errors.push( + `Events should go first. Event '${members[eventIndex].name}' in class ${cls.name} breaks order` + ); + + // Constructor should be right after events and before all other members. + const constructorIndex = members.findIndex( + (member) => member.kind === 'method' && member.name === 'constructor' + ); + if (constructorIndex > 0 && members[constructorIndex - 1].kind !== 'event') + errors.push(`Constructor of ${cls.name} should go before other methods`); + + // Events should be sorted alphabetically. + for (let i = 0; i < members.length - 1; ++i) { + const member1 = cls.membersArray[i]; + const member2 = cls.membersArray[i + 1]; + if (member1.kind !== 'event' || member2.kind !== 'event') continue; + if (member1.name > member2.name) + errors.push( + `Event '${member1.name}' in class ${cls.name} breaks alphabetic ordering of events` + ); + } + + // All other members should be sorted alphabetically. + for (let i = 0; i < members.length - 1; ++i) { + const member1 = cls.membersArray[i]; + const member2 = cls.membersArray[i + 1]; + if (member1.kind === 'event' || member2.kind === 'event') continue; + if (member1.kind === 'method' && member1.name === 'constructor') continue; + if (member1.name > member2.name) { + let memberName1 = `${cls.name}.${member1.name}`; + if (member1.kind === 'method') memberName1 += '()'; + let memberName2 = `${cls.name}.${member2.name}`; + if (member2.kind === 'method') memberName2 += '()'; + errors.push( + `Bad alphabetic ordering of ${cls.name} members: ${memberName1} should go after ${memberName2}` + ); + } + } + } + return errors; +} + +/** + * @param {!Documentation} jsDocumentation + * @returns {!Documentation} + */ +function filterJSDocumentation(jsDocumentation) { + const includedClasses = new Set(Object.keys(MODULES_TO_CHECK_FOR_COVERAGE)); + // Filter private classes and methods. + const classes = []; + for (const cls of jsDocumentation.classesArray) { + if (includedClasses && !includedClasses.has(cls.name)) continue; + const members = cls.membersArray.filter( + (member) => !EXCLUDE_PROPERTIES.has(`${cls.name}.${member.name}`) + ); + classes.push(new Documentation.Class(cls.name, members)); + } + return new Documentation(classes); +} + +/** + * @param {!Documentation} doc + * @returns {!Array<string>} + */ +function checkDuplicates(doc) { + const errors = []; + const classes = new Set(); + // Report duplicates. + for (const cls of doc.classesArray) { + if (classes.has(cls.name)) + errors.push(`Duplicate declaration of class ${cls.name}`); + classes.add(cls.name); + const members = new Set(); + for (const member of cls.membersArray) { + if (members.has(member.kind + ' ' + member.name)) + errors.push( + `Duplicate declaration of ${member.kind} ${cls.name}.${member.name}()` + ); + members.add(member.kind + ' ' + member.name); + const args = new Set(); + for (const arg of member.argsArray) { + if (args.has(arg.name)) + errors.push( + `Duplicate declaration of argument ${cls.name}.${member.name} "${arg.name}"` + ); + args.add(arg.name); + } + } + } + return errors; +} + +// All the methods from our EventEmitter that we don't document for each subclass. +const EVENT_LISTENER_METHODS = new Set([ + 'emit', + 'listenerCount', + 'off', + 'on', + 'once', + 'removeListener', + 'addListener', + 'removeAllListeners', +]); + +/* Methods that are defined in code but are not documented */ +const expectedNotFoundMethods = new Map([ + ['Browser', EVENT_LISTENER_METHODS], + ['BrowserContext', EVENT_LISTENER_METHODS], + ['CDPSession', EVENT_LISTENER_METHODS], + ['Page', EVENT_LISTENER_METHODS], + ['WebWorker', EVENT_LISTENER_METHODS], +]); + +/** + * @param {!Documentation} actual + * @param {!Documentation} expected + * @returns {!Array<string>} + */ +function compareDocumentations(actual, expected) { + const errors = []; + + const actualClasses = Array.from(actual.classes.keys()).sort(); + const expectedClasses = Array.from(expected.classes.keys()).sort(); + const classesDiff = diff(actualClasses, expectedClasses); + + /* These have been moved onto PuppeteerNode but we want to document them under + * Puppeteer. See https://github.com/puppeteer/puppeteer/pull/6504 for details. + */ + const expectedPuppeteerClassMissingMethods = new Set([ + 'createBrowserFetcher', + 'defaultArgs', + 'executablePath', + 'launch', + ]); + + for (const className of classesDiff.extra) + errors.push(`Non-existing class found: ${className}`); + + for (const className of classesDiff.missing) { + if (className === 'PuppeteerNode') { + continue; + } + errors.push(`Class not found: ${className}`); + } + + for (const className of classesDiff.equal) { + const actualClass = actual.classes.get(className); + const expectedClass = expected.classes.get(className); + const actualMethods = Array.from(actualClass.methods.keys()).sort(); + const expectedMethods = Array.from(expectedClass.methods.keys()).sort(); + const methodDiff = diff(actualMethods, expectedMethods); + + for (const methodName of methodDiff.extra) { + if ( + expectedPuppeteerClassMissingMethods.has(methodName) && + actualClass.name === 'Puppeteer' + ) { + continue; + } + errors.push(`Non-existing method found: ${className}.${methodName}()`); + } + + for (const methodName of methodDiff.missing) { + const missingMethodsForClass = expectedNotFoundMethods.get(className); + if (missingMethodsForClass && missingMethodsForClass.has(methodName)) + continue; + errors.push(`Method not found: ${className}.${methodName}()`); + } + + for (const methodName of methodDiff.equal) { + const actualMethod = actualClass.methods.get(methodName); + const expectedMethod = expectedClass.methods.get(methodName); + if (!actualMethod.type !== !expectedMethod.type) { + if (actualMethod.type) + errors.push( + `Method ${className}.${methodName} has unneeded description of return type` + ); + else + errors.push( + `Method ${className}.${methodName} is missing return type description` + ); + } else if (actualMethod.hasReturn) { + checkType( + `Method ${className}.${methodName} has the wrong return type: `, + actualMethod.type, + expectedMethod.type + ); + } + const actualArgs = Array.from(actualMethod.args.keys()); + const expectedArgs = Array.from(expectedMethod.args.keys()); + const argsDiff = diff(actualArgs, expectedArgs); + + if (argsDiff.extra.length || argsDiff.missing.length) { + /* Doclint cannot handle the parameter type of the CDPSession send method. + * so we just ignore it. + */ + const isCdpSessionSend = + className === 'CDPSession' && methodName === 'send'; + if (!isCdpSessionSend) { + const text = [ + `Method ${className}.${methodName}() fails to describe its parameters:`, + ]; + for (const arg of argsDiff.missing) + text.push(`- Argument not found: ${arg}`); + for (const arg of argsDiff.extra) + text.push(`- Non-existing argument found: ${arg}`); + errors.push(text.join('\n')); + } + } + + for (const arg of argsDiff.equal) + checkProperty( + `Method ${className}.${methodName}()`, + actualMethod.args.get(arg), + expectedMethod.args.get(arg) + ); + } + const actualProperties = Array.from(actualClass.properties.keys()).sort(); + const expectedProperties = Array.from( + expectedClass.properties.keys() + ).sort(); + const propertyDiff = diff(actualProperties, expectedProperties); + for (const propertyName of propertyDiff.extra) { + if (className === 'Puppeteer' && propertyName === 'product') { + continue; + } + errors.push(`Non-existing property found: ${className}.${propertyName}`); + } + for (const propertyName of propertyDiff.missing) + errors.push(`Property not found: ${className}.${propertyName}`); + + const actualEvents = Array.from(actualClass.events.keys()).sort(); + const expectedEvents = Array.from(expectedClass.events.keys()).sort(); + const eventsDiff = diff(actualEvents, expectedEvents); + for (const eventName of eventsDiff.extra) + errors.push( + `Non-existing event found in class ${className}: '${eventName}'` + ); + for (const eventName of eventsDiff.missing) + errors.push(`Event not found in class ${className}: '${eventName}'`); + } + + /** + * @param {string} source + * @param {!Documentation.Member} actual + * @param {!Documentation.Member} expected + */ + function checkProperty(source, actual, expected) { + checkType(source + ' ' + actual.name, actual.type, expected.type); + } + + /** + * @param {string} source + * @param {!string} actualName + * @param {!string} expectedName + */ + function namingMisMatchInTypeIsExpected(source, actualName, expectedName) { + /* The DocLint tooling doesn't deal well with generics in TypeScript + * source files. We could fix this but the longterm plan is to + * auto-generate documentation from TS. So instead we document here + * the methods that use generics that DocLint trips up on and if it + * finds a mismatch that matches one of the cases below it doesn't + * error. This still means we're protected from accidental changes, as + * if the mismatch doesn't exactly match what's described below + * DocLint will fail. + */ + const expectedNamingMismatches = new Map([ + [ + 'Method CDPSession.send() method', + { + actualName: 'string', + expectedName: 'T', + }, + ], + [ + 'Method CDPSession.send() params', + { + actualName: 'Object', + expectedName: 'CommandParameters[T]', + }, + ], + [ + 'Method ElementHandle.click() options', + { + actualName: 'Object', + expectedName: 'ClickOptions', + }, + ], + [ + 'Method ElementHandle.press() options', + { + actualName: 'Object', + expectedName: 'PressOptions', + }, + ], + [ + 'Method ElementHandle.press() key', + { + actualName: 'string', + expectedName: 'KeyInput', + }, + ], + [ + 'Method Keyboard.down() key', + { + actualName: 'string', + expectedName: 'KeyInput', + }, + ], + [ + 'Method Keyboard.press() key', + { + actualName: 'string', + expectedName: 'KeyInput', + }, + ], + [ + 'Method Keyboard.up() key', + { + actualName: 'string', + expectedName: 'KeyInput', + }, + ], + [ + 'Method Mouse.down() options', + { + actualName: 'Object', + expectedName: 'MouseOptions', + }, + ], + [ + 'Method Mouse.up() options', + { + actualName: 'Object', + expectedName: 'MouseOptions', + }, + ], + [ + 'Method Mouse.wheel() options', + { + actualName: 'Object', + expectedName: 'MouseWheelOptions', + }, + ], + [ + 'Method Tracing.start() options', + { + actualName: 'Object', + expectedName: 'TracingOptions', + }, + ], + [ + 'Method Frame.waitForSelector() options', + { + actualName: 'Object', + expectedName: 'WaitForSelectorOptions', + }, + ], + [ + 'Method Frame.waitForXPath() options', + { + actualName: 'Object', + expectedName: 'WaitForSelectorOptions', + }, + ], + [ + 'Method HTTPRequest.abort() errorCode', + { + actualName: 'string', + expectedName: 'ErrorCode', + }, + ], + [ + 'Method Frame.goto() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Frame.waitForNavigation() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Frame.setContent() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Puppeteer.defaultArgs() options', + { + actualName: 'Object', + expectedName: 'ChromeArgOptions', + }, + ], + [ + 'Method Page.goBack() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.goForward() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.goto() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.reload() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.setContent() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.waitForNavigation() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method BrowserContext.overridePermissions() permissions', + { + actualName: 'Array<string>', + expectedName: 'Array<PermissionType>', + }, + ], + [ + 'Method Puppeteer.createBrowserFetcher() options', + { + actualName: 'Object', + expectedName: 'BrowserFetcherOptions', + }, + ], + [ + 'Method Page.authenticate() credentials', + { + actualName: 'Object', + expectedName: 'Credentials', + }, + ], + [ + 'Method Page.emulateMediaFeatures() features', + { + actualName: 'Array<Object>', + expectedName: 'Array<MediaFeature>', + }, + ], + [ + 'Method Page.emulate() options.viewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.setViewport() options.viewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.setViewport() viewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.connect() options.defaultViewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Puppeteer.connect() options.defaultViewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Puppeteer.launch() options.defaultViewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.launch() options.defaultViewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.goBack() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.goForward() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.reload() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.waitForNavigation() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.pdf() options', + { + actualName: 'Object', + expectedName: 'PDFOptions', + }, + ], + [ + 'Method Page.screenshot() options', + { + actualName: 'Object', + expectedName: 'ScreenshotOptions', + }, + ], + [ + 'Method Page.setContent() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.setCookie() ...cookies', + { + actualName: '...Object', + expectedName: '...CookieParam', + }, + ], + [ + 'Method Page.emulateVisionDeficiency() type', + { + actualName: 'string', + expectedName: 'Object', + }, + ], + [ + 'Method Accessibility.snapshot() options', + { + actualName: 'Object', + expectedName: 'SnapshotOptions', + }, + ], + [ + 'Method Browser.waitForTarget() options', + { + actualName: 'Object', + expectedName: 'WaitForTargetOptions', + }, + ], + [ + 'Method EventEmitter.emit() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.listenerCount() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.off() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.on() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.once() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.removeListener() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.addListener() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.removeAllListeners() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method Coverage.startCSSCoverage() options', + { + actualName: 'Object', + expectedName: 'CSSCoverageOptions', + }, + ], + [ + 'Method Coverage.startJSCoverage() options', + { + actualName: 'Object', + expectedName: 'JSCoverageOptions', + }, + ], + [ + 'Method Mouse.click() options.button', + { + actualName: '"left"|"right"|"middle"', + expectedName: 'MouseButton', + }, + ], + [ + 'Method Frame.click() options.button', + { + actualName: '"left"|"right"|"middle"', + expectedName: 'MouseButton', + }, + ], + [ + 'Method Page.click() options.button', + { + actualName: '"left"|"right"|"middle"', + expectedName: 'MouseButton', + }, + ], + [ + 'Method HTTPRequest.continue() overrides', + { + actualName: 'Object', + expectedName: 'ContinueRequestOverrides', + }, + ], + [ + 'Method HTTPRequest.respond() response', + { + actualName: 'Object', + expectedName: 'ResponseForRequest', + }, + ], + [ + 'Method Frame.addScriptTag() options', + { + actualName: 'Object', + expectedName: 'FrameAddScriptTagOptions', + }, + ], + [ + 'Method Frame.addStyleTag() options', + { + actualName: 'Object', + expectedName: 'FrameAddStyleTagOptions', + }, + ], + [ + 'Method Frame.waitForFunction() options', + { + actualName: 'Object', + expectedName: 'FrameWaitForFunctionOptions', + }, + ], + [ + 'Method BrowserContext.overridePermissions() permissions', + { + actualName: 'Array<string>', + expectedName: 'Array<Object>', + }, + ], + [ + 'Method Puppeteer.connect() options', + { + actualName: 'Object', + expectedName: 'ConnectOptions', + }, + ], + ]); + + const expectedForSource = expectedNamingMismatches.get(source); + if (!expectedForSource) return false; + + const namingMismatchIsExpected = + expectedForSource.actualName === actualName && + expectedForSource.expectedName === expectedName; + + return namingMismatchIsExpected; + } + + /** + * @param {string} source + * @param {!Documentation.Type} actual + * @param {!Documentation.Type} expected + */ + function checkType(source, actual, expected) { + // TODO(@JoelEinbinder): check functions and Serializable + if (actual.name.includes('unction') || actual.name.includes('Serializable')) + return; + // We don't have nullchecks on for TypeScript + const actualName = actual.name.replace(/[\? ]/g, ''); + // TypeScript likes to add some spaces + const expectedName = expected.name.replace(/\ /g, ''); + const namingMismatchIsExpected = namingMisMatchInTypeIsExpected( + source, + actualName, + expectedName + ); + if (expectedName !== actualName && !namingMismatchIsExpected) + errors.push(`${source} ${actualName} != ${expectedName}`); + + /* If we got a naming mismatch and it was expected, don't check the properties + * as they will likely be considered "wrong" by DocLint too. + */ + if (namingMismatchIsExpected) return; + + /* Some methods cause errors in the property checks for an unknown reason + * so we support a list of methods whose parameters are not checked. + */ + const skipPropertyChecksOnMethods = new Set([ + 'Method Page.deleteCookie() ...cookies', + 'Method Page.setCookie() ...cookies', + 'Method Puppeteer.connect() options', + ]); + if (skipPropertyChecksOnMethods.has(source)) return; + + const actualPropertiesMap = new Map( + actual.properties.map((property) => [property.name, property.type]) + ); + const expectedPropertiesMap = new Map( + expected.properties.map((property) => [property.name, property.type]) + ); + const propertiesDiff = diff( + Array.from(actualPropertiesMap.keys()).sort(), + Array.from(expectedPropertiesMap.keys()).sort() + ); + for (const propertyName of propertiesDiff.extra) + errors.push(`${source} has unexpected property ${propertyName}`); + for (const propertyName of propertiesDiff.missing) + errors.push(`${source} is missing property ${propertyName}`); + for (const propertyName of propertiesDiff.equal) + checkType( + source + '.' + propertyName, + actualPropertiesMap.get(propertyName), + expectedPropertiesMap.get(propertyName) + ); + } + + return errors; +} + +/** + * @param {!Array<string>} actual + * @param {!Array<string>} expected + * @returns {{extra: !Array<string>, missing: !Array<string>, equal: !Array<string>}} + */ +function diff(actual, expected) { + const N = actual.length; + const M = expected.length; + if (N === 0 && M === 0) return { extra: [], missing: [], equal: [] }; + if (N === 0) return { extra: [], missing: expected.slice(), equal: [] }; + if (M === 0) return { extra: actual.slice(), missing: [], equal: [] }; + const d = new Array(N); + const bt = new Array(N); + for (let i = 0; i < N; ++i) { + d[i] = new Array(M); + bt[i] = new Array(M); + for (let j = 0; j < M; ++j) { + const top = val(i - 1, j); + const left = val(i, j - 1); + if (top > left) { + d[i][j] = top; + bt[i][j] = 'extra'; + } else { + d[i][j] = left; + bt[i][j] = 'missing'; + } + const diag = val(i - 1, j - 1); + if (actual[i] === expected[j] && d[i][j] < diag + 1) { + d[i][j] = diag + 1; + bt[i][j] = 'eq'; + } + } + } + // Backtrack results. + let i = N - 1; + let j = M - 1; + const missing = []; + const extra = []; + const equal = []; + while (i >= 0 && j >= 0) { + switch (bt[i][j]) { + case 'extra': + extra.push(actual[i]); + i -= 1; + break; + case 'missing': + missing.push(expected[j]); + j -= 1; + break; + case 'eq': + equal.push(actual[i]); + i -= 1; + j -= 1; + break; + } + } + while (i >= 0) extra.push(actual[i--]); + while (j >= 0) missing.push(expected[j--]); + extra.reverse(); + missing.reverse(); + equal.reverse(); + return { extra, missing, equal }; + + function val(i, j) { + return i < 0 || j < 0 ? 0 : d[i][j]; + } +} diff --git a/remote/test/puppeteer/utils/doclint/cli.js b/remote/test/puppeteer/utils/doclint/cli.js new file mode 100755 index 0000000000..75775c65e5 --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/cli.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const puppeteer = require('../..'); +const path = require('path'); +const Source = require('./Source'); + +const PROJECT_DIR = path.join(__dirname, '..', '..'); +const VERSION = require(path.join(PROJECT_DIR, 'package.json')).version; + +const RED_COLOR = '\x1b[31m'; +const YELLOW_COLOR = '\x1b[33m'; +const RESET_COLOR = '\x1b[0m'; + +run(); + +async function run() { + const startTime = Date.now(); + + /** @type {!Array<!Message>} */ + const messages = []; + let changedFiles = false; + + if (!VERSION.endsWith('-post')) { + const versions = await Source.readFile( + path.join(PROJECT_DIR, 'versions.js') + ); + versions.setText(versions.text().replace(`, 'NEXT'],`, `, '${VERSION}'],`)); + await versions.save(); + } + + // Documentation checks. + const readme = await Source.readFile(path.join(PROJECT_DIR, 'README.md')); + const contributing = await Source.readFile( + path.join(PROJECT_DIR, 'CONTRIBUTING.md') + ); + const api = await Source.readFile(path.join(PROJECT_DIR, 'docs', 'api.md')); + const troubleshooting = await Source.readFile( + path.join(PROJECT_DIR, 'docs', 'troubleshooting.md') + ); + const mdSources = [readme, api, troubleshooting, contributing]; + + const preprocessor = require('./preprocessor'); + messages.push(...(await preprocessor.runCommands(mdSources, VERSION))); + messages.push( + ...(await preprocessor.ensureReleasedAPILinks([readme], VERSION)) + ); + + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const checkPublicAPI = require('./check_public_api'); + const tsSources = [ + /* Source.readdir doesn't deal with nested directories well. + * Rather than invest time here when we're going to remove this Doc tooling soon + * we'll just list the directories manually. + */ + ...(await Source.readdir(path.join(PROJECT_DIR, 'src'), 'ts')), + ...(await Source.readdir(path.join(PROJECT_DIR, 'src', 'common'), 'ts')), + ...(await Source.readdir(path.join(PROJECT_DIR, 'src', 'node'), 'ts')), + ]; + + const tsSourcesNoDefinitions = tsSources.filter( + (source) => !source.filePath().endsWith('.d.ts') + ); + + const jsSources = [ + ...(await Source.readdir(path.join(PROJECT_DIR, 'lib'))), + ...(await Source.readdir(path.join(PROJECT_DIR, 'lib', 'cjs'))), + ...(await Source.readdir( + path.join(PROJECT_DIR, 'lib', 'cjs', 'puppeteer', 'common') + )), + ...(await Source.readdir( + path.join(PROJECT_DIR, 'lib', 'cjs', 'puppeteer', 'node') + )), + ]; + const allSrcCode = [...jsSources, ...tsSourcesNoDefinitions]; + messages.push(...(await checkPublicAPI(page, mdSources, allSrcCode))); + + await browser.close(); + + for (const source of mdSources) { + if (!source.hasUpdatedText()) continue; + await source.save(); + changedFiles = true; + } + + // Report results. + const errors = messages.filter((message) => message.type === 'error'); + if (errors.length) { + console.log('DocLint Failures:'); + for (let i = 0; i < errors.length; ++i) { + let error = errors[i].text; + error = error.split('\n').join('\n '); + console.log(` ${i + 1}) ${RED_COLOR}${error}${RESET_COLOR}`); + } + } + const warnings = messages.filter((message) => message.type === 'warning'); + if (warnings.length) { + console.log('DocLint Warnings:'); + for (let i = 0; i < warnings.length; ++i) { + let warning = warnings[i].text; + warning = warning.split('\n').join('\n '); + console.log(` ${i + 1}) ${YELLOW_COLOR}${warning}${RESET_COLOR}`); + } + } + let clearExit = messages.length === 0; + if (changedFiles) { + if (clearExit) + console.log(`${YELLOW_COLOR}Some files were updated.${RESET_COLOR}`); + clearExit = false; + } + console.log(`${errors.length} failures, ${warnings.length} warnings.`); + + if (!clearExit && !process.env.TRAVIS) + console.log( + '\nIs your lib/ directory up to date? You might need to `npm run tsc`.\n' + ); + + const runningTime = Date.now() - startTime; + console.log(`DocLint Finished in ${runningTime / 1000} seconds`); + process.exit(clearExit ? 0 : 1); +} diff --git a/remote/test/puppeteer/utils/doclint/preprocessor/index.js b/remote/test/puppeteer/utils/doclint/preprocessor/index.js new file mode 100644 index 0000000000..340d973c9a --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/preprocessor/index.js @@ -0,0 +1,165 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const Message = require('../Message'); + +module.exports.ensureReleasedAPILinks = function (sources, version) { + // Release version is everything that doesn't include "-". + const apiLinkRegex = /https:\/\/github.com\/puppeteer\/puppeteer\/blob\/v[^/]*\/docs\/api.md/gi; + const lastReleasedAPI = `https://github.com/puppeteer/puppeteer/blob/v${ + version.split('-')[0] + }/docs/api.md`; + + const messages = []; + for (const source of sources) { + const text = source.text(); + const newText = text.replace(apiLinkRegex, lastReleasedAPI); + if (source.setText(newText)) + messages.push(Message.warning(`GEN: updated ${source.projectPath()}`)); + } + return messages; +}; + +module.exports.runCommands = function (sources, version) { + // Release version is everything that doesn't include "-". + const isReleaseVersion = !version.includes('-'); + + const messages = []; + const commands = []; + for (const source of sources) { + const text = source.text(); + const commandStartRegex = /<!--\s*gen:([a-z-]+)\s*-->/gi; + const commandEndRegex = /<!--\s*gen:stop\s*-->/gi; + let start; + + while ((start = commandStartRegex.exec(text))) { + // eslint-disable-line no-cond-assign + commandEndRegex.lastIndex = commandStartRegex.lastIndex; + const end = commandEndRegex.exec(text); + if (!end) { + messages.push( + Message.error(`Failed to find 'gen:stop' for command ${start[0]}`) + ); + return messages; + } + const name = start[1]; + const from = commandStartRegex.lastIndex; + const to = end.index; + const originalText = text.substring(from, to); + commands.push({ name, from, to, originalText, source }); + commandStartRegex.lastIndex = commandEndRegex.lastIndex; + } + } + + const changedSources = new Set(); + // Iterate commands in reverse order so that edits don't conflict. + commands.sort((a, b) => b.from - a.from); + for (const command of commands) { + let newText = null; + if (command.name === 'version') + newText = isReleaseVersion ? 'v' + version : 'Tip-Of-Tree'; + else if (command.name === 'empty-if-release') + newText = isReleaseVersion ? '' : command.originalText; + else if (command.name === 'toc') + newText = generateTableOfContents( + command.source.text().substring(command.to) + ); + else if (command.name === 'versions-per-release') + newText = generateVersionsPerRelease(); + if (newText === null) + messages.push(Message.error(`Unknown command 'gen:${command.name}'`)); + else if (applyCommand(command, newText)) changedSources.add(command.source); + } + for (const source of changedSources) + messages.push(Message.warning(`GEN: updated ${source.projectPath()}`)); + return messages; +}; + +/** + * @param {{name: string, from: number, to: number, source: !Source}} command + * @param {string} editText + * @returns {boolean} + */ +function applyCommand(command, editText) { + const text = command.source.text(); + const newText = + text.substring(0, command.from) + editText + text.substring(command.to); + return command.source.setText(newText); +} + +function generateTableOfContents(mdText) { + const ids = new Set(); + const titles = []; + let insideCodeBlock = false; + for (const aLine of mdText.split('\n')) { + const line = aLine.trim(); + if (line.startsWith('```')) { + insideCodeBlock = !insideCodeBlock; + continue; + } + if (!insideCodeBlock && line.startsWith('#')) titles.push(line); + } + const tocEntries = []; + for (const title of titles) { + const [, nesting, name] = title.match(/^(#+)\s+(.*)$/); + const delinkifiedName = name.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); + const id = delinkifiedName + .trim() + .toLowerCase() + .replace(/\s/g, '-') + .replace(/[^-0-9a-zа-яё]/gi, ''); + let dedupId = id; + let counter = 0; + while (ids.has(dedupId)) dedupId = id + '-' + ++counter; + ids.add(dedupId); + tocEntries.push({ + level: nesting.length, + name: delinkifiedName, + id: dedupId, + }); + } + + const minLevel = Math.min(...tocEntries.map((entry) => entry.level)); + tocEntries.forEach((entry) => (entry.level -= minLevel)); + return ( + '\n' + + tocEntries + .map((entry) => { + const prefix = entry.level % 2 === 0 ? '-' : '*'; + const padding = ' '.repeat(entry.level); + return `${padding}${prefix} [${entry.name}](#${entry.id})`; + }) + .join('\n') + + '\n' + ); +} + +const generateVersionsPerRelease = () => { + const versionsPerRelease = require('../../../versions.js'); + const buffer = ['- Releases per Chromium version:']; + for (const [chromiumVersion, puppeteerVersion] of versionsPerRelease) { + if (puppeteerVersion === 'NEXT') continue; + buffer.push( + ` * Chromium ${chromiumVersion} - [Puppeteer ${puppeteerVersion}](https://github.com/puppeteer/puppeteer/blob/${puppeteerVersion}/docs/api.md)` + ); + } + buffer.push( + ` * [All releases](https://github.com/puppeteer/puppeteer/releases)` + ); + + const output = '\n' + buffer.join('\n') + '\n'; + return output; +}; diff --git a/remote/test/puppeteer/utils/doclint/preprocessor/preprocessor.spec.js b/remote/test/puppeteer/utils/doclint/preprocessor/preprocessor.spec.js new file mode 100644 index 0000000000..713785db8f --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/preprocessor/preprocessor.spec.js @@ -0,0 +1,248 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { runCommands, ensureReleasedAPILinks } = require('.'); +const Source = require('../Source'); +const expect = require('expect'); + +describe('doclint preprocessor specs', function () { + describe('ensureReleasedAPILinks', function () { + it('should work with non-release version', function () { + const source = new Source( + 'doc.md', + ` + [API](https://github.com/puppeteer/puppeteer/blob/v1.1.0/docs/api.md#class-page) + ` + ); + const messages = ensureReleasedAPILinks([source], '1.3.0-post'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + [API](https://github.com/puppeteer/puppeteer/blob/v1.3.0/docs/api.md#class-page) + `); + }); + it('should work with release version', function () { + const source = new Source( + 'doc.md', + ` + [API](https://github.com/puppeteer/puppeteer/blob/v1.1.0/docs/api.md#class-page) + ` + ); + const messages = ensureReleasedAPILinks([source], '1.3.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + [API](https://github.com/puppeteer/puppeteer/blob/v1.3.0/docs/api.md#class-page) + `); + }); + it('should keep main branch links intact', function () { + const source = new Source( + 'doc.md', + ` + [API](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#class-page) + ` + ); + const messages = ensureReleasedAPILinks([source], '1.3.0'); + expect(messages.length).toBe(0); + expect(source.text()).toBe(` + [API](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#class-page) + `); + }); + }); + + describe('runCommands', function () { + it('should throw for unknown command', function () { + const source = new Source( + 'doc.md', + ` + <!-- gen:unknown-command -->something<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.1.1'); + expect(source.hasUpdatedText()).toBe(false); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('error'); + expect(messages[0].text).toContain('Unknown command'); + }); + describe('gen:version', function () { + it('should work', function () { + const source = new Source( + 'doc.md', + ` + Puppeteer <!-- gen:version -->XXX<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.2.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + Puppeteer <!-- gen:version -->v1.2.0<!-- gen:stop --> + `); + }); + it('should work for *-post versions', function () { + const source = new Source( + 'doc.md', + ` + Puppeteer <!-- gen:version -->XXX<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.2.0-post'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + Puppeteer <!-- gen:version -->Tip-Of-Tree<!-- gen:stop --> + `); + }); + it('should tolerate different writing', function () { + const source = new Source( + 'doc.md', + `Puppeteer v<!-- gEn:version -->WHAT +<!-- GEN:stop -->` + ); + runCommands([source], '1.1.1'); + expect(source.text()).toBe( + `Puppeteer v<!-- gEn:version -->v1.1.1<!-- GEN:stop -->` + ); + }); + it('should not tolerate missing gen:stop', function () { + const source = new Source('doc.md', `<!--GEN:version-->`); + const messages = runCommands([source], '1.2.0'); + expect(source.hasUpdatedText()).toBe(false); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('error'); + expect(messages[0].text).toContain(`Failed to find 'gen:stop'`); + }); + }); + describe('gen:empty-if-release', function () { + it('should clear text when release version', function () { + const source = new Source( + 'doc.md', + ` + <!-- gen:empty-if-release -->XXX<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.1.1'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + <!-- gen:empty-if-release --><!-- gen:stop --> + `); + }); + it('should keep text when non-release version', function () { + const source = new Source( + 'doc.md', + ` + <!-- gen:empty-if-release -->XXX<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.1.1-post'); + expect(messages.length).toBe(0); + expect(source.text()).toBe(` + <!-- gen:empty-if-release -->XXX<!-- gen:stop --> + `); + }); + }); + describe('gen:toc', function () { + it('should work', () => { + const source = new Source( + 'doc.md', + `<!-- gen:toc -->XXX<!-- gen:stop --> + ### class: page + #### page.$ + #### page.$$` + ); + const messages = runCommands([source], '1.3.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(`<!-- gen:toc --> +- [class: page](#class-page) + * [page.$](#page) + * [page.$$](#page-1) +<!-- gen:stop --> + ### class: page + #### page.$ + #### page.$$`); + }); + it('should work with code blocks', () => { + const source = new Source( + 'doc.md', + `<!-- gen:toc -->XXX<!-- gen:stop --> + ### class: page + + \`\`\`bash + # yo comment + \`\`\` + ` + ); + const messages = runCommands([source], '1.3.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(`<!-- gen:toc --> +- [class: page](#class-page) +<!-- gen:stop --> + ### class: page + + \`\`\`bash + # yo comment + \`\`\` + `); + }); + it('should work with links in titles', () => { + const source = new Source( + 'doc.md', + `<!-- gen:toc -->XXX<!-- gen:stop --> + ### some [link](#foobar) here + ` + ); + const messages = runCommands([source], '1.3.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(`<!-- gen:toc --> +- [some link here](#some-link-here) +<!-- gen:stop --> + ### some [link](#foobar) here + `); + }); + }); + it('should work with multiple commands', function () { + const source = new Source( + 'doc.md', + ` + <!-- gen:version -->XXX<!-- gen:stop --> + <!-- gen:empty-if-release -->YYY<!-- gen:stop --> + <!-- gen:version -->ZZZ<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.1.1'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + <!-- gen:version -->v1.1.1<!-- gen:stop --> + <!-- gen:empty-if-release --><!-- gen:stop --> + <!-- gen:version -->v1.1.1<!-- gen:stop --> + `); + }); + }); +}); diff --git a/remote/test/puppeteer/utils/fetch_devices.js b/remote/test/puppeteer/utils/fetch_devices.js new file mode 100755 index 0000000000..547b68842e --- /dev/null +++ b/remote/test/puppeteer/utils/fetch_devices.js @@ -0,0 +1,282 @@ +#!/usr/bin/env node +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const util = require('util'); +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('..'); +const DEVICES_URL = + 'https://raw.githubusercontent.com/ChromeDevTools/devtools-frontend/master/front_end/emulated_devices/module.json'; + +const template = `/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = %s; +for (const device of module.exports) + module.exports[device.name] = device; +`; + +const help = `Usage: node ${path.basename(__filename)} [-u <from>] <outputPath> + -u, --url The URL to load devices descriptor from. If not set, + devices will be fetched from the tip-of-tree of DevTools + frontend. + + -h, --help Show this help message + +Fetch Chrome DevTools front-end emulation devices from given URL, convert them to puppeteer +devices and save to the <outputPath>. +`; + +const argv = require('minimist')(process.argv.slice(2), { + alias: { u: 'url', h: 'help' }, +}); + +if (argv.help) { + console.log(help); + return; +} + +const url = argv.url || DEVICES_URL; +const outputPath = argv._[0]; +if (!outputPath) { + console.log('ERROR: output file name is missing. Use --help for help.'); + return; +} + +main(url); + +async function main(url) { + const browser = await puppeteer.launch(); + const chromeVersion = (await browser.version()).split('/').pop(); + await browser.close(); + console.log('GET ' + url); + const text = await httpGET(url); + let json = null; + try { + json = JSON.parse(text); + } catch (error) { + console.error(`FAILED: error parsing response - ${error.message}`); + return; + } + const devicePayloads = json.extensions + .filter((extension) => extension.type === 'emulated-device') + .map((extension) => extension.device); + let devices = []; + for (const payload of devicePayloads) { + let names = []; + if (payload.title === 'iPhone 6/7/8') + names = ['iPhone 6', 'iPhone 7', 'iPhone 8']; + else if (payload.title === 'iPhone 6/7/8 Plus') + names = ['iPhone 6 Plus', 'iPhone 7 Plus', 'iPhone 8 Plus']; + else if (payload.title === 'iPhone 5/SE') names = ['iPhone 5', 'iPhone SE']; + else names = [payload.title]; + for (const name of names) { + const device = createDevice(chromeVersion, name, payload, false); + const landscape = createDevice(chromeVersion, name, payload, true); + devices.push(device); + if ( + landscape.viewport.width !== device.viewport.width || + landscape.viewport.height !== device.viewport.height + ) + devices.push(landscape); + } + } + devices = devices.filter((device) => device.viewport.isMobile); + devices.sort((a, b) => a.name.localeCompare(b.name)); + // Use single-quotes instead of double-quotes to conform with codestyle. + const serialized = JSON.stringify(devices, null, 2) + .replace(/'/g, `\\'`) + .replace(/"/g, `'`); + const result = util.format(template, serialized); + fs.writeFileSync(outputPath, result, 'utf8'); +} + +/** + * @param {string} chromeVersion + * @param {string} deviceName + * @param {*} descriptor + * @param {boolean} landscape + * @returns {!Object} + */ +function createDevice(chromeVersion, deviceName, descriptor, landscape) { + const devicePayload = loadFromJSONV1(descriptor); + const viewportPayload = landscape + ? devicePayload.horizontal + : devicePayload.vertical; + return { + name: deviceName + (landscape ? ' landscape' : ''), + userAgent: devicePayload.userAgent.includes('%s') + ? util.format(devicePayload.userAgent, chromeVersion) + : devicePayload.userAgent, + viewport: { + width: viewportPayload.width, + height: viewportPayload.height, + deviceScaleFactor: devicePayload.deviceScaleFactor, + isMobile: devicePayload.capabilities.includes('mobile'), + hasTouch: devicePayload.capabilities.includes('touch'), + isLandscape: landscape || false, + }, + }; +} + +/** + * @param {*} json + * @returns {?Object} + */ +function loadFromJSONV1(json) { + /** + * @param {*} object + * @param {string} key + * @param {string} type + * @param {*=} defaultValue + * @returns {*} + */ + function parseValue(object, key, type, defaultValue) { + if ( + typeof object !== 'object' || + object === null || + !object.hasOwnProperty(key) + ) { + if (typeof defaultValue !== 'undefined') return defaultValue; + throw new Error( + "Emulated device is missing required property '" + key + "'" + ); + } + const value = object[key]; + if (typeof value !== type || value === null) + throw new Error( + "Emulated device property '" + + key + + "' has wrong type '" + + typeof value + + "'" + ); + return value; + } + + /** + * @param {*} object + * @param {string} key + * @returns {number} + */ + function parseIntValue(object, key) { + const value = /** @type {number} */ (parseValue(object, key, 'number')); + if (value !== Math.abs(value)) + throw new Error("Emulated device value '" + key + "' must be integer"); + return value; + } + + /** + * @param {*} json + * @returns {!{width: number, height: number}} + */ + function parseOrientation(json) { + const result = {}; + const minDeviceSize = 50; + const maxDeviceSize = 9999; + result.width = parseIntValue(json, 'width'); + if ( + result.width < 0 || + result.width > maxDeviceSize || + result.width < minDeviceSize + ) + throw new Error('Emulated device has wrong width: ' + result.width); + + result.height = parseIntValue(json, 'height'); + if ( + result.height < 0 || + result.height > maxDeviceSize || + result.height < minDeviceSize + ) + throw new Error('Emulated device has wrong height: ' + result.height); + + return /** @type {!{width: number, height: number}} */ (result); + } + + const result = {}; + result.type = /** @type {string} */ (parseValue(json, 'type', 'string')); + result.userAgent = /** @type {string} */ (parseValue( + json, + 'user-agent', + 'string' + )); + + const capabilities = parseValue(json, 'capabilities', 'object', []); + if (!Array.isArray(capabilities)) + throw new Error('Emulated device capabilities must be an array'); + result.capabilities = []; + for (let i = 0; i < capabilities.length; ++i) { + if (typeof capabilities[i] !== 'string') + throw new Error('Emulated device capability must be a string'); + result.capabilities.push(capabilities[i]); + } + + result.deviceScaleFactor = /** @type {number} */ (parseValue( + json['screen'], + 'device-pixel-ratio', + 'number' + )); + if (result.deviceScaleFactor < 0 || result.deviceScaleFactor > 100) + throw new Error( + 'Emulated device has wrong deviceScaleFactor: ' + result.deviceScaleFactor + ); + + result.vertical = parseOrientation( + parseValue(json['screen'], 'vertical', 'object') + ); + result.horizontal = parseOrientation( + parseValue(json['screen'], 'horizontal', 'object') + ); + return result; +} + +/** + * @param {url} + * @returns {!Promise} + */ +function httpGET(url) { + let fulfill, reject; + const promise = new Promise((res, rej) => { + fulfill = res; + reject = rej; + }); + const driver = url.startsWith('https://') + ? require('https') + : require('http'); + const request = driver.get(url, (response) => { + let data = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => (data += chunk)); + response.on('end', () => fulfill(data)); + response.on('error', reject); + }); + request.on('error', reject); + return promise; +} diff --git a/remote/test/puppeteer/utils/prepare_puppeteer_core.js b/remote/test/puppeteer/utils/prepare_puppeteer_core.js new file mode 100755 index 0000000000..e1e9a64f2f --- /dev/null +++ b/remote/test/puppeteer/utils/prepare_puppeteer_core.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const path = require('path'); + +const packagePath = path.join(__dirname, '..', 'package.json'); +const json = require(packagePath); + +json.name = 'puppeteer-core'; +delete json.scripts.install; +json.main = './cjs-entry-core.js'; +fs.writeFileSync(packagePath, JSON.stringify(json, null, ' ')); diff --git a/remote/test/puppeteer/utils/testserver/LICENSE b/remote/test/puppeteer/utils/testserver/LICENSE new file mode 100644 index 0000000000..afdfe50e72 --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/remote/test/puppeteer/utils/testserver/README.md b/remote/test/puppeteer/utils/testserver/README.md new file mode 100644 index 0000000000..a849eb873d --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/README.md @@ -0,0 +1,18 @@ +# TestServer + +This test server is used internally by Puppeteer to test Puppeteer itself. + +### Example + +```js +const {TestServer} = require('@pptr/testserver'); + +(async(() => { + const httpServer = await TestServer.create(__dirname, 8000), + const httpsServer = await TestServer.createHTTPS(__dirname, 8001) + httpServer.setRoute('/hello', (req, res) => { + res.end('Hello, world!'); + }); + console.log('HTTP and HTTPS servers are running!'); +})(); +``` diff --git a/remote/test/puppeteer/utils/testserver/cert.pem b/remote/test/puppeteer/utils/testserver/cert.pem new file mode 100644 index 0000000000..fd3838535a --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWDCCAkCgAwIBAgIUM8Tmw+D1j+eVz9x9So4zRVqFsKowDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPcHVwcGV0ZWVyLXRlc3RzMB4XDTIwMDUxMzA4MDQyOVoX +DTMwMDUxMTA4MDQyOVowGjEYMBYGA1UEAwwPcHVwcGV0ZWVyLXRlc3RzMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApWbbhgc6CnWywd8xGETT1mfLi3wi +KIbpAUHghLF4sj0jXz8vLh/4oicpQ12d6bsz+IAi7qrdXNh11P5nEej6/Gx4fWzB +gGdrJFGPqsvXuhYdzZAmy6xOaWcLIJeQ543bXv3YeST7EGRXJBc/ocTo2jIGTGjq +hksFaid910VQlX3KGOLTDMUCk00TeEYBTTUx47PWoIsxVqbl2RzVXRSWL5hlPWlW +29/BQtBGmsXxZyWtqqHudiUulGBSr4LcPyicZLI8nqCqD0ioS0TEmGh61nRBuwBa +xmLCvPmpt0+sDuOU+1bme3w8juvTVToBIFxGB86rADd3ys+8NeZzXqi+bQIDAQAB +o4GVMIGSMB0GA1UdDgQWBBT/m3vdkZpQyVQFdYrKHVoAHXDFODAfBgNVHSMEGDAW +gBT/m3vdkZpQyVQFdYrKHVoAHXDFODAPBgNVHRMBAf8EBTADAQH/MD8GA1UdEQQ4 +MDaCGHd3dy5wdXBwZXRlZXItdGVzdHMudGVzdIIad3d3LnB1cHBldGVlci10ZXN0 +cy0xLnRlc3QwDQYJKoZIhvcNAQELBQADggEBAI1qp5ZppV1R3e8XxzwwkFDPFN8W +Pe3AoqhAKyJnJl1NUn9q3sroEeSQRhODWUHCd7lENzhsT+3mzonNNkN9B/hq0rpK +KHHczXILDqdyuxH3LxQ1VHGE8VN2NbdkfobtzAsA3woiJxOuGeusXJnKB4kJQeIP +V+BMEZWeaSDC2PREkG7GOezmE1/WDUCYaorPw2whdCA5wJvTW3zXpJjYhfsld+5z +KuErx4OCxRJij73/BD9SpLxDEY1cdl819F1IvxsRGhmTIaSly2hQLrhOgo1jgZtV +FGCa6DSlXnQGLaV+N+ssR0lkCksNrNBVDfA1bP5bT/4VCcwUWwm9TUeF0Qo= +-----END CERTIFICATE----- diff --git a/remote/test/puppeteer/utils/testserver/index.js b/remote/test/puppeteer/utils/testserver/index.js new file mode 100644 index 0000000000..7d21009c94 --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/index.js @@ -0,0 +1,284 @@ +// @ts-nocheck + +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const http = require('http'); +const https = require('https'); +const url = require('url'); +const fs = require('fs'); +const path = require('path'); +const mime = require('mime'); +const WebSocketServer = require('ws').Server; + +const fulfillSymbol = Symbol('fullfil callback'); +const rejectSymbol = Symbol('reject callback'); + +class TestServer { + PORT = undefined; + PREFIX = undefined; + CROSS_PROCESS_PREFIX = undefined; + EMPTY_PAGE = undefined; + + /** + * @param {string} dirPath + * @param {number} port + * @returns {!TestServer} + */ + static async create(dirPath, port) { + const server = new TestServer(dirPath, port); + await new Promise((x) => server._server.once('listening', x)); + return server; + } + + /** + * @param {string} dirPath + * @param {number} port + * @returns {!TestServer} + */ + static async createHTTPS(dirPath, port) { + const server = new TestServer(dirPath, port, { + key: fs.readFileSync(path.join(__dirname, 'key.pem')), + cert: fs.readFileSync(path.join(__dirname, 'cert.pem')), + passphrase: 'aaaa', + }); + await new Promise((x) => server._server.once('listening', x)); + return server; + } + + /** + * @param {string} dirPath + * @param {number} port + * @param {!Object=} sslOptions + */ + constructor(dirPath, port, sslOptions) { + if (sslOptions) + this._server = https.createServer(sslOptions, this._onRequest.bind(this)); + else this._server = http.createServer(this._onRequest.bind(this)); + this._server.on('connection', (socket) => this._onSocket(socket)); + this._wsServer = new WebSocketServer({ server: this._server }); + this._wsServer.on('connection', this._onWebSocketConnection.bind(this)); + this._server.listen(port); + this._dirPath = dirPath; + + this._startTime = new Date(); + this._cachedPathPrefix = null; + + /** @type {!Set<!net.Socket>} */ + this._sockets = new Set(); + + /** @type {!Map<string, function(!IncomingMessage, !ServerResponse)>} */ + this._routes = new Map(); + /** @type {!Map<string, !{username:string, password:string}>} */ + this._auths = new Map(); + /** @type {!Map<string, string>} */ + this._csp = new Map(); + /** @type {!Set<string>} */ + this._gzipRoutes = new Set(); + /** @type {!Map<string, !Promise>} */ + this._requestSubscribers = new Map(); + } + + _onSocket(socket) { + this._sockets.add(socket); + // ECONNRESET is a legit error given + // that tab closing simply kills process. + socket.on('error', (error) => { + if (error.code !== 'ECONNRESET') throw error; + }); + socket.once('close', () => this._sockets.delete(socket)); + } + + /** + * @param {string} pathPrefix + */ + enableHTTPCache(pathPrefix) { + this._cachedPathPrefix = pathPrefix; + } + + /** + * @param {string} path + * @param {string} username + * @param {string} password + */ + setAuth(path, username, password) { + this._auths.set(path, { username, password }); + } + + enableGzip(path) { + this._gzipRoutes.add(path); + } + + /** + * @param {string} path + * @param {string} csp + */ + setCSP(path, csp) { + this._csp.set(path, csp); + } + + async stop() { + this.reset(); + for (const socket of this._sockets) socket.destroy(); + this._sockets.clear(); + await new Promise((x) => this._server.close(x)); + } + + /** + * @param {string} path + * @param {function(!IncomingMessage, !ServerResponse)} handler + */ + setRoute(path, handler) { + this._routes.set(path, handler); + } + + /** + * @param {string} from + * @param {string} to + */ + setRedirect(from, to) { + this.setRoute(from, (req, res) => { + res.writeHead(302, { location: to }); + res.end(); + }); + } + + /** + * @param {string} path + * @returns {!Promise<!IncomingMessage>} + */ + waitForRequest(path) { + let promise = this._requestSubscribers.get(path); + if (promise) return promise; + let fulfill, reject; + promise = new Promise((f, r) => { + fulfill = f; + reject = r; + }); + promise[fulfillSymbol] = fulfill; + promise[rejectSymbol] = reject; + this._requestSubscribers.set(path, promise); + return promise; + } + + reset() { + this._routes.clear(); + this._auths.clear(); + this._csp.clear(); + this._gzipRoutes.clear(); + const error = new Error('Static Server has been reset'); + for (const subscriber of this._requestSubscribers.values()) + subscriber[rejectSymbol].call(null, error); + this._requestSubscribers.clear(); + } + + _onRequest(request, response) { + request.on('error', (error) => { + if (error.code === 'ECONNRESET') response.end(); + else throw error; + }); + request.postBody = new Promise((resolve) => { + let body = ''; + request.on('data', (chunk) => (body += chunk)); + request.on('end', () => resolve(body)); + }); + const pathName = url.parse(request.url).path; + if (this._auths.has(pathName)) { + const auth = this._auths.get(pathName); + const credentials = Buffer.from( + (request.headers.authorization || '').split(' ')[1] || '', + 'base64' + ).toString(); + if (credentials !== `${auth.username}:${auth.password}`) { + response.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="Secure Area"', + }); + response.end('HTTP Error 401 Unauthorized: Access is denied'); + return; + } + } + // Notify request subscriber. + if (this._requestSubscribers.has(pathName)) { + this._requestSubscribers.get(pathName)[fulfillSymbol].call(null, request); + this._requestSubscribers.delete(pathName); + } + const handler = this._routes.get(pathName); + if (handler) { + handler.call(null, request, response); + } else { + const pathName = url.parse(request.url).path; + this.serveFile(request, response, pathName); + } + } + + /** + * @param {!IncomingMessage} request + * @param {!ServerResponse} response + * @param {string} pathName + */ + serveFile(request, response, pathName) { + if (pathName === '/') pathName = '/index.html'; + const filePath = path.join(this._dirPath, pathName.substring(1)); + + if ( + this._cachedPathPrefix !== null && + filePath.startsWith(this._cachedPathPrefix) + ) { + if (request.headers['if-modified-since']) { + response.statusCode = 304; // not modified + response.end(); + return; + } + response.setHeader('Cache-Control', 'public, max-age=31536000'); + response.setHeader('Last-Modified', this._startTime.toISOString()); + } else { + response.setHeader('Cache-Control', 'no-cache, no-store'); + } + if (this._csp.has(pathName)) + response.setHeader('Content-Security-Policy', this._csp.get(pathName)); + + fs.readFile(filePath, (err, data) => { + if (err) { + response.statusCode = 404; + response.end(`File not found: ${filePath}`); + return; + } + const mimeType = mime.getType(filePath); + const isTextEncoding = /^text\/|^application\/(javascript|json)/.test( + mimeType + ); + const contentType = isTextEncoding + ? `${mimeType}; charset=utf-8` + : mimeType; + response.setHeader('Content-Type', contentType); + if (this._gzipRoutes.has(pathName)) { + response.setHeader('Content-Encoding', 'gzip'); + const zlib = require('zlib'); + zlib.gzip(data, (_, result) => { + response.end(result); + }); + } else { + response.end(data); + } + }); + } + + _onWebSocketConnection(connection) { + connection.send('opened'); + } +} + +module.exports = { TestServer }; diff --git a/remote/test/puppeteer/utils/testserver/key.pem b/remote/test/puppeteer/utils/testserver/key.pem new file mode 100644 index 0000000000..cbc3acb229 --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQClZtuGBzoKdbLB +3zEYRNPWZ8uLfCIohukBQeCEsXiyPSNfPy8uH/iiJylDXZ3puzP4gCLuqt1c2HXU +/mcR6Pr8bHh9bMGAZ2skUY+qy9e6Fh3NkCbLrE5pZwsgl5Dnjdte/dh5JPsQZFck +Fz+hxOjaMgZMaOqGSwVqJ33XRVCVfcoY4tMMxQKTTRN4RgFNNTHjs9agizFWpuXZ +HNVdFJYvmGU9aVbb38FC0EaaxfFnJa2qoe52JS6UYFKvgtw/KJxksjyeoKoPSKhL +RMSYaHrWdEG7AFrGYsK8+am3T6wO45T7VuZ7fDyO69NVOgEgXEYHzqsAN3fKz7w1 +5nNeqL5tAgMBAAECggEAKPveo0xBHnxhidZzBM9xKixX7D0a/a3IKI6ZQmfzPz8U +97HhT+2OHyfS+qVEzribPRULEtZ1uV7Ne7R5958iKc/63yFGpTl6++nVzn1p++sl +AV2Zr1gHqehlgnLr7eRhmh0OOZ5nM32ZdhDorH3tMLu6gc5xZktKkS4t6Vx8hj3a +Docx+rbawp8GRd0p7I6vzIE3bsDab8hC+RTRO63q2G0BqgKwV9ZNtJxQgcDJ5L8N +6gtM2z5nKXAIOCbCQYa1PsrDh3IRA/ZNxEeA9G3YQjwlZYCWmdRRplgDraYxcTBO +oQGjaLwICNdcprMacPD6cCSgrI+PadzyMsAuk9SgpQKBgQDO9PT4gK40Pm+Damxv ++tWYBFmvn3vasmyolc1zVDltsxQbQTjKhVpTLXTTGmrIhDXEIIV9I4rg164WptQs +6Brp2EwYR7ZJIrjvXs/9i2QTW1ZXvhdiWpB3s+RXD5VHGovHUadcI6wOgw2Cl+Jk +zXjSIgyXKM99N1MAonuR7DyzTwKBgQDMmPX+9vWZMpS/gc6JLQiPPoGszE6tYjXg +W3LpRUNqmO0/bDDjslbebDgrGAmhlkJlxzH6gz96VmGm1evEGPEet3euy8S9zuM3 +LCgEM9Ulqa3JbInwtKupmKv76Im+XWLLSxAXbfiel1zFRRwxI99A3ad0QRZ6Bov5 +3cHJBwvzgwKBgAU5HW2gIcVjxgC1EOOKmxVpFrJd/gw48JEYpsTAXWqtWFaPwNUr +pGnw/b/OLN++pnS6tWPBH+Ioz1X3A+fWO8enE9SRCsKxw6UW6XzmpbHvXjB8ta5f +xsGeoqan2AahXuG659RlehQrro2bM7WDkgcLoPG3r/TjDo83ipLWOXn1AoGAKWiL +4R56dpcWI+xRsNG8ecFc3Ww8QDswTEg16aBrFJf+7GcpPexKSJn+hDpJOLsAlTjL +lLgbkNcKzIlfPkEOC/l175quJvxIYFI/hxo2eXjuA2ZERMNMOvb7V/CocC7WX+7B +Qvyu5OodjI+ANTHdbXNvAMhrlCbfDaMkJVuXv6ECgYBzvY4aYmVoFsr+72/EfLls +Dz9pi55tUUWc61w6ovd+iliawvXeGi4wibtTH4iGj/C2sJIaMmOD99NQ7Oi/x89D +oMgSUemkoFL8FGsZGyZ7szqxyON1jP42Bm2MQrW5kIf7Y4yaIGhoak5JNxn2JUyV +gupVbY1mQ1GTPByxHeLh1w== +-----END PRIVATE KEY----- diff --git a/remote/test/puppeteer/utils/testserver/package.json b/remote/test/puppeteer/utils/testserver/package.json new file mode 100644 index 0000000000..6cac90ffc5 --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/package.json @@ -0,0 +1,15 @@ +{ + "name": "@pptr/testserver", + "version": "0.5.0", + "description": "testing server", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/utils/testserver" + }, + "author": "The Chromium Authors", + "license": "Apache-2.0" +} |