summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/utils/doclint
diff options
context:
space:
mode:
Diffstat (limited to 'remote/test/puppeteer/utils/doclint')
-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
11 files changed, 2557 insertions, 0 deletions
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 -->
+ `);
+ });
+ });
+});