summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/utils
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--remote/test/puppeteer/utils/ESTreeWalker.js135
-rw-r--r--remote/test/puppeteer/utils/apply_next_version.js32
-rwxr-xr-xremote/test/puppeteer/utils/bisect.js229
-rwxr-xr-xremote/test/puppeteer/utils/check_availability.js298
-rw-r--r--remote/test/puppeteer/utils/doclint/.gitignore1
-rw-r--r--remote/test/puppeteer/utils/doclint/Message.js44
-rw-r--r--remote/test/puppeteer/utils/doclint/README.md31
-rw-r--r--remote/test/puppeteer/utils/doclint/Source.js117
-rw-r--r--remote/test/puppeteer/utils/doclint/check_public_api/Documentation.js157
-rw-r--r--remote/test/puppeteer/utils/doclint/check_public_api/JSBuilder.js279
-rw-r--r--remote/test/puppeteer/utils/doclint/check_public_api/MDBuilder.js402
-rw-r--r--remote/test/puppeteer/utils/doclint/check_public_api/index.js977
-rwxr-xr-xremote/test/puppeteer/utils/doclint/cli.js136
-rw-r--r--remote/test/puppeteer/utils/doclint/preprocessor/index.js165
-rw-r--r--remote/test/puppeteer/utils/doclint/preprocessor/preprocessor.spec.js248
-rwxr-xr-xremote/test/puppeteer/utils/fetch_devices.js282
-rwxr-xr-xremote/test/puppeteer/utils/prepare_puppeteer_core.js27
-rw-r--r--remote/test/puppeteer/utils/testserver/LICENSE202
-rw-r--r--remote/test/puppeteer/utils/testserver/README.md18
-rw-r--r--remote/test/puppeteer/utils/testserver/cert.pem20
-rw-r--r--remote/test/puppeteer/utils/testserver/index.js284
-rw-r--r--remote/test/puppeteer/utils/testserver/key.pem28
-rw-r--r--remote/test/puppeteer/utils/testserver/package.json15
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"
+}