diff options
Diffstat (limited to 'remote/test/puppeteer/utils/doclint')
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 --> + `); + }); + }); +}); |