summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/tools
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /remote/test/puppeteer/tools
parentInitial commit. (diff)
downloadfirefox-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')
-rwxr-xr-xremote/test/puppeteer/tools/analyze_issue.mjs281
-rwxr-xr-xremote/test/puppeteer/tools/assets/verify_issue.ts68
-rw-r--r--remote/test/puppeteer/tools/chmod.ts16
-rwxr-xr-xremote/test/puppeteer/tools/clean.js12
-rw-r--r--remote/test/puppeteer/tools/cp.ts12
-rw-r--r--remote/test/puppeteer/tools/docgen/package.json33
-rw-r--r--remote/test/puppeteer/tools/docgen/src/custom_markdown_documenter.ts1495
-rw-r--r--remote/test/puppeteer/tools/docgen/src/docgen.ts38
-rw-r--r--remote/test/puppeteer/tools/docgen/tsconfig.json11
-rw-r--r--remote/test/puppeteer/tools/docgen/tsdoc.json15
-rw-r--r--remote/test/puppeteer/tools/doctest/package.json39
-rw-r--r--remote/test/puppeteer/tools/doctest/src/doctest.ts349
-rw-r--r--remote/test/puppeteer/tools/doctest/tsconfig.json11
-rw-r--r--remote/test/puppeteer/tools/doctest/tsdoc.json15
-rw-r--r--remote/test/puppeteer/tools/download_chrome_bidi.mjs56
-rw-r--r--remote/test/puppeteer/tools/ensure-pinned-deps.ts52
-rw-r--r--remote/test/puppeteer/tools/eslint/package.json37
-rw-r--r--remote/test/puppeteer/tools/eslint/src/check-license.ts83
-rw-r--r--remote/test/puppeteer/tools/eslint/src/extensions.ts48
-rw-r--r--remote/test/puppeteer/tools/eslint/src/prettier-comments.js99
-rw-r--r--remote/test/puppeteer/tools/eslint/src/use-using.ts85
-rw-r--r--remote/test/puppeteer/tools/eslint/tsconfig.json14
-rw-r--r--remote/test/puppeteer/tools/eslint/tsdoc.json15
-rw-r--r--remote/test/puppeteer/tools/generate_module_package_json.ts15
-rw-r--r--remote/test/puppeteer/tools/get_deprecated_version_range.js18
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/README.md103
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/package.json43
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/src/interface.ts191
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/src/mocha-runner.ts330
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/src/reporter.ts16
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/src/test.ts212
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/src/types.ts57
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/src/utils.ts291
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/tsconfig.json13
-rw-r--r--remote/test/puppeteer/tools/mocha-runner/tsdoc.json15
-rw-r--r--remote/test/puppeteer/tools/sort-test-expectations.mjs65
-rw-r--r--remote/test/puppeteer/tools/third_party/validate-licenses.ts154
-rw-r--r--remote/test/puppeteer/tools/tsconfig.json4
-rw-r--r--remote/test/puppeteer/tools/tsdoc.json15
-rw-r--r--remote/test/puppeteer/tools/update_chrome_revision.mjs162
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, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/\{/g, '&#123;')
+ .replace(/\}/g, '&#125;');
+ return textWithBackslashes;
+ }
+
+ protected override getTableEscapedText(text: string): string {
+ return text
+ .replace(/&/g, '&amp;')
+ .replace(/"/g, '&quot;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/\|/g, '&#124;');
+ }
+}
+
+/**
+ * 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);