diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /remote/test/puppeteer/tools | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/test/puppeteer/tools')
40 files changed, 4588 insertions, 0 deletions
diff --git a/remote/test/puppeteer/tools/analyze_issue.mjs b/remote/test/puppeteer/tools/analyze_issue.mjs new file mode 100755 index 0000000000..9592112de0 --- /dev/null +++ b/remote/test/puppeteer/tools/analyze_issue.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// @ts-check + +'use strict'; + +import {writeFile, mkdir, copyFile} from 'fs/promises'; +import {dirname, join} from 'path'; +import {fileURLToPath} from 'url'; + +import core from '@actions/core'; +import semver from 'semver'; + +import packageJson from '../packages/puppeteer-core/package.json' assert {type: 'json'}; + +const codifyAndJoinValues = values => { + return values + .map(value => { + return `\`${value}\``; + }) + .join(' ,'); +}; +const formatMessage = value => { + return value.trim(); +}; +const removeVersionPrefix = value => { + return value.startsWith('v') ? value.slice(1) : value; +}; + +const LAST_PUPPETEER_VERSION = packageJson.version; +if (!LAST_PUPPETEER_VERSION) { + core.setFailed('No maintained version found.'); +} +const LAST_SUPPORTED_NODE_VERSION = removeVersionPrefix( + packageJson.engines.node.slice(2).trim() +); + +const SUPPORTED_OSES = ['windows', 'macos', 'linux']; +const SUPPORTED_PACKAGE_MANAGERS = ['yarn', 'npm', 'pnpm']; + +const ERROR_MESSAGES = { + unsupportedOs(value) { + return formatMessage(` +This issue has an unsupported OS: \`${value}\`. Only the following operating systems are supported: ${codifyAndJoinValues( + SUPPORTED_OSES + )}. Please verify the issue on a supported OS and update the form. +`); + }, + unsupportedPackageManager(value) { + return formatMessage(` +This issue has an unsupported package manager: \`${value}\`. Only the following package managers are supported: ${codifyAndJoinValues( + SUPPORTED_PACKAGE_MANAGERS + )}. Please verify the issue using a supported package manager and update the form. +`); + }, + invalidPackageManagerVersion(value) { + return formatMessage(` +This issue has an invalid package manager version: \`${value}\`. Versions must follow [SemVer](https://semver.org/) formatting. Please update the form with a valid version. +`); + }, + unsupportedNodeVersion(value) { + return formatMessage(` +This issue has an unsupported Node.js version: \`${value}\`. Only versions above \`v${LAST_SUPPORTED_NODE_VERSION}\` are supported. Please verify the issue on a supported version of Node.js and update the form. +`); + }, + invalidNodeVersion(value) { + return formatMessage(` +This issue has an invalid Node.js version: \`${value}\`. Versions must follow [SemVer](https://semver.org/) formatting. Please update the form with a valid version. +`); + }, + unsupportedPuppeteerVersion(value) { + return formatMessage(` +This issue has an outdated Puppeteer version: \`${value}\`. Please verify your issue on the latest \`${LAST_PUPPETEER_VERSION}\` version. Then update the form accordingly. +`); + }, + invalidPuppeteerVersion(value) { + return formatMessage(` +This issue has an invalid Puppeteer version: \`${value}\`. Versions must follow [SemVer](https://semver.org/) formatting. Please update the form with a valid version. +`); + }, +}; + +(async () => { + let input = ''; + for await (const chunk of process.stdin.iterator({ + destroyOnReturn: false, + })) { + input += chunk; + } + input = JSON.parse(input).trim(); + + let mvce = ''; + let error = ''; + let configuration = ''; + let puppeteerVersion = ''; + let nodeVersion = ''; + let packageManagerVersion = ''; + let packageManager = ''; + let os = ''; + const behavior = {}; + const lines = input.split('\n'); + { + /** @type {(value: string) => void} */ + let set = () => { + return void 0; + }; + let j = 1; + let i = 1; + for (; i < lines.length; ++i) { + if (lines[i].startsWith('### Bug behavior')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + if (value.match(/\[x\] Flaky/i)) { + behavior.flaky = true; + } + if (value.match(/\[x\] pdf/i)) { + behavior.noError = true; + } + }; + } else if (lines[i].startsWith('### Minimal, reproducible example')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + mvce = value; + }; + } else if (lines[i].startsWith('### Error string')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + if (value.match(/no error/i)) { + behavior.noError = true; + } else { + error = value; + } + }; + } else if (lines[i].startsWith('### Puppeteer configuration')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + configuration = value; + }; + } else if (lines[i].startsWith('### Puppeteer version')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + puppeteerVersion = removeVersionPrefix(value); + }; + } else if (lines[i].startsWith('### Node version')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + nodeVersion = removeVersionPrefix(value); + }; + } else if (lines[i].startsWith('### Package manager version')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + packageManagerVersion = removeVersionPrefix(value); + }; + } else if (lines[i].startsWith('### Package manager')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + packageManager = value.toLowerCase(); + }; + } else if (lines[i].startsWith('### Operating system')) { + set(lines.slice(j, i).join('\n').trim()); + j = i + 1; + set = value => { + os = value.toLowerCase(); + }; + } + } + set(lines.slice(j, i).join('\n').trim()); + } + + let runsOn; + switch (os) { + case 'windows': + runsOn = 'windows-latest'; + break; + case 'macos': + runsOn = 'macos-latest'; + break; + case 'linux': + runsOn = 'ubuntu-latest'; + break; + default: + core.setOutput('errorMessage', ERROR_MESSAGES.unsupportedOs(os)); + core.setFailed(`Unsupported OS: ${os}`); + } + + if (!SUPPORTED_PACKAGE_MANAGERS.includes(packageManager)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.unsupportedPackageManager(packageManager) + ); + core.setFailed(`Unsupported package manager: ${packageManager}`); + } + + if (!semver.valid(nodeVersion)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.invalidNodeVersion(nodeVersion) + ); + core.setFailed('Invalid Node version'); + } + if (semver.lt(nodeVersion, LAST_SUPPORTED_NODE_VERSION)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.unsupportedNodeVersion(nodeVersion) + ); + core.setFailed(`Unsupported node version: ${nodeVersion}`); + } + + if (!semver.valid(puppeteerVersion)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.invalidPuppeteerVersion(puppeteerVersion) + ); + core.setFailed(`Invalid puppeteer version: ${puppeteerVersion}`); + } + if ( + !LAST_PUPPETEER_VERSION || + semver.lt(puppeteerVersion, LAST_PUPPETEER_VERSION) + ) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.unsupportedPuppeteerVersion(puppeteerVersion) + ); + core.setFailed(`Unsupported puppeteer version: ${puppeteerVersion}`); + } + + if (!semver.valid(packageManagerVersion)) { + core.setOutput( + 'errorMessage', + ERROR_MESSAGES.invalidPackageManagerVersion(packageManagerVersion) + ); + core.setFailed(`Invalid package manager version: ${packageManagerVersion}`); + } + + core.setOutput('errorMessage', ''); + core.setOutput('runsOn', runsOn); + core.setOutput('nodeVersion', nodeVersion); + core.setOutput('packageManager', packageManager); + + await mkdir('out'); + Promise.all([ + writeFile(join('out', 'main.ts'), mvce.split('\n').slice(1, -1).join('\n')), + writeFile(join('out', 'puppeteer-error.txt'), error), + writeFile( + join('out', 'puppeteer.config.js'), + configuration.split('\n').slice(1, -1).join('\n') + ), + writeFile(join('out', 'puppeteer-behavior.json'), JSON.stringify(behavior)), + writeFile( + join('out', 'package.json'), + JSON.stringify({ + packageManager: `${packageManager}@${packageManagerVersion}`, + scripts: { + start: 'tsx main.ts', + verify: 'tsx verify_issue.ts', + }, + dependencies: { + puppeteer: puppeteerVersion, + }, + devDependencies: { + tsx: 'latest', + }, + }) + ), + copyFile( + join( + dirname(fileURLToPath(import.meta.url)), + 'assets', + 'verify_issue.ts' + ), + join('out', 'verify_issue.ts') + ), + ]); +})(); diff --git a/remote/test/puppeteer/tools/assets/verify_issue.ts b/remote/test/puppeteer/tools/assets/verify_issue.ts new file mode 100755 index 0000000000..5814eff66c --- /dev/null +++ b/remote/test/puppeteer/tools/assets/verify_issue.ts @@ -0,0 +1,68 @@ +import {spawnSync} from 'child_process'; +import {readFile, writeFile} from 'fs/promises'; + +(async () => { + const error = await readFile('puppeteer-error.txt', 'utf-8'); + const behavior = JSON.parse( + await readFile('puppeteer-behavior.json', 'utf-8') + ) as {flaky?: boolean; noError?: boolean}; + + let maxRepetitions = 1; + if (behavior.flaky) { + maxRepetitions = 100; + } + + let status: number | null = null; + let stderr = ''; + let stdout = ''; + + const preHook = async () => { + console.log('Writing output and error logs...'); + await Promise.all([ + writeFile('output.log', stdout), + writeFile('error.log', stderr), + ]); + }; + + let checkStatusWithError: () => Promise<void>; + if (behavior.noError) { + checkStatusWithError = async () => { + if (status === 0) { + await preHook(); + console.log('Script ran successfully; no error found.'); + process.exit(0); + } + }; + } else { + checkStatusWithError = async () => { + if (status !== 0) { + await preHook(); + if (stderr.toLowerCase().includes(error.toLowerCase())) { + console.log('Script failed; error found.'); + process.exit(0); + } + console.error('Script failed; unknown error found.'); + process.exit(1); + } + }; + } + + for (let i = 0; i < maxRepetitions; ++i) { + const result = spawnSync('npm', ['start'], { + shell: true, + encoding: 'utf-8', + }); + status = result.status; + stdout = result.stdout ?? ''; + stderr = result.stderr ?? ''; + await checkStatusWithError(); + } + + await preHook(); + if (behavior.noError) { + console.error('Script failed; unknown error found.'); + } else { + console.error('Script ran successfully; no error found.'); + } + process.exit(1); +})(); diff --git a/remote/test/puppeteer/tools/chmod.ts b/remote/test/puppeteer/tools/chmod.ts new file mode 100644 index 0000000000..da15b64fae --- /dev/null +++ b/remote/test/puppeteer/tools/chmod.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; + +/** + * Calls chmod with the mode in argv[2] on paths in argv[3...length-1]. + */ +const mode = process.argv[2]; + +for (let i = 3; i < process.argv.length; i++) { + fs.chmodSync(process.argv[i], mode); +} diff --git a/remote/test/puppeteer/tools/clean.js b/remote/test/puppeteer/tools/clean.js new file mode 100755 index 0000000000..049fdc0434 --- /dev/null +++ b/remote/test/puppeteer/tools/clean.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +const {exec} = require('child_process'); +const {readdirSync} = require('fs'); + +exec( + `git clean -Xf ${readdirSync(process.cwd()) + .filter(file => { + return file !== 'node_modules'; + }) + .join(' ')}` +); diff --git a/remote/test/puppeteer/tools/cp.ts b/remote/test/puppeteer/tools/cp.ts new file mode 100644 index 0000000000..2915389e19 --- /dev/null +++ b/remote/test/puppeteer/tools/cp.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; + +/** + * Copies single file in argv[2] to argv[3] + */ +fs.cpSync(process.argv[2], process.argv[3]); diff --git a/remote/test/puppeteer/tools/docgen/package.json b/remote/test/puppeteer/tools/docgen/package.json new file mode 100644 index 0000000000..f1ca4ea127 --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/package.json @@ -0,0 +1,33 @@ +{ + "name": "@puppeteer/docgen", + "version": "0.1.0", + "type": "module", + "private": true, + "main": "./lib/docgen.js", + "description": "Documentation generator for Puppeteer", + "license": "Apache-2.0", + "scripts": { + "build": "wireit", + "clean": "../clean.js" + }, + "wireit": { + "build": { + "command": "tsc -b", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "lib/**", + "tsconfig.tsbuildinfo" + ] + } + }, + "devDependencies": { + "@microsoft/api-extractor": "7.39.4", + "@microsoft/api-documenter": "7.23.20", + "@microsoft/api-extractor-model": "7.28.7", + "@microsoft/tsdoc": "0.14.2", + "@rushstack/node-core-library": "3.64.2" + } +} diff --git a/remote/test/puppeteer/tools/docgen/src/custom_markdown_documenter.ts b/remote/test/puppeteer/tools/docgen/src/custom_markdown_documenter.ts new file mode 100644 index 0000000000..d63a8b96ef --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/src/custom_markdown_documenter.ts @@ -0,0 +1,1495 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the +// MIT license. See LICENSE in the project root for license information. + +// Taken from +// https://github.com/microsoft/rushstack/blob/main/apps/api-documenter/src/documenters/MarkdownDocumenter.ts +// This file has been edited to morph into Docusaurus's expected inputs. + +import * as path from 'path'; + +import type {DocumenterConfig} from '@microsoft/api-documenter/lib/documenters/DocumenterConfig.js'; +import {CustomMarkdownEmitter as ApiFormatterMarkdownEmitter} from '@microsoft/api-documenter/lib/markdown/CustomMarkdownEmitter.js'; +import {CustomDocNodes} from '@microsoft/api-documenter/lib/nodes/CustomDocNodeKind.js'; +import {DocEmphasisSpan} from '@microsoft/api-documenter/lib/nodes/DocEmphasisSpan.js'; +import {DocHeading} from '@microsoft/api-documenter/lib/nodes/DocHeading.js'; +import {DocNoteBox} from '@microsoft/api-documenter/lib/nodes/DocNoteBox.js'; +import {DocTable} from '@microsoft/api-documenter/lib/nodes/DocTable.js'; +import {DocTableCell} from '@microsoft/api-documenter/lib/nodes/DocTableCell.js'; +import {DocTableRow} from '@microsoft/api-documenter/lib/nodes/DocTableRow.js'; +import {MarkdownDocumenterAccessor} from '@microsoft/api-documenter/lib/plugin/MarkdownDocumenterAccessor.js'; +import { + type IMarkdownDocumenterFeatureOnBeforeWritePageArgs, + MarkdownDocumenterFeatureContext, +} from '@microsoft/api-documenter/lib/plugin/MarkdownDocumenterFeature.js'; +import {PluginLoader} from '@microsoft/api-documenter/lib/plugin/PluginLoader.js'; +import {Utilities} from '@microsoft/api-documenter/lib/utils/Utilities.js'; +import { + ApiClass, + ApiDeclaredItem, + ApiDocumentedItem, + type ApiEnum, + ApiInitializerMixin, + ApiInterface, + type ApiItem, + ApiItemKind, + type ApiModel, + type ApiNamespace, + ApiOptionalMixin, + type ApiPackage, + ApiParameterListMixin, + ApiPropertyItem, + ApiProtectedMixin, + ApiReadonlyMixin, + ApiReleaseTagMixin, + ApiReturnTypeMixin, + ApiStaticMixin, + ApiTypeAlias, + type Excerpt, + type ExcerptToken, + ExcerptTokenKind, + type IResolveDeclarationReferenceResult, + ReleaseTag, +} from '@microsoft/api-extractor-model'; +import { + type DocBlock, + DocCodeSpan, + type DocComment, + DocFencedCode, + DocLinkTag, + type DocNodeContainer, + DocNodeKind, + DocParagraph, + DocPlainText, + DocSection, + StandardTags, + StringBuilder, + type TSDocConfiguration, +} from '@microsoft/tsdoc'; +import { + FileSystem, + NewlineKind, + PackageName, +} from '@rushstack/node-core-library'; + +export interface IMarkdownDocumenterOptions { + apiModel: ApiModel; + documenterConfig: DocumenterConfig | undefined; + outputFolder: string; +} + +export class CustomMarkdownEmitter extends ApiFormatterMarkdownEmitter { + protected override getEscapedText(text: string): string { + const textWithBackslashes: string = text + .replace(/\\/g, '\\\\') // first replace the escape character + .replace(/[*#[\]_|`~]/g, x => { + return '\\' + x; + }) // then escape any special characters + .replace(/---/g, '\\-\\-\\-') // hyphens only if it's 3 or more + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\{/g, '{') + .replace(/\}/g, '}'); + return textWithBackslashes; + } + + protected override getTableEscapedText(text: string): string { + return text + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\|/g, '|'); + } +} + +/** + * Renders API documentation in the Markdown file format. + * For more info: https://en.wikipedia.org/wiki/Markdown + */ +export class MarkdownDocumenter { + private readonly _apiModel: ApiModel; + private readonly _documenterConfig: DocumenterConfig | undefined; + private readonly _tsdocConfiguration: TSDocConfiguration; + private readonly _markdownEmitter: CustomMarkdownEmitter; + private readonly _outputFolder: string; + private readonly _pluginLoader: PluginLoader; + + public constructor(options: IMarkdownDocumenterOptions) { + this._apiModel = options.apiModel; + this._documenterConfig = options.documenterConfig; + this._outputFolder = options.outputFolder; + this._tsdocConfiguration = CustomDocNodes.configuration; + this._markdownEmitter = new CustomMarkdownEmitter(this._apiModel); + + this._pluginLoader = new PluginLoader(); + } + + public generateFiles(): void { + if (this._documenterConfig) { + this._pluginLoader.load(this._documenterConfig, () => { + return new MarkdownDocumenterFeatureContext({ + apiModel: this._apiModel, + outputFolder: this._outputFolder, + documenter: new MarkdownDocumenterAccessor({ + getLinkForApiItem: (apiItem: ApiItem) => { + return this._getLinkFilenameForApiItem(apiItem); + }, + }), + }); + }); + } + + this._deleteOldOutputFiles(); + + this._writeApiItemPage(this._apiModel.members[0]!); + + if (this._pluginLoader.markdownDocumenterFeature) { + this._pluginLoader.markdownDocumenterFeature.onFinished({}); + } + } + + private _writeApiItemPage(apiItem: ApiItem): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + const output: DocSection = new DocSection({ + configuration: this._tsdocConfiguration, + }); + + const scopedName: string = apiItem.getScopedNameWithinPackage(); + + switch (apiItem.kind) { + case ApiItemKind.Class: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} class`}) + ); + break; + case ApiItemKind.Enum: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} enum`}) + ); + break; + case ApiItemKind.Interface: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} interface`}) + ); + break; + case ApiItemKind.Constructor: + case ApiItemKind.ConstructSignature: + output.appendNode(new DocHeading({configuration, title: scopedName})); + break; + case ApiItemKind.Method: + case ApiItemKind.MethodSignature: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} method`}) + ); + break; + case ApiItemKind.Function: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} function`}) + ); + break; + case ApiItemKind.Model: + output.appendNode( + new DocHeading({configuration, title: `API Reference`}) + ); + break; + case ApiItemKind.Namespace: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} namespace`}) + ); + break; + case ApiItemKind.Package: + console.log(`Writing ${apiItem.displayName} package`); + output.appendNode( + new DocHeading({ + configuration, + title: `API Reference`, + }) + ); + break; + case ApiItemKind.Property: + case ApiItemKind.PropertySignature: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} property`}) + ); + break; + case ApiItemKind.TypeAlias: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} type`}) + ); + break; + case ApiItemKind.Variable: + output.appendNode( + new DocHeading({configuration, title: `${scopedName} variable`}) + ); + break; + default: + throw new Error('Unsupported API item kind: ' + apiItem.kind); + } + + if (ApiReleaseTagMixin.isBaseClassOf(apiItem)) { + if (apiItem.releaseTag === ReleaseTag.Beta) { + this._writeBetaWarning(output); + } + } + + const decoratorBlocks: DocBlock[] = []; + + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + decoratorBlocks.push( + ...tsdocComment.customBlocks.filter(block => { + return ( + block.blockTag.tagNameWithUpperCase === + StandardTags.decorator.tagNameWithUpperCase + ); + }) + ); + + if (tsdocComment.deprecatedBlock) { + output.appendNode( + new DocNoteBox({configuration: this._tsdocConfiguration}, [ + new DocParagraph({configuration: this._tsdocConfiguration}, [ + new DocPlainText({ + configuration: this._tsdocConfiguration, + text: 'Warning: This API is now obsolete. ', + }), + ]), + ...tsdocComment.deprecatedBlock.content.nodes, + ]) + ); + } + + this._appendSection(output, tsdocComment.summarySection); + } + } + + if (apiItem instanceof ApiDeclaredItem) { + if (apiItem.excerpt.text.length > 0) { + output.appendNode( + new DocHeading({configuration, title: 'Signature:', level: 4}) + ); + + let code: string; + switch (apiItem.parent?.kind) { + case ApiItemKind.Class: + code = `class ${ + apiItem.parent.displayName + } {${apiItem.getExcerptWithModifiers()}}`; + break; + case ApiItemKind.Interface: + code = `interface ${ + apiItem.parent.displayName + } {${apiItem.getExcerptWithModifiers()}}`; + break; + default: + code = apiItem.getExcerptWithModifiers(); + } + output.appendNode( + new DocFencedCode({ + configuration, + code: code, + language: 'typescript', + }) + ); + } + + this._writeHeritageTypes(output, apiItem); + } + + if (decoratorBlocks.length > 0) { + output.appendNode( + new DocHeading({configuration, title: 'Decorators:', level: 4}) + ); + for (const decoratorBlock of decoratorBlocks) { + output.appendNodes(decoratorBlock.content.nodes); + } + } + + let appendRemarks = true; + switch (apiItem.kind) { + case ApiItemKind.Class: + case ApiItemKind.Interface: + case ApiItemKind.Namespace: + case ApiItemKind.Package: + this._writeRemarksSection(output, apiItem); + appendRemarks = false; + break; + } + + switch (apiItem.kind) { + case ApiItemKind.Class: + this._writeClassTables(output, apiItem as ApiClass); + break; + case ApiItemKind.Enum: + this._writeEnumTables(output, apiItem as ApiEnum); + break; + case ApiItemKind.Interface: + this._writeInterfaceTables(output, apiItem as ApiInterface); + break; + case ApiItemKind.Constructor: + case ApiItemKind.ConstructSignature: + case ApiItemKind.Method: + case ApiItemKind.MethodSignature: + case ApiItemKind.Function: + this._writeParameterTables(output, apiItem as ApiParameterListMixin); + this._writeThrowsSection(output, apiItem); + break; + case ApiItemKind.Namespace: + this._writePackageOrNamespaceTables(output, apiItem as ApiNamespace); + break; + case ApiItemKind.Model: + this._writeModelTable(output, apiItem as ApiModel); + break; + case ApiItemKind.Package: + this._writePackageOrNamespaceTables(output, apiItem as ApiPackage); + break; + case ApiItemKind.Property: + case ApiItemKind.PropertySignature: + break; + case ApiItemKind.TypeAlias: + break; + case ApiItemKind.Variable: + break; + default: + throw new Error('Unsupported API item kind: ' + apiItem.kind); + } + + this._writeDefaultValueSection(output, apiItem); + + if (appendRemarks) { + this._writeRemarksSection(output, apiItem); + } + + const filename: string = path.join( + this._outputFolder, + this._getFilenameForApiItem(apiItem) + ); + const stringBuilder: StringBuilder = new StringBuilder(); + + this._markdownEmitter.emit(stringBuilder, output, { + contextApiItem: apiItem, + onGetFilenameForApiItem: (apiItemForFilename: ApiItem) => { + return this._getLinkFilenameForApiItem(apiItemForFilename); + }, + }); + + let pageContent: string = stringBuilder.toString(); + + if (this._pluginLoader.markdownDocumenterFeature) { + // Allow the plugin to customize the pageContent + const eventArgs: IMarkdownDocumenterFeatureOnBeforeWritePageArgs = { + apiItem: apiItem, + outputFilename: filename, + pageContent: pageContent, + }; + this._pluginLoader.markdownDocumenterFeature.onBeforeWritePage(eventArgs); + pageContent = eventArgs.pageContent; + } + + pageContent = + `---\nsidebar_label: ${this._getSidebarLabelForApiItem(apiItem)}\n---` + + pageContent; + pageContent = pageContent.replace('##', '#'); + pageContent = pageContent.replace(/<!-- -->/g, ''); + pageContent = pageContent.replace(/\\\*\\\*/g, '**'); + pageContent = pageContent.replace(/<b>|<\/b>/g, '**'); + FileSystem.writeFile(filename, pageContent, { + convertLineEndings: this._documenterConfig + ? this._documenterConfig.newlineKind + : NewlineKind.CrLf, + }); + } + + private _writeHeritageTypes( + output: DocSection, + apiItem: ApiDeclaredItem + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + if (apiItem instanceof ApiClass) { + if (apiItem.extendsType) { + const extendsParagraph: DocParagraph = new DocParagraph( + {configuration}, + [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'Extends: '}), + ]), + ] + ); + this._appendExcerptWithHyperlinks( + extendsParagraph, + apiItem.extendsType.excerpt + ); + output.appendNode(extendsParagraph); + } + if (apiItem.implementsTypes.length > 0) { + const extendsParagraph: DocParagraph = new DocParagraph( + {configuration}, + [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'Implements: '}), + ]), + ] + ); + let needsComma = false; + for (const implementsType of apiItem.implementsTypes) { + if (needsComma) { + extendsParagraph.appendNode( + new DocPlainText({configuration, text: ', '}) + ); + } + this._appendExcerptWithHyperlinks( + extendsParagraph, + implementsType.excerpt + ); + needsComma = true; + } + output.appendNode(extendsParagraph); + } + } + + if (apiItem instanceof ApiInterface) { + if (apiItem.extendsTypes.length > 0) { + const extendsParagraph: DocParagraph = new DocParagraph( + {configuration}, + [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'Extends: '}), + ]), + ] + ); + let needsComma = false; + for (const extendsType of apiItem.extendsTypes) { + if (needsComma) { + extendsParagraph.appendNode( + new DocPlainText({configuration, text: ', '}) + ); + } + this._appendExcerptWithHyperlinks( + extendsParagraph, + extendsType.excerpt + ); + needsComma = true; + } + output.appendNode(extendsParagraph); + } + } + + if (apiItem instanceof ApiTypeAlias) { + const refs: ExcerptToken[] = apiItem.excerptTokens.filter(token => { + return ( + token.kind === ExcerptTokenKind.Reference && + token.canonicalReference && + this._apiModel.resolveDeclarationReference( + token.canonicalReference, + undefined + ).resolvedApiItem + ); + }); + if (refs.length > 0) { + const referencesParagraph: DocParagraph = new DocParagraph( + {configuration}, + [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'References: '}), + ]), + ] + ); + let needsComma = false; + const visited = new Set<string>(); + for (const ref of refs) { + if (visited.has(ref.text)) { + continue; + } + visited.add(ref.text); + + if (needsComma) { + referencesParagraph.appendNode( + new DocPlainText({configuration, text: ', '}) + ); + } + + this._appendExcerptTokenWithHyperlinks(referencesParagraph, ref); + needsComma = true; + } + output.appendNode(referencesParagraph); + } + } + } + + private _writeDefaultValueSection(output: DocSection, apiItem: ApiItem) { + if (apiItem instanceof ApiDocumentedItem) { + const block = apiItem.tsdocComment?.customBlocks.find(block => { + return ( + block.blockTag.tagNameWithUpperCase === + StandardTags.defaultValue.tagNameWithUpperCase + ); + }); + if (block) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Default value:', + level: 4, + }) + ); + this._appendSection(output, block.content); + } + } + } + + private _writeRemarksSection(output: DocSection, apiItem: ApiItem): void { + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + // Write the @remarks block + if (tsdocComment.remarksBlock) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Remarks', + }) + ); + this._appendSection(output, tsdocComment.remarksBlock.content); + } + + // Write the @example blocks + const exampleBlocks: DocBlock[] = tsdocComment.customBlocks.filter( + x => { + return ( + x.blockTag.tagNameWithUpperCase === + StandardTags.example.tagNameWithUpperCase + ); + } + ); + + let exampleNumber = 1; + for (const exampleBlock of exampleBlocks) { + const heading: string = + exampleBlocks.length > 1 ? `Example ${exampleNumber}` : 'Example'; + + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: heading, + }) + ); + + this._appendSection(output, exampleBlock.content); + + ++exampleNumber; + } + } + } + } + + private _writeThrowsSection(output: DocSection, apiItem: ApiItem): void { + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + // Write the @throws blocks + const throwsBlocks: DocBlock[] = tsdocComment.customBlocks.filter(x => { + return ( + x.blockTag.tagNameWithUpperCase === + StandardTags.throws.tagNameWithUpperCase + ); + }); + + if (throwsBlocks.length > 0) { + const heading = 'Exceptions'; + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: heading, + }) + ); + + for (const throwsBlock of throwsBlocks) { + this._appendSection(output, throwsBlock.content); + } + } + } + } + } + + /** + * GENERATE PAGE: MODEL + */ + private _writeModelTable(output: DocSection, apiModel: ApiModel): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const packagesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Package', 'Description'], + }); + + for (const apiMember of apiModel.members) { + const row: DocTableRow = new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createDescriptionCell(apiMember), + ]); + + switch (apiMember.kind) { + case ApiItemKind.Package: + packagesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + } + } + + if (packagesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Packages', + }) + ); + output.appendNode(packagesTable); + } + } + + /** + * GENERATE PAGE: PACKAGE or NAMESPACE + */ + private _writePackageOrNamespaceTables( + output: DocSection, + apiContainer: ApiPackage | ApiNamespace + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const classesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Class', 'Description'], + }); + + const enumerationsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Enumeration', 'Description'], + }); + + const functionsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Function', 'Description'], + }); + + const interfacesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Interface', 'Description'], + }); + + const namespacesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Namespace', 'Description'], + }); + + const variablesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Variable', 'Description'], + }); + + const typeAliasesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Type Alias', 'Description'], + }); + + const apiMembers: readonly ApiItem[] = + apiContainer.kind === ApiItemKind.Package + ? (apiContainer as ApiPackage).entryPoints[0]!.members + : (apiContainer as ApiNamespace).members; + + for (const apiMember of apiMembers) { + const row: DocTableRow = new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createDescriptionCell(apiMember), + ]); + + switch (apiMember.kind) { + case ApiItemKind.Class: + classesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Enum: + enumerationsTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Interface: + interfacesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Namespace: + namespacesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Function: + functionsTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.TypeAlias: + typeAliasesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Variable: + variablesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + } + } + + if (classesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Classes', + }) + ); + output.appendNode(classesTable); + } + + if (enumerationsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Enumerations', + }) + ); + output.appendNode(enumerationsTable); + } + if (functionsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Functions', + }) + ); + output.appendNode(functionsTable); + } + + if (interfacesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Interfaces', + }) + ); + output.appendNode(interfacesTable); + } + + if (namespacesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Namespaces', + }) + ); + output.appendNode(namespacesTable); + } + + if (variablesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Variables', + }) + ); + output.appendNode(variablesTable); + } + + if (typeAliasesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Type Aliases', + }) + ); + output.appendNode(typeAliasesTable); + } + } + + /** + * GENERATE PAGE: CLASS + */ + private _writeClassTables(output: DocSection, apiClass: ApiClass): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const eventsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Property', 'Modifiers', 'Type', 'Description'], + }); + + const constructorsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Constructor', 'Modifiers', 'Description'], + }); + + const propertiesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Property', 'Modifiers', 'Type', 'Description'], + }); + + const methodsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Method', 'Modifiers', 'Description'], + }); + + for (const apiMember of apiClass.members) { + switch (apiMember.kind) { + case ApiItemKind.Constructor: { + constructorsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createModifiersCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + + this._writeApiItemPage(apiMember); + break; + } + case ApiItemKind.Method: { + methodsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createModifiersCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + + this._writeApiItemPage(apiMember); + break; + } + case ApiItemKind.Property: { + if ((apiMember as ApiPropertyItem).isEventProperty) { + eventsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember, true), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + } else { + propertiesTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember, true), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + } + break; + } + } + } + + if (eventsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Events', + }) + ); + output.appendNode(eventsTable); + } + + if (constructorsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Constructors', + }) + ); + output.appendNode(constructorsTable); + } + + if (propertiesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Properties', + }) + ); + output.appendNode(propertiesTable); + } + + if (methodsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Methods', + }) + ); + output.appendNode(methodsTable); + } + } + + /** + * GENERATE PAGE: ENUM + */ + private _writeEnumTables(output: DocSection, apiEnum: ApiEnum): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const enumMembersTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Member', 'Value', 'Description'], + }); + + for (const apiEnumMember of apiEnum.members) { + enumMembersTable.addRow( + new DocTableRow({configuration}, [ + new DocTableCell({configuration}, [ + new DocParagraph({configuration}, [ + new DocPlainText({ + configuration, + text: Utilities.getConciseSignature(apiEnumMember), + }), + ]), + ]), + this._createInitializerCell(apiEnumMember), + this._createDescriptionCell(apiEnumMember), + ]) + ); + } + + if (enumMembersTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Enumeration Members', + }) + ); + output.appendNode(enumMembersTable); + } + } + + /** + * GENERATE PAGE: INTERFACE + */ + private _writeInterfaceTables( + output: DocSection, + apiClass: ApiInterface + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const eventsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Property', 'Modifiers', 'Type', 'Description'], + }); + + const propertiesTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Property', 'Modifiers', 'Type', 'Description', 'Default'], + }); + + const methodsTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Method', 'Description'], + }); + + for (const apiMember of apiClass.members) { + switch (apiMember.kind) { + case ApiItemKind.ConstructSignature: + case ApiItemKind.MethodSignature: { + methodsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + + this._writeApiItemPage(apiMember); + break; + } + case ApiItemKind.PropertySignature: { + if ((apiMember as ApiPropertyItem).isEventProperty) { + eventsTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember, true), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell(apiMember), + ]) + ); + } else { + propertiesTable.addRow( + new DocTableRow({configuration}, [ + this._createTitleCell(apiMember, true), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell(apiMember), + this._createDefaultCell(apiMember), + ]) + ); + } + break; + } + } + } + + if (eventsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Events', + }) + ); + output.appendNode(eventsTable); + } + + if (propertiesTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Properties', + }) + ); + output.appendNode(propertiesTable); + } + + if (methodsTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Methods', + }) + ); + output.appendNode(methodsTable); + } + } + + /** + * GENERATE PAGE: FUNCTION-LIKE + */ + private _writeParameterTables( + output: DocSection, + apiParameterListMixin: ApiParameterListMixin + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const parametersTable: DocTable = new DocTable({ + configuration, + headerTitles: ['Parameter', 'Type', 'Description'], + }); + for (const apiParameter of apiParameterListMixin.parameters) { + const parameterDescription: DocSection = new DocSection({configuration}); + + if (apiParameter.isOptional) { + parameterDescription.appendNodesInParagraph([ + new DocEmphasisSpan({configuration, italic: true}, [ + new DocPlainText({configuration, text: '(Optional)'}), + ]), + new DocPlainText({configuration, text: ' '}), + ]); + } + + if (apiParameter.tsdocParamBlock) { + this._appendAndMergeSection( + parameterDescription, + apiParameter.tsdocParamBlock.content + ); + } + + parametersTable.addRow( + new DocTableRow({configuration}, [ + new DocTableCell({configuration}, [ + new DocParagraph({configuration}, [ + new DocPlainText({configuration, text: apiParameter.name}), + ]), + ]), + new DocTableCell({configuration}, [ + this._createParagraphForTypeExcerpt( + apiParameter.parameterTypeExcerpt + ), + ]), + new DocTableCell({configuration}, parameterDescription.nodes), + ]) + ); + } + + if (parametersTable.rows.length > 0) { + output.appendNode( + new DocHeading({ + configuration: this._tsdocConfiguration, + title: 'Parameters', + }) + ); + output.appendNode(parametersTable); + } + + if (ApiReturnTypeMixin.isBaseClassOf(apiParameterListMixin)) { + const returnTypeExcerpt: Excerpt = + apiParameterListMixin.returnTypeExcerpt; + output.appendNode( + new DocParagraph({configuration}, [ + new DocEmphasisSpan({configuration, bold: true}, [ + new DocPlainText({configuration, text: 'Returns:'}), + ]), + ]) + ); + + output.appendNode(this._createParagraphForTypeExcerpt(returnTypeExcerpt)); + + if (apiParameterListMixin instanceof ApiDocumentedItem) { + if ( + apiParameterListMixin.tsdocComment && + apiParameterListMixin.tsdocComment.returnsBlock + ) { + this._appendSection( + output, + apiParameterListMixin.tsdocComment.returnsBlock.content + ); + } + } + } + } + + private _createParagraphForTypeExcerpt(excerpt: Excerpt): DocParagraph { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const paragraph: DocParagraph = new DocParagraph({configuration}); + if (!excerpt.text.trim()) { + paragraph.appendNode( + new DocPlainText({configuration, text: '(not declared)'}) + ); + } else { + this._appendExcerptWithHyperlinks(paragraph, excerpt); + } + + return paragraph; + } + + private _appendExcerptWithHyperlinks( + docNodeContainer: DocNodeContainer, + excerpt: Excerpt + ): void { + for (const token of excerpt.spannedTokens) { + this._appendExcerptTokenWithHyperlinks(docNodeContainer, token); + } + } + + private _appendExcerptTokenWithHyperlinks( + docNodeContainer: DocNodeContainer, + token: ExcerptToken + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + // Markdown doesn't provide a standardized syntax for hyperlinks inside code + // spans, so we will render the type expression as DocPlainText. Instead of + // creating multiple DocParagraphs, we can simply discard any newlines and + // let the renderer do normal word-wrapping. + const unwrappedTokenText: string = token.text.replace(/[\r\n]+/g, ' '); + + // If it's hyperlinkable, then append a DocLinkTag + if (token.kind === ExcerptTokenKind.Reference && token.canonicalReference) { + const apiItemResult: IResolveDeclarationReferenceResult = + this._apiModel.resolveDeclarationReference( + token.canonicalReference, + undefined + ); + + if (apiItemResult.resolvedApiItem) { + docNodeContainer.appendNode( + new DocLinkTag({ + configuration, + tagName: StandardTags.link.tagName, + linkText: unwrappedTokenText, + urlDestination: this._getLinkFilenameForApiItem( + apiItemResult.resolvedApiItem + ), + }) + ); + return; + } + } + + // Otherwise append non-hyperlinked text + docNodeContainer.appendNode( + new DocPlainText({configuration, text: unwrappedTokenText}) + ); + } + + private _createTitleCell(apiItem: ApiItem, plain = false): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const text: string = Utilities.getConciseSignature(apiItem); + + return new DocTableCell({configuration}, [ + new DocParagraph({configuration}, [ + plain + ? new DocPlainText({configuration, text}) + : new DocLinkTag({ + configuration, + tagName: '@link', + linkText: text, + urlDestination: this._getLinkFilenameForApiItem(apiItem), + }), + ]), + ]); + } + + /** + * This generates a DocTableCell for an ApiItem including the summary section + * and "(BETA)" annotation. + * + * @remarks + * We mostly assume that the input is an ApiDocumentedItem, but it's easier to + * perform this as a runtime check than to have each caller perform a type + * cast. + */ + private _createDescriptionCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const section: DocSection = new DocSection({configuration}); + + if (ApiReleaseTagMixin.isBaseClassOf(apiItem)) { + if (apiItem.releaseTag === ReleaseTag.Beta) { + section.appendNodesInParagraph([ + new DocEmphasisSpan({configuration, bold: true, italic: true}, [ + new DocPlainText({configuration, text: '(BETA)'}), + ]), + new DocPlainText({configuration, text: ' '}), + ]); + } + } + + if (apiItem instanceof ApiDocumentedItem) { + if (apiItem.tsdocComment !== undefined) { + this._appendAndMergeSection( + section, + apiItem.tsdocComment.summarySection + ); + } + } + + return new DocTableCell({configuration}, section.nodes); + } + + private _createDefaultCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + if (apiItem instanceof ApiDocumentedItem) { + const block = apiItem.tsdocComment?.customBlocks.find(block => { + return ( + block.blockTag.tagNameWithUpperCase === + StandardTags.defaultValue.tagNameWithUpperCase + ); + }); + if (block !== undefined) { + return new DocTableCell({configuration}, block.content.getChildNodes()); + } + } + + return new DocTableCell({configuration}, []); + } + + private _createModifiersCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const section: DocSection = new DocSection({configuration}); + + if (ApiProtectedMixin.isBaseClassOf(apiItem)) { + if (apiItem.isProtected) { + section.appendNode( + new DocParagraph({configuration}, [ + new DocCodeSpan({configuration, code: 'protected'}), + ]) + ); + } + } + + if (ApiReadonlyMixin.isBaseClassOf(apiItem)) { + if (apiItem.isReadonly) { + section.appendNode( + new DocParagraph({configuration}, [ + new DocCodeSpan({configuration, code: 'readonly'}), + ]) + ); + } + } + + if (ApiStaticMixin.isBaseClassOf(apiItem)) { + if (apiItem.isStatic) { + section.appendNode( + new DocParagraph({configuration}, [ + new DocCodeSpan({configuration, code: 'static'}), + ]) + ); + } + } + + if (ApiOptionalMixin.isBaseClassOf(apiItem)) { + if (apiItem.isOptional) { + section.appendNode( + new DocParagraph({configuration}, [ + new DocCodeSpan({configuration, code: 'optional'}), + ]) + ); + } + } + + return new DocTableCell({configuration}, section.nodes); + } + + private _createPropertyTypeCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const section: DocSection = new DocSection({configuration}); + + if (apiItem instanceof ApiPropertyItem) { + section.appendNode( + this._createParagraphForTypeExcerpt(apiItem.propertyTypeExcerpt) + ); + } + + return new DocTableCell({configuration}, section.nodes); + } + + private _createInitializerCell(apiItem: ApiItem): DocTableCell { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + const section: DocSection = new DocSection({configuration}); + + if (ApiInitializerMixin.isBaseClassOf(apiItem)) { + if (apiItem.initializerExcerpt) { + section.appendNodeInParagraph( + new DocCodeSpan({ + configuration, + code: apiItem.initializerExcerpt.text, + }) + ); + } + } + + return new DocTableCell({configuration}, section.nodes); + } + + private _writeBetaWarning(output: DocSection): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + const betaWarning: string = + 'This API is provided as a preview for developers and may change' + + ' based on feedback that we receive. Do not use this API in a production environment.'; + output.appendNode( + new DocNoteBox({configuration}, [ + new DocParagraph({configuration}, [ + new DocPlainText({configuration, text: betaWarning}), + ]), + ]) + ); + } + + private _appendSection(output: DocSection, docSection: DocSection): void { + for (const node of docSection.nodes) { + output.appendNode(node); + } + } + + private _appendAndMergeSection( + output: DocSection, + docSection: DocSection + ): void { + let firstNode = true; + for (const node of docSection.nodes) { + if (firstNode) { + if (node.kind === DocNodeKind.Paragraph) { + output.appendNodesInParagraph(node.getChildNodes()); + firstNode = false; + continue; + } + } + firstNode = false; + + output.appendNode(node); + } + } + + private _getSidebarLabelForApiItem(apiItem: ApiItem): string { + if (apiItem.kind === ApiItemKind.Package) { + return 'API'; + } + + let baseName = ''; + for (const hierarchyItem of apiItem.getHierarchy()) { + // For overloaded methods, add a suffix such as "MyClass.myMethod_2". + let qualifiedName: string = hierarchyItem.displayName; + if (ApiParameterListMixin.isBaseClassOf(hierarchyItem)) { + if (hierarchyItem.overloadIndex > 1) { + // Subtract one for compatibility with earlier releases of API Documenter. + qualifiedName += `_${hierarchyItem.overloadIndex - 1}`; + } + } + + switch (hierarchyItem.kind) { + case ApiItemKind.Model: + case ApiItemKind.EntryPoint: + case ApiItemKind.EnumMember: + case ApiItemKind.Package: + break; + default: + baseName += qualifiedName + '.'; + } + } + return baseName.slice(0, baseName.length - 1); + } + + private _getFilenameForApiItem(apiItem: ApiItem): string { + if (apiItem.kind === ApiItemKind.Package) { + return 'index.md'; + } + + let baseName = ''; + for (const hierarchyItem of apiItem.getHierarchy()) { + // For overloaded methods, add a suffix such as "MyClass.myMethod_2". + let qualifiedName: string = Utilities.getSafeFilenameForName( + hierarchyItem.displayName + ); + if (ApiParameterListMixin.isBaseClassOf(hierarchyItem)) { + if (hierarchyItem.overloadIndex > 1) { + // Subtract one for compatibility with earlier releases of API Documenter. + // (This will get revamped when we fix GitHub issue #1308) + qualifiedName += `_${hierarchyItem.overloadIndex - 1}`; + } + } + + switch (hierarchyItem.kind) { + case ApiItemKind.Model: + case ApiItemKind.EntryPoint: + case ApiItemKind.EnumMember: + break; + case ApiItemKind.Package: + baseName = Utilities.getSafeFilenameForName( + PackageName.getUnscopedName(hierarchyItem.displayName) + ); + break; + default: + baseName += '.' + qualifiedName; + } + } + return baseName + '.md'; + } + + private _getLinkFilenameForApiItem(apiItem: ApiItem): string { + return './' + this._getFilenameForApiItem(apiItem); + } + + private _deleteOldOutputFiles(): void { + console.log('Deleting old output from ' + this._outputFolder); + FileSystem.ensureEmptyFolder(this._outputFolder); + } +} diff --git a/remote/test/puppeteer/tools/docgen/src/docgen.ts b/remote/test/puppeteer/tools/docgen/src/docgen.ts new file mode 100644 index 0000000000..c7bafdab3d --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/src/docgen.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ApiModel} from '@microsoft/api-extractor-model'; + +import {MarkdownDocumenter} from './custom_markdown_documenter.js'; + +export function docgen(jsonPath: string, outputDir: string): void { + const apiModel = new ApiModel(); + apiModel.loadPackage(jsonPath); + + const markdownDocumenter: MarkdownDocumenter = new MarkdownDocumenter({ + apiModel: apiModel, + documenterConfig: undefined, + outputFolder: outputDir, + }); + markdownDocumenter.generateFiles(); +} + +export function spliceIntoSection( + sectionName: string, + content: string, + sectionContent: string +): string { + const lines = content.split('\n'); + const offset = + lines.findIndex(line => { + return line.includes(`<!-- ${sectionName}-start -->`); + }) + 1; + const limit = lines.slice(offset).findIndex(line => { + return line.includes(`<!-- ${sectionName}-end -->`); + }); + lines.splice(offset, limit, ...sectionContent.split('\n')); + return lines.join('\n'); +} diff --git a/remote/test/puppeteer/tools/docgen/tsconfig.json b/remote/test/puppeteer/tools/docgen/tsconfig.json new file mode 100644 index 0000000000..fcaf1db737 --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "sourceMap": true, + "declaration": false, + "declarationMap": false, + "composite": false, + }, +} diff --git a/remote/test/puppeteer/tools/docgen/tsdoc.json b/remote/test/puppeteer/tools/docgen/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/docgen/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/doctest/package.json b/remote/test/puppeteer/tools/doctest/package.json new file mode 100644 index 0000000000..8c7e9544d0 --- /dev/null +++ b/remote/test/puppeteer/tools/doctest/package.json @@ -0,0 +1,39 @@ +{ + "name": "@puppeteer/doctest", + "version": "0.1.0", + "type": "module", + "private": true, + "bin": "./bin/doctest.js", + "description": "Tests JSDoc @example code within a file.", + "license": "Apache-2.0", + "scripts": { + "build": "wireit", + "clean": "../clean.js" + }, + "wireit": { + "build": { + "command": "tsc -b && chmod +x ./bin/doctest.js", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "bin/**", + "tsconfig.tsbuildinfo" + ] + } + }, + "devDependencies": { + "@swc/core": "1.3.107", + "@types/doctrine": "0.0.9", + "@types/source-map-support": "0.5.10", + "@types/yargs": "17.0.32", + "acorn": "8.11.3", + "doctrine": "3.0.0", + "glob": "10.3.10", + "pkg-dir": "8.0.0", + "source-map-support": "0.5.21", + "source-map": "0.7.4", + "yargs": "17.7.2" + } +} diff --git a/remote/test/puppeteer/tools/doctest/src/doctest.ts b/remote/test/puppeteer/tools/doctest/src/doctest.ts new file mode 100644 index 0000000000..34349ef766 --- /dev/null +++ b/remote/test/puppeteer/tools/doctest/src/doctest.ts @@ -0,0 +1,349 @@ +#! /usr/bin/env -S node --test-reporter spec + +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * `@puppeteer/doctest` tests `@example` code within a JavaScript file. + * + * There are a few reasonable assumptions for this tool to work: + * + * 1. Examples are written in block comments, not line comments. + * 2. Examples do not use packages that are not available to the file it exists + * in. (Note the package will always be available). + * 3. Examples are strictly written between code fences (\`\`\`) on separate + * lines. For example, \`\`\`console.log(1)\`\`\` is not allowed. + * 4. Code is written using ES modules. + * + * By default, code blocks are interpreted as JavaScript. Use \`\`\`ts to change + * the language. In general, the format is "\`\`\`[language] [ignore] [fail]". + * + * If there are several code blocks within an example, they are concatenated. + */ +import 'source-map-support/register.js'; + +import assert from 'node:assert'; +import {createHash} from 'node:crypto'; +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {basename, dirname, join, relative, resolve} from 'node:path'; +import {test} from 'node:test'; +import {pathToFileURL} from 'node:url'; + +import {transform, type Output} from '@swc/core'; +import {parse as parseJs} from 'acorn'; +import {parse, type Tag} from 'doctrine'; +import {Glob} from 'glob'; +import {packageDirectory} from 'pkg-dir'; +import { + SourceMapConsumer, + SourceMapGenerator, + type RawSourceMap, +} from 'source-map'; +import yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; + +// This is 1-indexed. +interface Position { + line: number; + column: number; +} + +interface Comment { + file: string; + text: string; + position: Position; +} + +interface ExtractedSourceLocation { + // File path to the original source code. + origin: string; + // Mappings from the extracted code to the original code. + positions: Array<{ + // The 1-indexed line number for the extracted code. + extracted: number; + // The position in the original code. + original: Position; + }>; +} + +interface ExampleCode extends ExtractedSourceLocation { + language: Language; + code: string; + fail: boolean; +} + +const enum Language { + JavaScript, + TypeScript, +} + +const CODE_FENCE = '```'; +const BLOCK_COMMENT_START = ' * '; + +const {files = []} = await yargs(hideBin(process.argv)) + .scriptName('@puppeteer/doctest') + .command('* <files..>', `JSDoc @example code tester.`) + .positional('files', { + describe: 'Files to test', + type: 'string', + }) + .array('files') + .version(false) + .help() + .parse(); + +for await (const file of new Glob(files, {})) { + void test(file, async context => { + const testDirectory = await createTestDirectory(file); + context.after(async () => { + if (!process.env['KEEP_TESTS']) { + await rm(testDirectory, {force: true, recursive: true}); + } + }); + const tests = []; + for (const example of await extractJSDocComments(file).then( + extractExampleCode + )) { + tests.push( + context.test( + `${file}:${example.positions[0]!.original.line}:${ + example.positions[0]!.original.column + }`, + async () => { + await run(testDirectory, example); + } + ) + ); + } + await Promise.all(tests); + }); +} + +async function createTestDirectory(file: string) { + const dir = await packageDirectory({cwd: dirname(file)}); + if (!dir) { + throw new Error(`Could not find package root for ${file}.`); + } + + return await mkdtemp(join(dir, 'doctest-')); +} + +async function run(tempdir: string, example: Readonly<ExampleCode>) { + const path = getTestPath(tempdir, example.code); + await compile(example.language, example.code, path, example); + try { + await import(pathToFileURL(path).toString()); + if (example.fail) { + throw new Error(`Expected failure.`); + } + } catch (error) { + if (!example.fail) { + throw error; + } + } +} + +function getTestPath(dir: string, code: string) { + return join( + dir, + `doctest-${createHash('md5').update(code).digest('hex')}.js` + ); +} + +async function compile( + language: Language, + sourceCode: string, + filePath: string, + location: ExtractedSourceLocation +) { + const output = await compileCode(language, sourceCode); + const map = await getExtractSourceMap(output.map, filePath, location); + await writeFile(filePath, inlineSourceMap(output.code, map)); +} + +function inlineSourceMap(code: string, sourceMap: RawSourceMap) { + return `${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from( + JSON.stringify(sourceMap) + ).toString('base64')}`; +} + +async function getExtractSourceMap( + map: string, + generatedFile: string, + location: ExtractedSourceLocation +) { + const sourceMap = JSON.parse(map) as RawSourceMap; + sourceMap.file = basename(generatedFile); + sourceMap.sourceRoot = ''; + sourceMap.sources = [ + relative(dirname(generatedFile), resolve(location.origin)), + ]; + const consumer = await new SourceMapConsumer(sourceMap); + const generator = new SourceMapGenerator({ + file: consumer.file, + sourceRoot: consumer.sourceRoot, + }); + // We want descending order of the `generated` property. + const positions = [...location.positions].reverse(); + consumer.eachMapping(mapping => { + // Note `mapping.originalLine` is the line number with respect to the + // extracted, raw code. + const {extracted, original} = positions.find(({extracted}) => { + return mapping.originalLine >= extracted; + })!; + + // `original.line` will account for `extracted`, so we need to subtract + // `extracted` to avoid duplicity. We also subtract 1 because `extracted` is + // 1-indexed. + mapping.originalLine -= extracted - 1; + + generator.addMapping({ + ...mapping, + original: { + line: mapping.originalLine + original.line - 1, + column: mapping.originalColumn + original.column - 1, + }, + generated: { + line: mapping.generatedLine, + column: mapping.generatedColumn, + }, + }); + }); + return generator.toJSON(); +} + +const LANGUAGE_TO_SYNTAX = { + [Language.TypeScript]: 'typescript', + [Language.JavaScript]: 'ecmascript', +} as const; + +async function compileCode(language: Language, code: string) { + return (await transform(code, { + sourceMaps: true, + inlineSourcesContent: false, + jsc: { + parser: { + syntax: LANGUAGE_TO_SYNTAX[language], + }, + target: 'es2022', + }, + })) as Required<Output>; +} + +const enum Option { + Ignore = 'ignore', + Fail = 'fail', +} + +function* extractExampleCode( + comments: Iterable<Readonly<Comment>> +): Iterable<Readonly<ExampleCode>> { + interface Context { + language: Language; + fail: boolean; + start: number; + } + for (const {file, text, position: loc} of comments) { + const {tags} = parse(text, { + unwrap: true, + tags: ['example'], + lineNumbers: true, + preserveWhitespace: true, + }); + for (const {description, lineNumber} of tags as Array< + Tag & {lineNumber: number} + >) { + if (!description) { + continue; + } + const lines = description.split('\n'); + const blocks: ExampleCode[] = []; + let context: Context | undefined; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const borderIndex = line.indexOf(CODE_FENCE); + if (borderIndex === -1) { + continue; + } + if (context) { + blocks.push({ + language: context.language, + code: lines.slice(context.start, i).join('\n'), + origin: file, + positions: [ + { + extracted: 1, + original: { + line: loc.line + lineNumber + context.start, + column: + loc.column + borderIndex + BLOCK_COMMENT_START.length + 1, + }, + }, + ], + fail: context.fail, + }); + context = undefined; + continue; + } + const [tag, ...options] = line + .slice(borderIndex + CODE_FENCE.length) + .split(' '); + if (options.includes(Option.Ignore)) { + // Ignore the code sample. + continue; + } + const fail = options.includes(Option.Fail); + // Code starts on the next line. + const start = i + 1; + if (!tag || tag.match(/js|javascript/)) { + context = {language: Language.JavaScript, fail, start}; + } else if (tag.match(/ts|typescript/)) { + context = {language: Language.TypeScript, fail, start}; + } + } + // Merging the blocks into a single block. + yield blocks.reduce( + (context, {language, code, positions: [position], fail}, index) => { + assert(position); + return { + origin: file, + language: language || context.language, + code: `${context.code}\n${code}`, + positions: [ + ...context.positions, + { + ...position, + extracted: + context.code.split('\n').length + + context.positions.at(-1)!.extracted - + // We subtract this because of the accumulated '\n'. + (index - 1), + }, + ], + fail: fail || context.fail, + }; + } + ); + } + } +} + +async function extractJSDocComments(file: string) { + const contents = await readFile(file, 'utf8'); + const comments: Comment[] = []; + parseJs(contents, { + ecmaVersion: 'latest', + sourceType: 'module', + locations: true, + sourceFile: file, + onComment(isBlock, text, _, __, loc) { + if (isBlock) { + comments.push({file, text, position: loc!}); + } + }, + }); + return comments; +} diff --git a/remote/test/puppeteer/tools/doctest/tsconfig.json b/remote/test/puppeteer/tools/doctest/tsconfig.json new file mode 100644 index 0000000000..bd70c0bd5e --- /dev/null +++ b/remote/test/puppeteer/tools/doctest/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./bin", + "sourceMap": true, + "declaration": false, + "declarationMap": false, + "composite": false, + }, +} diff --git a/remote/test/puppeteer/tools/doctest/tsdoc.json b/remote/test/puppeteer/tools/doctest/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/doctest/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/download_chrome_bidi.mjs b/remote/test/puppeteer/tools/download_chrome_bidi.mjs new file mode 100644 index 0000000000..faa73d9a95 --- /dev/null +++ b/remote/test/puppeteer/tools/download_chrome_bidi.mjs @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-console */ + +/** + * @fileoverview Installs a browser defined in `.browser` for Chromium-BiDi using + * `@puppeteer/browsers` to the directory provided as the first argument + * (default: cwd). The executable path is written to the `executablePath` output + * param for GitHub actions. + * + * Examples: + * + * - `node install-browser.mjs` + * - `node install-browser.mjs /tmp/cache` + */ +import {readFile} from 'node:fs/promises'; +import {createRequire} from 'node:module'; + +import actions from '@actions/core'; + +import {computeExecutablePath, install} from '@puppeteer/browsers'; + +const require = createRequire(import.meta.url); + +try { + const browserSpec = await readFile( + require.resolve('chromium-bidi/.browser', { + paths: [require.resolve('puppeteer-core')], + }), + 'utf-8' + ); + const cacheDir = process.argv[2] || process.cwd(); + // See .browser for the format. + const browser = browserSpec.split('@')[0]; + const buildId = browserSpec.split('@')[1]; + await install({ + browser, + buildId, + cacheDir, + }); + const executablePath = computeExecutablePath({ + cacheDir, + browser, + buildId, + }); + if (process.argv.indexOf('--shell') === -1) { + actions.setOutput('executablePath', executablePath); + } + console.log(executablePath); +} catch (err) { + actions.setFailed(`Failed to download the browser: ${err.message}`); +} diff --git a/remote/test/puppeteer/tools/ensure-pinned-deps.ts b/remote/test/puppeteer/tools/ensure-pinned-deps.ts new file mode 100644 index 0000000000..eb21fc647b --- /dev/null +++ b/remote/test/puppeteer/tools/ensure-pinned-deps.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2021 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {readdirSync, readFileSync} from 'fs'; +import {join} from 'path'; + +import {devDependencies} from '../package.json'; + +const LOCAL_PACKAGE_NAMES: string[] = []; + +const packagesDir = join(__dirname, '..', 'packages'); +for (const packageName of readdirSync(packagesDir)) { + const {name} = JSON.parse( + readFileSync(join(packagesDir, packageName, 'package.json'), 'utf8') + ); + LOCAL_PACKAGE_NAMES.push(name); +} + +const allDeps = {...devDependencies}; + +const invalidDeps = new Map<string, string>(); + +for (const [depKey, depValue] of Object.entries(allDeps)) { + if (depValue.startsWith('file:')) { + continue; + } + if (LOCAL_PACKAGE_NAMES.includes(depKey)) { + continue; + } + if (/[0-9]/.test(depValue[0]!)) { + continue; + } + + invalidDeps.set(depKey, depValue); +} + +if (invalidDeps.size > 0) { + console.error('Found non-pinned dependencies in package.json:'); + console.log( + [...invalidDeps.keys()] + .map(k => { + return ` ${k}`; + }) + .join('\n') + ); + process.exit(1); +} + +process.exit(0); diff --git a/remote/test/puppeteer/tools/eslint/package.json b/remote/test/puppeteer/tools/eslint/package.json new file mode 100644 index 0000000000..190367ae43 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/package.json @@ -0,0 +1,37 @@ +{ + "name": "@puppeteer/eslint", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/tools/eslint" + }, + "scripts": { + "build": "wireit", + "prepare": "wireit" + }, + "wireit": { + "build": { + "command": "tsc -b", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "lib/**", + "tsconfig.tsbuildinfo" + ] + }, + "prepare": { + "dependencies": [ + "build" + ] + } + }, + "author": "The Chromium Authors", + "license": "Apache-2.0", + "devDependencies": { + "@prettier/sync": "0.5.0" + } +} diff --git a/remote/test/puppeteer/tools/eslint/src/check-license.ts b/remote/test/puppeteer/tools/eslint/src/check-license.ts new file mode 100644 index 0000000000..7ae1a54384 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/src/check-license.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {TSESTree} from '@typescript-eslint/utils'; +import {ESLintUtils} from '@typescript-eslint/utils'; + +const createRule = ESLintUtils.RuleCreator(name => { + return `https://github.com/puppeteer/puppeteer/tree/main/tools/eslint/${name}.ts`; +}); + +const copyrightPattern = /Copyright ([0-9]{4}) Google Inc\./; + +// const currentYear = new Date().getFullYear; + +// const licenseHeader = `/** +// * @license +// * Copyright ${currentYear} Google Inc. +// * SPDX-License-Identifier: Apache-2.0 +// */`; + +const enforceLicenseRule = createRule<[], 'licenseRule'>({ + name: 'check-license', + meta: { + type: 'layout', + docs: { + description: 'Validate existence of license header', + requiresTypeChecking: false, + }, + fixable: undefined, // TODO: change to 'code' once fixer works. + schema: [], + messages: { + licenseRule: 'Add license header.', + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const comments = sourceCode.getAllComments(); + const header = + comments[0]?.type === 'Block' && isHeaderComment(comments[0]) + ? comments[0] + : null; + + function isHeaderComment(comment: TSESTree.Comment) { + if (comment && comment.range[0] >= 0 && comment.range[1] <= 88) { + return true; + } else { + return false; + } + } + + return { + Program(node) { + if ( + header && + header.value.includes('@license') && + header.value.includes('SPDX-License-Identifier: Apache-2.0') && + copyrightPattern.test(header.value) + ) { + return; + } + + // Add header license + if (!header || !header.value.includes('@license')) { + // const startLoc: [number, number] = [0, 88]; + context.report({ + node: node, + messageId: 'licenseRule', + // TODO: fix the fixer. + // fix(fixer) { + // return fixer.insertTextBeforeRange(startLoc, licenseHeader); + // }, + }); + } + }, + }; + }, +}); + +export = enforceLicenseRule; diff --git a/remote/test/puppeteer/tools/eslint/src/extensions.ts b/remote/test/puppeteer/tools/eslint/src/extensions.ts new file mode 100644 index 0000000000..89b9279625 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/src/extensions.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ESLintUtils} from '@typescript-eslint/utils'; + +const createRule = ESLintUtils.RuleCreator(name => { + return `https://github.com/puppeteer/puppeteer/tree/main/tools/eslint/${name}.js`; +}); + +const enforceExtensionRule = createRule<[], 'extensionsRule'>({ + name: 'extensions', + meta: { + docs: { + description: 'Requires `.js` for imports', + requiresTypeChecking: false, + }, + messages: { + extensionsRule: 'Add `.js` to import.', + }, + schema: [], + fixable: 'code', + type: 'problem', + }, + defaultOptions: [], + create(context) { + return { + ImportDeclaration(node): void { + const file = node.source.value.split('/').pop(); + + if (!node.source.value.startsWith('.') || file?.includes('.')) { + return; + } + context.report({ + node: node.source, + messageId: 'extensionsRule', + fix(fixer) { + return fixer.replaceText(node.source, `'${node.source.value}.js'`); + }, + }); + }, + }; + }, +}); + +export = enforceExtensionRule; diff --git a/remote/test/puppeteer/tools/eslint/src/prettier-comments.js b/remote/test/puppeteer/tools/eslint/src/prettier-comments.js new file mode 100644 index 0000000000..3cbaad2909 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/src/prettier-comments.js @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// @ts-nocheck +// TODO: We should convert this to types. + +const prettier = require('@prettier/sync'); + +const prettierConfigPath = '../../../.prettierrc.cjs'; +const prettierConfig = require(prettierConfigPath); + +const cleanupBlockComment = value => { + return value + .trim() + .split('\n') + .map(value => { + value = value.trim(); + if (value.startsWith('*')) { + value = value.slice(1); + if (value.startsWith(' ')) { + value = value.slice(1); + } + } + return value.trimEnd(); + }) + .join('\n') + .trim(); +}; + +const format = (value, offset) => { + return prettier + .format(value, { + ...prettierConfig, + parser: 'markdown', + // This is the print width minus 3 (the length of ` * `) and the offset. + printWidth: 80 - (offset + 3), + }) + .trim(); +}; + +const buildBlockComment = (value, offset) => { + const spaces = ' '.repeat(offset); + const lines = value.split('\n').map(line => { + return ` * ${line}`; + }); + lines.unshift('/**'); + lines.push(' */'); + lines.forEach((line, i) => { + lines[i] = `${spaces}${line}`; + }); + return lines.join('\n'); +}; + +/** + * @type import("eslint").Rule.RuleModule + */ +const prettierCommentsRule = { + meta: { + type: 'suggestion', + docs: { + description: 'Enforce Prettier formatting on comments', + recommended: false, + }, + fixable: 'code', + schema: [], + messages: {}, + }, + + create(context) { + for (const comment of context.sourceCode.getAllComments()) { + switch (comment.type) { + case 'Block': { + const offset = comment.loc.start.column; + const value = cleanupBlockComment(comment.value); + const formattedValue = format(value, offset); + if (formattedValue !== value) { + context.report({ + node: comment, + message: `Comment is not formatted correctly.`, + fix(fixer) { + return fixer.replaceText( + comment, + buildBlockComment(formattedValue, offset).trimStart() + ); + }, + }); + } + break; + } + } + } + return {}; + }, +}; + +module.exports = prettierCommentsRule; diff --git a/remote/test/puppeteer/tools/eslint/src/use-using.ts b/remote/test/puppeteer/tools/eslint/src/use-using.ts new file mode 100644 index 0000000000..0c727a4334 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/src/use-using.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ESLintUtils, TSESTree} from '@typescript-eslint/utils'; + +const usingSymbols = ['ElementHandle', 'JSHandle']; + +const createRule = ESLintUtils.RuleCreator(name => { + return `https://github.com/puppeteer/puppeteer/tree/main/tools/eslint/${name}.js`; +}); + +const useUsingRule = createRule<[], 'useUsing' | 'useUsingFix'>({ + name: 'use-using', + meta: { + docs: { + description: "Requires 'using' for element/JS handles.", + requiresTypeChecking: true, + }, + hasSuggestions: true, + messages: { + useUsing: "Use 'using'.", + useUsingFix: "Replace with 'using' to ignore.", + }, + schema: [], + type: 'problem', + }, + defaultOptions: [], + create(context) { + const services = ESLintUtils.getParserServices(context); + const checker = services.program.getTypeChecker(); + + return { + VariableDeclaration(node): void { + if (['using', 'await using'].includes(node.kind) || node.declare) { + return; + } + for (const declaration of node.declarations) { + if (declaration.id.type === TSESTree.AST_NODE_TYPES.Identifier) { + const tsNode = services.esTreeNodeToTSNodeMap.get(declaration.id); + const type = checker.getTypeAtLocation(tsNode); + let isElementHandleReference = false; + if (type.isUnionOrIntersection()) { + for (const member of type.types) { + if ( + member.symbol !== undefined && + usingSymbols.includes(member.symbol.escapedName as string) + ) { + isElementHandleReference = true; + break; + } + } + } else { + isElementHandleReference = + type.symbol !== undefined + ? usingSymbols.includes(type.symbol.escapedName as string) + : false; + } + if (isElementHandleReference) { + context.report({ + node: declaration.id, + messageId: 'useUsing', + suggest: [ + { + messageId: 'useUsingFix', + fix(fixer) { + return fixer.replaceTextRange( + [node.range[0], node.range[0] + node.kind.length], + 'using' + ); + }, + }, + ], + }); + } + } + } + }, + }; + }, +}); + +export = useUsingRule; diff --git a/remote/test/puppeteer/tools/eslint/tsconfig.json b/remote/test/puppeteer/tools/eslint/tsconfig.json new file mode 100644 index 0000000000..da26cc936b --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "./src", + "outDir": "./lib", + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "composite": false, + "removeComments": true, + }, +} diff --git a/remote/test/puppeteer/tools/eslint/tsdoc.json b/remote/test/puppeteer/tools/eslint/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/eslint/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/generate_module_package_json.ts b/remote/test/puppeteer/tools/generate_module_package_json.ts new file mode 100644 index 0000000000..f13672e9d3 --- /dev/null +++ b/remote/test/puppeteer/tools/generate_module_package_json.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {mkdirSync, writeFileSync} from 'fs'; +import {dirname} from 'path'; + +/** + * Outputs the dummy package.json file to the path specified + * by the first argument. + */ +mkdirSync(dirname(process.argv[2]), {recursive: true}); +writeFileSync(process.argv[2], `{"type": "module"}`); diff --git a/remote/test/puppeteer/tools/get_deprecated_version_range.js b/remote/test/puppeteer/tools/get_deprecated_version_range.js new file mode 100644 index 0000000000..bac40e3677 --- /dev/null +++ b/remote/test/puppeteer/tools/get_deprecated_version_range.js @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +const { + versionsPerRelease, + lastMaintainedChromeVersion, +} = require('../versions.js'); + +const version = versionsPerRelease.get(lastMaintainedChromeVersion); +if (version.toLowerCase() === 'next') { + console.error('Unexpected NEXT Puppeteer version in versions.js'); + process.exit(1); +} +console.log(`< ${version.substring(1)}`); +process.exit(0); diff --git a/remote/test/puppeteer/tools/mocha-runner/README.md b/remote/test/puppeteer/tools/mocha-runner/README.md new file mode 100644 index 0000000000..0bdd9f253b --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/README.md @@ -0,0 +1,103 @@ +# Mocha Runner + +Mocha Runner is a test runner on top of mocha. +It uses `/test/TestSuites.json` and `/test/TestExpectations.json` files to run mocha tests in multiple configurations and interpret results. + +## Running tests for Mocha Runner itself. + +```bash +npm test +``` + +## Running tests using Mocha Runner + +```bash +npm run build && npm run test +``` + +By default, the runner runs all test suites applicable to the current platform. +To pick a test suite, provide the `--test-suite` arguments. For example, + +```bash +npm run build && npm run test -- --test-suite chrome-headless +``` + +## TestSuites.json + +Define test suites via the `testSuites` attribute. `parameters` can be used in the `TestExpectations.json` to disable tests +based on parameters. The meaning for parameters is defined in `parameterDefinitions` which tell what env object corresponds +to the given parameter. + +## TestExpectations.json + +An expectation looks like this: + +```json +{ + "testIdPattern": "[accessibility.spec]", + "platforms": ["darwin", "win32", "linux"], + "parameters": ["firefox"], + "expectations": ["SKIP"] +} +``` + +| Field | Description | Type | Match Logic | +| --------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------- | +| `testIdPattern` | Defines the full name (or pattern) to match against test name | string | - | +| `platforms` | Defines the platforms the expectation is for | Array<`linux` \| `win32` \|`darwin`> | `OR` | +| `parameters` | Defines the parameters that the test has to match | Array<[ParameterDefinitions](https://github.com/puppeteer/puppeteer/blob/main/test/TestSuites.json)> | `AND` | +| `expectations` | The list of test results that are considered to be acceptable | Array<`PASS` \| `FAIL` \| `TIMEOUT` \| `SKIP`> | `OR` | + +> Order of defining expectations matters. The latest expectation that is set will take president over earlier ones. + +> Adding `SKIP` to `expectations` will prevent the test from running, no matter if there are other expectations. + +### Using pattern in `testIdPattern` + +Sometimes we want a whole group of test to run. For that we can use a +pattern to achieve. +Pattern are defined with the use of `*` (using greedy method). + +Examples: +| Pattern | Description | Example Pattern | Example match | +|------------------------|---------------------------------------------------------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------| +| `*` | Match all tests | - | - | +| `[test.spec] *` | Matches tests for the given file | `[jshandle.spec] *` | `[jshandle] JSHandle JSHandle.toString should work for primitives` | +| `[test.spec] <text> *` | Matches tests with for a given test with a specific prefixed test (usually a describe node) | `[page.spec] Page Page.goto *` | `[page.spec] Page Page.goto should work`,<br>`[page.spec] Page Page.goto should work with anchor navigation` | +| `[test.spec] * <text>` | Matches test with a surfix | `[navigation.spec] * should work` | `[navigation.spec] navigation Page.goto should work`,<br>`[navigation.spec] navigation Page.waitForNavigation should work` | + +## Updating Expectations + +Currently, expectations are updated manually. The test runner outputs the +suggested changes to the expectation file if the test run does not match +expectations. + +## Debugging flaky test + +### Utility functions: + +| Utility | Params | Description | +| ------------------------ | ------------------------------- | --------------------------------------------------------------------------------- | +| `describe.withDebugLogs` | `(title, <DescribeBody>)` | Capture and print debug logs for each test that failed | +| `it.deflake` | `(repeat, title, <itFunction>)` | Reruns the test N number of times and print the debug logs if for the failed runs | +| `it.deflakeOnly` | `(repeat, title, <itFunction>)` | Same as `it.deflake` but runs only this specific test | + +### With Environment variable + +Run the test with the following environment variable to wrap it around `describe.withDebugLogs`. Example: + +```bash +PUPPETEER_DEFLAKE_TESTS="[navigation.spec] navigation Page.goto should navigate to empty page with networkidle0" npm run test:chrome:headless +``` + +It also works with [patterns](#1--this-is-my-header) just like `TestExpectations.json` + +```bash +PUPPETEER_DEFLAKE_TESTS="[navigation.spec] *" npm run test:chrome:headless +``` + +By default the test is rerun 100 times, but you can control this as well: + +```bash +PUPPETEER_DEFLAKE_RETRIES=1000 PUPPETEER_DEFLAKE_TESTS="[navigation.spec] *" npm run test:chrome:headless +``` diff --git a/remote/test/puppeteer/tools/mocha-runner/package.json b/remote/test/puppeteer/tools/mocha-runner/package.json new file mode 100644 index 0000000000..26612e504a --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/package.json @@ -0,0 +1,43 @@ +{ + "name": "@puppeteer/mocha-runner", + "version": "0.1.0", + "type": "commonjs", + "private": true, + "bin": "./bin/mocha-runner.js", + "description": "Mocha runner for Puppeteer", + "license": "Apache-2.0", + "scripts": { + "build": "wireit", + "test": "wireit", + "clean": "../clean.js" + }, + "wireit": { + "build": { + "command": "tsc -b && chmod +x ./bin/mocha-runner.js", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "bin/**", + "tsconfig.tsbuildinfo" + ], + "dependencies": [ + "../../packages/puppeteer-core:build" + ] + }, + "test": { + "command": "c8 node ./bin/test.js", + "dependencies": [ + "build" + ] + } + }, + "devDependencies": { + "@types/yargs": "17.0.32", + "c8": "9.1.0", + "glob": "10.3.10", + "yargs": "17.7.2", + "zod": "3.22.4" + } +} diff --git a/remote/test/puppeteer/tools/mocha-runner/src/interface.ts b/remote/test/puppeteer/tools/mocha-runner/src/interface.ts new file mode 100644 index 0000000000..fe0f7e18b5 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/interface.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import Mocha from 'mocha'; +import commonInterface from 'mocha/lib/interfaces/common'; +import { + setLogCapture, + getCapturedLogs, +} from 'puppeteer-core/internal/common/Debug.js'; + +import {testIdMatchesExpectationPattern} from './utils.js'; + +type SuiteFunction = ((this: Mocha.Suite) => void) | undefined; +type ExclusiveSuiteFunction = (this: Mocha.Suite) => void; + +const skippedTests: Array<{testIdPattern: string; skip: true}> = process.env[ + 'PUPPETEER_SKIPPED_TEST_CONFIG' +] + ? JSON.parse(process.env['PUPPETEER_SKIPPED_TEST_CONFIG']) + : []; + +const deflakeRetries = Number( + process.env['PUPPETEER_DEFLAKE_RETRIES'] + ? process.env['PUPPETEER_DEFLAKE_RETRIES'] + : 100 +); +const deflakeTestPattern: string | undefined = + process.env['PUPPETEER_DEFLAKE_TESTS']; + +function shouldSkipTest(test: Mocha.Test): boolean { + // TODO: more efficient lookup. + const definition = skippedTests.find(skippedTest => { + return testIdMatchesExpectationPattern(test, skippedTest.testIdPattern); + }); + if (definition && definition.skip) { + return true; + } + return false; +} + +function shouldDeflakeTest(test: Mocha.Test): boolean { + if (deflakeTestPattern) { + // TODO: cache if we have seen it already + return testIdMatchesExpectationPattern(test, deflakeTestPattern); + } + return false; +} + +function dumpLogsIfFail(this: Mocha.Context) { + if (this.currentTest?.state === 'failed') { + console.log( + `\n"${this.currentTest.fullTitle()}" failed. Here is a debug log:` + ); + console.log(getCapturedLogs().join('\n') + '\n'); + } + setLogCapture(false); +} + +function customBDDInterface(suite: Mocha.Suite) { + const suites: [Mocha.Suite] = [suite]; + + suite.on( + Mocha.Suite.constants.EVENT_FILE_PRE_REQUIRE, + function (context, file, mocha) { + const common = commonInterface(suites, context, mocha); + + context['before'] = common.before; + context['after'] = common.after; + context['beforeEach'] = common.beforeEach; + context['afterEach'] = common.afterEach; + if (mocha.options.delay) { + context['run'] = common.runWithSuite(suite); + } + function describe(title: string, fn: SuiteFunction) { + return common.suite.create({ + title: title, + file: file, + fn: fn, + }); + } + describe.only = function (title: string, fn: ExclusiveSuiteFunction) { + return common.suite.only({ + title: title, + file: file, + fn: fn, + isOnly: true, + }); + }; + + describe.skip = function (title: string, fn: SuiteFunction) { + return common.suite.skip({ + title: title, + file: file, + fn: fn, + }); + }; + + describe.withDebugLogs = function ( + description: string, + body: (this: Mocha.Suite) => void + ): void { + context['describe']('with Debug Logs', () => { + context['beforeEach'](() => { + setLogCapture(true); + }); + context['afterEach'](dumpLogsIfFail); + context['describe'](description, body); + }); + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + context['describe'] = describe; + + function it(title: string, fn: Mocha.TestFunction, itOnly = false) { + const suite = suites[0]! as Mocha.Suite; + const test = new Mocha.Test(title, suite.isPending() ? undefined : fn); + test.file = file; + test.parent = suite; + + const describeOnly = Boolean( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + suite.parent?._onlySuites.find(child => { + return child === suite; + }) + ); + if (shouldDeflakeTest(test)) { + const deflakeSuit = Mocha.Suite.create(suite, 'with Debug Logs'); + test.file = file; + deflakeSuit.beforeEach(function () { + setLogCapture(true); + }); + deflakeSuit.afterEach(dumpLogsIfFail); + for (let i = 0; i < deflakeRetries; i++) { + deflakeSuit.addTest(test.clone()); + } + return test; + } else if (!(itOnly || describeOnly) && shouldSkipTest(test)) { + const test = new Mocha.Test(title); + test.file = file; + suite.addTest(test); + return test; + } else { + suite.addTest(test); + return test; + } + } + + it.only = function (title: string, fn: Mocha.TestFunction) { + return common.test.only( + mocha, + (context['it'] as unknown as typeof it)(title, fn, true) + ); + }; + + it.skip = function (title: string) { + return context['it'](title); + }; + + function wrapDeflake( + func: Function + ): (repeats: number, title: string, fn: Mocha.AsyncFunc) => void { + return (repeats: number, title: string, fn: Mocha.AsyncFunc): void => { + (context['describe'] as unknown as typeof describe).withDebugLogs( + 'with Debug Logs', + () => { + for (let i = 1; i <= repeats; i++) { + func(`${i}/${title}`, fn); + } + } + ); + }; + } + + it.deflake = wrapDeflake(it); + it.deflakeOnly = wrapDeflake(it.only); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + context.it = it; + } + ); +} + +customBDDInterface.description = 'Custom BDD'; + +module.exports = customBDDInterface; diff --git a/remote/test/puppeteer/tools/mocha-runner/src/mocha-runner.ts b/remote/test/puppeteer/tools/mocha-runner/src/mocha-runner.ts new file mode 100644 index 0000000000..1707e4cc41 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/mocha-runner.ts @@ -0,0 +1,330 @@ +#! /usr/bin/env node + +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {randomUUID} from 'crypto'; +import fs from 'fs'; +import {spawn} from 'node:child_process'; +import os from 'os'; +import path from 'path'; + +import {globSync} from 'glob'; +import yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; + +import { + zPlatform, + zTestSuiteFile, + type MochaResults, + type Platform, + type TestExpectation, + type TestSuite, + type TestSuiteFile, +} from './types.js'; +import { + extendProcessEnv, + filterByParameters, + filterByPlatform, + getExpectationUpdates, + printSuggestions, + readJSON, + writeJSON, + type RecommendedExpectation, +} from './utils.js'; + +const { + _: mochaArgs, + testSuite: testSuiteId, + saveStatsTo, + cdpTests: includeCdpTests, + suggestions: provideSuggestions, + coverage: useCoverage, + minTests, + shard, + reporter, + printMemory, +} = yargs(hideBin(process.argv)) + .parserConfiguration({'unknown-options-as-args': true}) + .scriptName('@puppeteer/mocha-runner') + .option('coverage', { + boolean: true, + default: true, + }) + .option('suggestions', { + boolean: true, + default: true, + }) + .option('cdp-tests', { + boolean: true, + default: true, + }) + .option('save-stats-to', { + string: true, + requiresArg: true, + }) + .option('min-tests', { + number: true, + default: 0, + requiresArg: true, + }) + .option('test-suite', { + string: true, + requiresArg: true, + }) + .option('shard', { + string: true, + requiresArg: true, + }) + .option('reporter', { + string: true, + requiresArg: true, + }) + .option('print-memory', { + boolean: true, + default: false, + }) + .parseSync(); + +function getApplicableTestSuites( + parsedSuitesFile: TestSuiteFile, + platform: Platform +): TestSuite[] { + let applicableSuites: TestSuite[] = []; + + if (!testSuiteId) { + applicableSuites = filterByPlatform(parsedSuitesFile.testSuites, platform); + } else { + const testSuite = parsedSuitesFile.testSuites.find(suite => { + return suite.id === testSuiteId; + }); + + if (!testSuite) { + console.error(`Test suite ${testSuiteId} is not defined`); + process.exit(1); + } + + if (!testSuite.platforms.includes(platform)) { + console.warn( + `Test suite ${testSuiteId} is not enabled for your platform. Running it anyway.` + ); + } + + applicableSuites = [testSuite]; + } + + return applicableSuites; +} + +async function main() { + let statsPath = saveStatsTo; + if (statsPath && statsPath.includes('INSERTID')) { + statsPath = statsPath.replace(/INSERTID/gi, randomUUID()); + } + + const platform = zPlatform.parse(os.platform()); + + const expectations = readJSON( + path.join(process.cwd(), 'test', 'TestExpectations.json') + ) as TestExpectation[]; + + const parsedSuitesFile = zTestSuiteFile.parse( + readJSON(path.join(process.cwd(), 'test', 'TestSuites.json')) + ); + + const applicableSuites = getApplicableTestSuites(parsedSuitesFile, platform); + + console.log('Planning to run the following test suites', applicableSuites); + if (statsPath) { + console.log('Test stats will be saved to', statsPath); + } + + let fail = false; + const recommendations: RecommendedExpectation[] = []; + try { + for (const suite of applicableSuites) { + const parameters = suite.parameters; + + const applicableExpectations = filterByParameters( + filterByPlatform(expectations, platform), + parameters + ).reverse(); + + // Add more logging when the GitHub Action Debugging option is set + // https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + const githubActionDebugging = process.env['RUNNER_DEBUG'] + ? { + DEBUG: 'puppeteer:*', + EXTRA_LAUNCH_OPTIONS: JSON.stringify({ + dumpio: true, + extraPrefsFirefox: { + 'remote.log.level': 'Trace', + }, + }), + } + : {}; + + const env = extendProcessEnv([ + ...parameters.map(param => { + return parsedSuitesFile.parameterDefinitions[param]; + }), + { + PUPPETEER_SKIPPED_TEST_CONFIG: JSON.stringify( + applicableExpectations.map(ex => { + return { + testIdPattern: ex.testIdPattern, + skip: ex.expectations.includes('SKIP'), + }; + }) + ), + }, + githubActionDebugging, + ]); + + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-test-runner-') + ); + const tmpFilename = statsPath + ? statsPath + : path.join(tmpDir, 'output.json'); + console.log('Running', JSON.stringify(parameters), tmpFilename); + const args = [ + '-u', + path.join(__dirname, 'interface.js'), + '-R', + !reporter ? path.join(__dirname, 'reporter.js') : reporter, + '-O', + `output=${tmpFilename}`, + '-n', + 'trace-warnings', + ]; + + if (printMemory) { + args.push('-n', 'expose-gc'); + } + + const specPattern = 'test/build/**/*.spec.js'; + const specs = globSync(specPattern, { + ignore: !includeCdpTests ? 'test/build/cdp/**/*.spec.js' : undefined, + }).sort((a, b) => { + return a.localeCompare(b); + }); + if (shard) { + // Shard ID is 1-based. + const [shardId, shards] = shard.split('-').map(s => { + return Number(s); + }) as [number, number]; + const argsLength = args.length; + for (let i = 0; i < specs.length; i++) { + if (i % shards === shardId - 1) { + args.push(specs[i]!); + } + } + if (argsLength === args.length) { + throw new Error('Shard did not result in any test files'); + } + console.log( + `Running shard ${shardId}-${shards}. Picked ${ + args.length - argsLength + } files out of ${specs.length}.` + ); + } else { + args.push(...specs); + } + const handle = spawn( + 'npx', + [ + ...(useCoverage + ? [ + 'c8', + '--check-coverage', + '--lines', + String(suite.expectedLineCoverage), + 'npx', + ] + : []), + 'mocha', + ...mochaArgs.map(String), + ...args, + ], + { + shell: true, + cwd: process.cwd(), + stdio: 'inherit', + env, + } + ); + await new Promise<void>((resolve, reject) => { + handle.on('error', err => { + reject(err); + }); + handle.on('close', () => { + resolve(); + }); + }); + console.log('Finished', JSON.stringify(parameters)); + try { + const results = readJSON(tmpFilename) as MochaResults; + const updates = getExpectationUpdates(results, applicableExpectations, { + platforms: [os.platform()], + parameters, + }); + const totalTests = results.stats.tests; + results.parameters = parameters; + results.platform = platform; + results.date = new Date().toISOString(); + if (updates.length > 0) { + fail = true; + recommendations.push(...updates); + results.updates = updates; + writeJSON(tmpFilename, results); + } else { + if (!shard && totalTests < minTests) { + fail = true; + console.log( + `Test run matches expectations but the number of discovered tests is too low (expected: ${minTests}, actual: ${totalTests}).` + ); + writeJSON(tmpFilename, results); + continue; + } + console.log('Test run matches expectations'); + writeJSON(tmpFilename, results); + continue; + } + } catch (err) { + fail = true; + console.error(err); + } + } + } catch (err) { + fail = true; + console.error(err); + } finally { + if (!!provideSuggestions) { + printSuggestions( + recommendations, + 'add', + 'Add the following to TestExpectations.json to ignore the error:' + ); + printSuggestions( + recommendations, + 'remove', + 'Remove the following from the TestExpectations.json to ignore the error:' + ); + printSuggestions( + recommendations, + 'update', + 'Update the following expectations in the TestExpectations.json to ignore the error:' + ); + } + process.exit(fail ? 1 : 0); + } +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/remote/test/puppeteer/tools/mocha-runner/src/reporter.ts b/remote/test/puppeteer/tools/mocha-runner/src/reporter.ts new file mode 100644 index 0000000000..7acd5319fe --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/reporter.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import Mocha from 'mocha'; + +class SpecJSONReporter extends Mocha.reporters.Spec { + constructor(runner: Mocha.Runner, options?: Mocha.MochaOptions) { + super(runner, options); + Mocha.reporters.JSON.call(this, runner, options); + } +} + +module.exports = SpecJSONReporter; diff --git a/remote/test/puppeteer/tools/mocha-runner/src/test.ts b/remote/test/puppeteer/tools/mocha-runner/src/test.ts new file mode 100644 index 0000000000..5510966235 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/test.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +import type {Platform, TestExpectation, MochaTestResult} from './types.js'; +import { + filterByParameters, + getTestResultForFailure, + isWildCardPattern, + testIdMatchesExpectationPattern, + getExpectationUpdates, +} from './utils.js'; +import {getFilename, extendProcessEnv} from './utils.js'; + +describe('extendProcessEnv', () => { + it('should extend env variables for the subprocess', () => { + const env = extendProcessEnv([{TEST: 'TEST'}, {TEST2: 'TEST2'}]); + assert.equal(env['TEST'], 'TEST'); + assert.equal(env['TEST2'], 'TEST2'); + }); +}); + +describe('getFilename', () => { + it('extract filename for a path', () => { + assert.equal(getFilename('/etc/test.ts'), 'test'); + assert.equal(getFilename('/etc/test.js'), 'test'); + }); +}); + +describe('getTestResultForFailure', () => { + it('should get a test result for a mocha failure', () => { + assert.equal( + getTestResultForFailure({err: {code: 'ERR_MOCHA_TIMEOUT'}}), + 'TIMEOUT' + ); + assert.equal(getTestResultForFailure({err: {code: 'ERROR'}}), 'FAIL'); + }); +}); + +describe('filterByParameters', () => { + it('should filter a list of expectations by parameters', () => { + const expectations: TestExpectation[] = [ + { + testIdPattern: + '[oopif.spec] OOPIF "after all" hook for "should keep track of a frames OOP state"', + platforms: ['darwin'], + parameters: ['firefox', 'headless'], + expectations: ['FAIL'], + }, + ]; + assert.equal( + filterByParameters(expectations, ['firefox', 'headless']).length, + 1 + ); + assert.equal(filterByParameters(expectations, ['firefox']).length, 0); + assert.equal( + filterByParameters(expectations, ['firefox', 'headless', 'other']).length, + 1 + ); + assert.equal(filterByParameters(expectations, ['other']).length, 0); + }); +}); + +describe('isWildCardPattern', () => { + it('should detect if an expectation is a wildcard pattern', () => { + assert.equal(isWildCardPattern(''), false); + assert.equal(isWildCardPattern('a'), false); + assert.equal(isWildCardPattern('*'), true); + + assert.equal(isWildCardPattern('[queryHandler.spec]'), false); + assert.equal(isWildCardPattern('[queryHandler.spec] *'), true); + assert.equal(isWildCardPattern(' [queryHandler.spec] '), false); + + assert.equal(isWildCardPattern('[queryHandler.spec] Query'), false); + assert.equal(isWildCardPattern('[queryHandler.spec] Page *'), true); + assert.equal( + isWildCardPattern('[queryHandler.spec] Page Page.goto *'), + true + ); + }); +}); + +describe('testIdMatchesExpectationPattern', () => { + const expectations: Array<[string, boolean]> = [ + ['', false], + ['*', true], + ['* should work', true], + ['* Page.setContent *', true], + ['* should work as expected', false], + ['Page.setContent *', false], + ['[page.spec]', false], + ['[page.spec] *', true], + ['[page.spec] Page *', true], + ['[page.spec] Page Page.setContent *', true], + ['[page.spec] Page Page.setContent should work', true], + ['[page.spec] Page * should work', true], + ['[page.spec] * Page.setContent *', true], + ['[jshandle.spec] *', false], + ['[jshandle.spec] JSHandle should work', false], + ]; + + it('with MochaTest', () => { + const test = { + title: 'should work', + file: 'page.spec.ts', + fullTitle() { + return 'Page Page.setContent should work'; + }, + }; + + for (const [pattern, expected] of expectations) { + assert.equal( + testIdMatchesExpectationPattern(test, pattern), + expected, + `Expected "${pattern}" to yield "${expected}"` + ); + } + }); + + it('with MochaTestResult', () => { + const test: MochaTestResult = { + title: 'should work', + file: 'page.spec.ts', + fullTitle: 'Page Page.setContent should work', + }; + + for (const [pattern, expected] of expectations) { + assert.equal( + testIdMatchesExpectationPattern(test, pattern), + expected, + `Expected "${pattern}" to yield "${expected}"` + ); + } + }); +}); + +describe('getExpectationUpdates', () => { + it('should generate an update for expectations if a test passed with a fail expectation', () => { + const mochaResults = { + stats: {tests: 1}, + pending: [], + passes: [ + { + fullTitle: 'Page Page.setContent should work', + title: 'should work', + file: 'page.spec.ts', + }, + ], + failures: [], + }; + const expectations = [ + { + testIdPattern: '[page.spec] Page Page.setContent should work', + platforms: ['darwin'] as Platform[], + parameters: ['test'], + expectations: ['FAIL' as const], + }, + ]; + const updates = getExpectationUpdates(mochaResults, expectations, { + platforms: ['darwin'] as Platform[], + parameters: ['test'], + }); + assert.deepEqual(updates, [ + { + action: 'remove', + basedOn: { + expectations: ['FAIL'], + parameters: ['test'], + platforms: ['darwin'], + testIdPattern: '[page.spec] Page Page.setContent should work', + }, + expectation: { + expectations: ['FAIL'], + parameters: ['test'], + platforms: ['darwin'], + testIdPattern: '[page.spec] Page Page.setContent should work', + }, + }, + ]); + }); + + it('should not generate an update for successful retries', () => { + const mochaResults = { + stats: {tests: 1}, + pending: [], + passes: [ + { + fullTitle: 'Page Page.setContent should work', + title: 'should work', + file: 'page.spec.ts', + }, + ], + failures: [ + { + fullTitle: 'Page Page.setContent should work', + title: 'should work', + file: 'page.spec.ts', + err: {code: 'Timeout'}, + }, + ], + }; + const updates = getExpectationUpdates(mochaResults, [], { + platforms: ['darwin'], + parameters: ['test'], + }); + assert.deepEqual(updates, []); + }); +}); diff --git a/remote/test/puppeteer/tools/mocha-runner/src/types.ts b/remote/test/puppeteer/tools/mocha-runner/src/types.ts new file mode 100644 index 0000000000..01dc4d6be6 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/types.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {z} from 'zod'; + +import type {RecommendedExpectation} from './utils.js'; + +export const zPlatform = z.enum(['win32', 'linux', 'darwin']); + +export type Platform = z.infer<typeof zPlatform>; + +export const zTestSuite = z.object({ + id: z.string(), + platforms: z.array(zPlatform), + parameters: z.array(z.string()), + expectedLineCoverage: z.number(), +}); + +export type TestSuite = z.infer<typeof zTestSuite>; + +export const zTestSuiteFile = z.object({ + testSuites: z.array(zTestSuite), + parameterDefinitions: z.record(z.any()), +}); + +export type TestSuiteFile = z.infer<typeof zTestSuiteFile>; + +export type TestResult = 'PASS' | 'FAIL' | 'TIMEOUT' | 'SKIP'; + +export interface TestExpectation { + testIdPattern: string; + platforms: NodeJS.Platform[]; + parameters: string[]; + expectations: TestResult[]; +} + +export interface MochaTestResult { + fullTitle: string; + title: string; + file: string; + err?: {code: string}; +} + +export interface MochaResults { + stats: {tests: number}; + pending: MochaTestResult[]; + passes: MochaTestResult[]; + failures: MochaTestResult[]; + // Added by mocha-runner. + updates?: RecommendedExpectation[]; + parameters?: string[]; + platform?: string; + date?: string; +} diff --git a/remote/test/puppeteer/tools/mocha-runner/src/utils.ts b/remote/test/puppeteer/tools/mocha-runner/src/utils.ts new file mode 100644 index 0000000000..066c5fbe57 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/src/utils.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; + +import type { + MochaTestResult, + TestExpectation, + MochaResults, + TestResult, +} from './types.js'; + +export function extendProcessEnv(envs: object[]): NodeJS.ProcessEnv { + const env = envs.reduce( + (acc: object, item: object) => { + Object.assign(acc, item); + return acc; + }, + { + ...process.env, + } + ); + + if (process.env['CI']) { + const puppeteerEnv = Object.entries(env).reduce( + (acc, [key, value]) => { + if (key.startsWith('PUPPETEER_')) { + acc[key] = value; + } + + return acc; + }, + {} as Record<string, unknown> + ); + + console.log( + 'PUPPETEER env:\n', + JSON.stringify(puppeteerEnv, null, 2), + '\n' + ); + } + + return env as NodeJS.ProcessEnv; +} + +export function getFilename(file: string): string { + return path.basename(file).replace(path.extname(file), ''); +} + +export function readJSON(path: string): unknown { + return JSON.parse(fs.readFileSync(path, 'utf-8')); +} + +export function writeJSON(path: string, json: unknown): unknown { + return fs.writeFileSync(path, JSON.stringify(json, null, 2)); +} + +export function filterByPlatform<T extends {platforms: NodeJS.Platform[]}>( + items: T[], + platform: NodeJS.Platform +): T[] { + return items.filter(item => { + return item.platforms.includes(platform); + }); +} + +export function prettyPrintJSON(json: unknown): void { + console.log(JSON.stringify(json, null, 2)); +} + +export function printSuggestions( + recommendations: RecommendedExpectation[], + action: RecommendedExpectation['action'], + message: string +): void { + const toPrint = recommendations.filter(item => { + return item.action === action; + }); + if (toPrint.length) { + console.log(message); + prettyPrintJSON( + toPrint.map(item => { + return item.expectation; + }) + ); + if (action !== 'remove') { + console.log( + 'The recommendations are based on the following applied expectations:' + ); + prettyPrintJSON( + toPrint.map(item => { + return item.basedOn; + }) + ); + } + } +} + +export function filterByParameters( + expectations: TestExpectation[], + parameters: string[] +): TestExpectation[] { + const querySet = new Set(parameters); + return expectations.filter(ex => { + return ex.parameters.every(param => { + return querySet.has(param); + }); + }); +} + +/** + * The last expectation that matches an empty string as all tests pattern + * or the name of the file or the whole name of the test the filter wins. + */ +export function findEffectiveExpectationForTest( + expectations: TestExpectation[], + result: MochaTestResult +): TestExpectation | undefined { + return expectations.find(expectation => { + return testIdMatchesExpectationPattern(result, expectation.testIdPattern); + }); +} + +export interface RecommendedExpectation { + expectation: TestExpectation; + action: 'remove' | 'add' | 'update'; + basedOn?: TestExpectation; +} + +export function isWildCardPattern(testIdPattern: string): boolean { + return testIdPattern.includes('*'); +} + +export function getExpectationUpdates( + results: MochaResults, + expectations: TestExpectation[], + context: { + platforms: NodeJS.Platform[]; + parameters: string[]; + } +): RecommendedExpectation[] { + const output = new Map<string, RecommendedExpectation>(); + + const passesByKey = results.passes.reduce((acc, pass) => { + acc.add(getTestId(pass.file, pass.fullTitle)); + return acc; + }, new Set()); + + for (const pass of results.passes) { + const expectationEntry = findEffectiveExpectationForTest( + expectations, + pass + ); + if (expectationEntry && !expectationEntry.expectations.includes('PASS')) { + if (isWildCardPattern(expectationEntry.testIdPattern)) { + addEntry({ + expectation: { + testIdPattern: getTestId(pass.file, pass.fullTitle), + platforms: context.platforms, + parameters: context.parameters, + expectations: ['PASS'], + }, + action: 'add', + basedOn: expectationEntry, + }); + } else { + addEntry({ + expectation: expectationEntry, + action: 'remove', + basedOn: expectationEntry, + }); + } + } + } + + for (const failure of results.failures) { + // If an error occurs during a hook + // the error not have a file associated with it + if (!failure.file) { + console.error('Hook failed:', failure.err); + addEntry({ + expectation: { + testIdPattern: failure.fullTitle, + platforms: context.platforms, + parameters: context.parameters, + expectations: [], + }, + action: 'add', + }); + continue; + } + + if (passesByKey.has(getTestId(failure.file, failure.fullTitle))) { + continue; + } + + const expectationEntry = findEffectiveExpectationForTest( + expectations, + failure + ); + if (expectationEntry && !expectationEntry.expectations.includes('SKIP')) { + if ( + !expectationEntry.expectations.includes( + getTestResultForFailure(failure) + ) + ) { + // If the effective explanation is a wildcard, we recommend adding a new + // expectation instead of updating the wildcard that might affect multiple + // tests. + if (isWildCardPattern(expectationEntry.testIdPattern)) { + addEntry({ + expectation: { + testIdPattern: getTestId(failure.file, failure.fullTitle), + platforms: context.platforms, + parameters: context.parameters, + expectations: [getTestResultForFailure(failure)], + }, + action: 'add', + basedOn: expectationEntry, + }); + } else { + addEntry({ + expectation: { + ...expectationEntry, + expectations: [ + ...expectationEntry.expectations, + getTestResultForFailure(failure), + ], + }, + action: 'update', + basedOn: expectationEntry, + }); + } + } + } else if (!expectationEntry) { + addEntry({ + expectation: { + testIdPattern: getTestId(failure.file, failure.fullTitle), + platforms: context.platforms, + parameters: context.parameters, + expectations: [getTestResultForFailure(failure)], + }, + action: 'add', + }); + } + } + + function addEntry(value: RecommendedExpectation) { + const key = JSON.stringify(value); + if (!output.has(key)) { + output.set(key, value); + } + } + + return [...output.values()]; +} + +export function getTestResultForFailure( + test: Pick<MochaTestResult, 'err'> +): TestResult { + return test.err?.code === 'ERR_MOCHA_TIMEOUT' ? 'TIMEOUT' : 'FAIL'; +} + +export function getTestId(file: string, fullTitle?: string): string { + return fullTitle + ? `[${getFilename(file)}] ${fullTitle}` + : `[${getFilename(file)}]`; +} + +export function testIdMatchesExpectationPattern( + test: MochaTestResult | Pick<Mocha.Test, 'title' | 'file' | 'fullTitle'>, + pattern: string +): boolean { + const patternRegExString = pattern + // Replace `*` with non special character + .replace(/\*/g, '--STAR--') + // Escape special characters https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Replace placeholder with greedy match + .replace(/--STAR--/g, '(.*)?'); + // Match beginning and end explicitly + const patternRegEx = new RegExp(`^${patternRegExString}$`); + const fullTitle = + typeof test.fullTitle === 'string' ? test.fullTitle : test.fullTitle(); + + return patternRegEx.test(getTestId(test.file ?? '', fullTitle)); +} diff --git a/remote/test/puppeteer/tools/mocha-runner/tsconfig.json b/remote/test/puppeteer/tools/mocha-runner/tsconfig.json new file mode 100644 index 0000000000..73a1b17815 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./bin", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "sourceMap": true, + "declaration": false, + "declarationMap": false, + "composite": false, + }, +} diff --git a/remote/test/puppeteer/tools/mocha-runner/tsdoc.json b/remote/test/puppeteer/tools/mocha-runner/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/mocha-runner/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/sort-test-expectations.mjs b/remote/test/puppeteer/tools/sort-test-expectations.mjs new file mode 100644 index 0000000000..d1c8588d8a --- /dev/null +++ b/remote/test/puppeteer/tools/sort-test-expectations.mjs @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// TODO: this could be an eslint rule probably. +import fs from 'fs'; +import path from 'path'; +import url from 'url'; + +import prettier from 'prettier'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); +const source = 'test/TestExpectations.json'; +const testExpectations = JSON.parse(fs.readFileSync(source, 'utf-8')); +const committedExpectations = structuredClone(testExpectations); + +const prettierConfig = await import( + path.join(__dirname, '..', '.prettierrc.cjs') +); + +function getSpecificity(item) { + return ( + item.parameters.length + + (item.testIdPattern.includes('*') + ? item.testIdPattern === '*' + ? 0 + : 1 + : 2) + ); +} + +testExpectations.sort((a, b) => { + const result = getSpecificity(a) - getSpecificity(b); + if (result === 0) { + return a.testIdPattern.localeCompare(b.testIdPattern); + } + return result; +}); + +testExpectations.forEach(item => { + item.parameters.sort(); + item.expectations.sort(); + item.platforms.sort(); +}); + +if (process.argv.includes('--lint')) { + if ( + JSON.stringify(committedExpectations) !== JSON.stringify(testExpectations) + ) { + console.error( + `${source} is not formatted properly. Run 'npm run format:expectations'.` + ); + process.exit(1); + } +} else { + fs.writeFileSync( + source, + await prettier.format(JSON.stringify(testExpectations), { + ...prettierConfig, + parser: 'json', + }) + ); +} diff --git a/remote/test/puppeteer/tools/third_party/validate-licenses.ts b/remote/test/puppeteer/tools/third_party/validate-licenses.ts new file mode 100644 index 0000000000..56964854bd --- /dev/null +++ b/remote/test/puppeteer/tools/third_party/validate-licenses.ts @@ -0,0 +1,154 @@ +// The MIT License + +// Copyright (c) 2010-2022 Google LLC. http://angular.io/license + +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Taken and adapted from https://github.com/angular/angular-cli/blob/173823d/scripts/validate-licenses.ts. + +import * as path from 'path'; + +import checker from 'license-checker'; +import spdxSatisfies from 'spdx-satisfies'; + +/** + * A general note on some black listed specific licenses: + * + * - CC0 This is not a valid license. It does not grant copyright of the + * code/asset, and does not resolve patents or other licensed work. The + * different claims also have no standing in court and do not provide + * protection to or from Google and/or third parties. We cannot use nor + * contribute to CC0 licenses. + * - Public Domain Same as CC0, it is not a valid license. + */ +const allowedLicenses = [ + // Regular valid open source licenses supported by Google. + 'MIT', + 'ISC', + 'Apache-2.0', + 'Python-2.0', + 'Artistic-2.0', + 'BlueOak-1.0.0', + + 'BSD-2-Clause', + 'BSD-3-Clause', + 'BSD-4-Clause', + + // All CC-BY licenses have a full copyright grant and attribution section. + 'CC-BY-3.0', + 'CC-BY-4.0', + + // Have a full copyright grant. Validated by opensource team. + 'Unlicense', + 'CC0-1.0', + '0BSD', + + // Combinations. + '(AFL-2.1 OR BSD-2-Clause)', +]; + +// Name variations of SPDX licenses that some packages have. +// Licenses not included in SPDX but accepted will be converted to MIT. +const licenseReplacements: {[key: string]: string} = { + // Just a longer string that our script catches. SPDX official name is the shorter one. + 'Apache License, Version 2.0': 'Apache-2.0', + Apache2: 'Apache-2.0', + 'Apache 2.0': 'Apache-2.0', + 'Apache v2': 'Apache-2.0', + 'AFLv2.1': 'AFL-2.1', + // BSD is BSD-2-clause by default. + BSD: 'BSD-2-Clause', +}; + +// Specific packages to ignore, add a reason in a comment. Format: package-name@version. +const ignoredPackages = [ + // * Development only + 'spdx-license-ids@3.0.5', // CC0 but it's content only (index.json, no code) and not distributed. +]; + +// Check if a license is accepted by an array of accepted licenses +function _passesSpdx(licenses: string[], accepted: string[]) { + try { + return spdxSatisfies(licenses.join(' AND '), accepted.join(' OR ')); + } catch { + return false; + } +} + +function main(): Promise<number> { + return new Promise(resolve => { + const startFolder = path.join(__dirname, '..', '..'); + checker.init( + {start: startFolder, excludePrivatePackages: true}, + (err: Error, json: object) => { + if (err) { + console.error(`Something happened:\n${err.message}`); + resolve(1); + } else { + console.info(`Testing ${Object.keys(json).length} packages.\n`); + + // Packages with bad licenses are those that neither pass SPDX nor are ignored. + const badLicensePackages = Object.keys(json) + .map(key => { + return { + id: key, + licenses: ([] as string[]) + .concat((json[key] as {licenses: string[]}).licenses) + // `*` is used when the license is guessed. + .map(x => { + return x.replace(/\*$/, ''); + }) + .map(x => { + return x in licenseReplacements + ? licenseReplacements[x] + : x; + }), + }; + }) + .filter(pkg => { + return !_passesSpdx(pkg.licenses, allowedLicenses); + }) + .filter(pkg => { + return !ignoredPackages.find(ignored => { + return ignored === pkg.id; + }); + }); + + // Report packages with bad licenses + if (badLicensePackages.length > 0) { + console.error('Invalid package licences found:'); + badLicensePackages.forEach(pkg => { + console.error(`${pkg.id}: ${JSON.stringify(pkg.licenses)}`); + }); + console.error( + `\n${badLicensePackages.length} total packages with invalid licenses.` + ); + resolve(2); + } else { + console.info('All package licenses are valid.'); + resolve(0); + } + } + } + ); + }); +} + +main().then(code => { + return process.exit(code); +}); diff --git a/remote/test/puppeteer/tools/tsconfig.json b/remote/test/puppeteer/tools/tsconfig.json new file mode 100644 index 0000000000..964d349435 --- /dev/null +++ b/remote/test/puppeteer/tools/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "files": ["../package.json"], +} diff --git a/remote/test/puppeteer/tools/tsdoc.json b/remote/test/puppeteer/tools/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/tools/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/tools/update_chrome_revision.mjs b/remote/test/puppeteer/tools/update_chrome_revision.mjs new file mode 100644 index 0000000000..64eeef74d5 --- /dev/null +++ b/remote/test/puppeteer/tools/update_chrome_revision.mjs @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {execSync, exec} from 'child_process'; +import {writeFile, readFile} from 'fs/promises'; +import {promisify} from 'util'; + +import actions from '@actions/core'; +import {SemVer} from 'semver'; + +import packageJson from '../packages/puppeteer-core/package.json' assert {type: 'json'}; +import {versionsPerRelease, lastMaintainedChromeVersion} from '../versions.js'; + +import {PUPPETEER_REVISIONS} from 'puppeteer-core/internal/revisions.js'; + +const execAsync = promisify(exec); + +const CHROME_CURRENT_VERSION = PUPPETEER_REVISIONS.chrome; +const VERSIONS_PER_RELEASE_COMMENT = + '// In Chrome roll patches, use `NEXT` for the Puppeteer version.'; + +const touchedFiles = []; + +function checkIfNeedsUpdate(oldVersion, newVersion, newRevision) { + const oldSemVer = new SemVer(oldVersion, true); + const newSemVer = new SemVer(newVersion, true); + let message = `roll to Chrome ${newVersion} (r${newRevision})`; + + if (newSemVer.compare(oldSemVer) <= 0) { + // Exit the process without setting up version + console.warn( + `Version ${newVersion} is older or the same as the current ${oldVersion}` + ); + process.exit(0); + } else if (newSemVer.compareMain(oldSemVer) === 0) { + message = `fix: ${message}`; + } else { + message = `feat: ${message}`; + } + actions.setOutput('commit', message); +} + +/** + * We cant use `npm run format` as it's too slow + * so we only scope the files we updated + */ +async function formatUpdateFiles() { + await Promise.all( + touchedFiles.map(file => { + return execAsync(`npx eslint --ext js --ext ts --fix ${file}`); + }) + ); + await Promise.all( + touchedFiles.map(file => { + return execAsync(`npx prettier --write ${file}`); + }) + ); +} + +async function replaceInFile(filePath, search, replace) { + const buffer = await readFile(filePath); + const update = buffer.toString().replaceAll(search, replace); + + await writeFile(filePath, update); + + touchedFiles.push(filePath); +} + +async function getVersionAndRevisionForStable() { + const result = await fetch( + 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json' + ).then(response => { + return response.json(); + }); + + const {version, revision} = result.channels['Stable']; + + return { + version, + revision, + }; +} + +async function updateDevToolsProtocolVersion(revision) { + const currentProtocol = packageJson.dependencies['devtools-protocol']; + const command = `npm view "devtools-protocol@<=0.0.${revision}" version | tail -1`; + + const bestNewProtocol = execSync(command, { + encoding: 'utf8', + }) + .split(' ')[1] + .replace(/'|\n/g, ''); + + await replaceInFile( + './packages/puppeteer-core/package.json', + `"devtools-protocol": "${currentProtocol}"`, + `"devtools-protocol": "${bestNewProtocol}"` + ); +} + +async function updateVersionFileLastMaintained(oldVersion, newVersion) { + const versions = [...versionsPerRelease.keys()]; + if (versions.indexOf(newVersion) !== -1) { + return; + } + + // If we have manually rolled Chrome but not yet released + // We will have NEXT as value in the Map + if (versionsPerRelease.get(oldVersion) === 'NEXT') { + await replaceInFile('./versions.js', oldVersion, newVersion); + return; + } + + await replaceInFile( + './versions.js', + VERSIONS_PER_RELEASE_COMMENT, + `${VERSIONS_PER_RELEASE_COMMENT}\n ['${version}', 'NEXT'],` + ); + + const oldSemVer = new SemVer(oldVersion, true); + const newSemVer = new SemVer(newVersion, true); + + if (newSemVer.compareMain(oldSemVer) !== 0) { + const lastMaintainedSemVer = new SemVer(lastMaintainedChromeVersion, true); + const newLastMaintainedMajor = lastMaintainedSemVer.major + 1; + + const nextMaintainedVersion = versions.find(version => { + return new SemVer(version, true).major === newLastMaintainedMajor; + }); + + await replaceInFile( + './versions.js', + `const lastMaintainedChromeVersion = '${lastMaintainedChromeVersion}';`, + `const lastMaintainedChromeVersion = '${nextMaintainedVersion}';` + ); + } +} + +const {version, revision} = await getVersionAndRevisionForStable(); + +checkIfNeedsUpdate(CHROME_CURRENT_VERSION, version, revision); + +await replaceInFile( + './packages/puppeteer-core/src/revisions.ts', + CHROME_CURRENT_VERSION, + version +); + +await updateVersionFileLastMaintained(CHROME_CURRENT_VERSION, version); +await updateDevToolsProtocolVersion(revision); + +// Create new `package-lock.json` as we update devtools-protocol +execSync('npm install --ignore-scripts'); +// Make sure we pass CI formatter check by running all the new files though it +await formatUpdateFiles(); + +// Keep this as they can be used to debug GitHub Actions if needed +actions.setOutput('version', version); +actions.setOutput('revision', revision); |