playwright/utils/doclint/cli.js

301 lines
12 KiB
JavaScript
Raw Normal View History

2019-11-19 05:18:28 +03:00
#!/usr/bin/env node
/**
* Copyright 2017 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
//@ts-check
2021-10-11 17:52:17 +03:00
const playwright = require('playwright-core');
2020-12-03 00:50:10 +03:00
const fs = require('fs');
2019-11-19 05:18:28 +03:00
const path = require('path');
const { parseApi } = require('./api_parser');
2021-01-02 02:17:27 +03:00
const missingDocs = require('./missingDocs');
2021-01-09 03:36:52 +03:00
const md = require('../markdown');
const docs = require('./documentation');
const toKebabCase = require('lodash/kebabCase')
/** @typedef {import('./documentation').Type} Type */
2020-12-29 04:38:00 +03:00
/** @typedef {import('../markdown').MarkdownNode} MarkdownNode */
2019-11-19 05:18:28 +03:00
const PROJECT_DIR = path.join(__dirname, '..', '..');
2021-01-02 02:17:27 +03:00
const dirtyFiles = new Set();
run().catch(e => {
console.error(e);
process.exit(1);
});;
2019-11-19 05:18:28 +03:00
function getAllMarkdownFiles(dirPath, filePaths = []) {
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
filePaths.push(path.join(dirPath, entry.name));
else if (entry.isDirectory())
getAllMarkdownFiles(path.join(dirPath, entry.name), filePaths);
}
return filePaths;
}
2019-11-19 05:18:28 +03:00
async function run() {
2021-01-02 02:17:27 +03:00
// Patch README.md
const versions = await getBrowserVersions();
2019-11-19 05:18:28 +03:00
{
2021-01-02 02:17:27 +03:00
const params = new Map();
const { chromium, firefox, webkit } = versions;
params.set('chromium-version', chromium);
params.set('firefox-version', firefox);
params.set('webkit-version', webkit);
params.set('chromium-version-badge', `[![Chromium version](https://img.shields.io/badge/chromium-${chromium}-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)`);
2022-12-20 01:47:37 +03:00
params.set('firefox-version-badge', `[![Firefox version](https://img.shields.io/badge/firefox-${firefox}-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)`);
2021-01-02 02:17:27 +03:00
params.set('webkit-version-badge', `[![WebKit version](https://img.shields.io/badge/webkit-${webkit}-blue.svg?logo=safari)](https://webkit.org/)`);
2021-01-02 02:17:27 +03:00
let content = fs.readFileSync(path.join(PROJECT_DIR, 'README.md')).toString();
content = content.replace(/<!-- GEN:([^ ]+) -->([^<]*)<!-- GEN:stop -->/ig, (match, p1) => {
if (!params.has(p1)) {
console.log(`ERROR: Invalid generate parameter "${p1}" in "${match}"`);
process.exit(1);
}
return `<!-- GEN:${p1} -->${params.get(p1)}<!-- GEN:stop -->`;
});
writeAssumeNoop(path.join(PROJECT_DIR, 'README.md'), content, dirtyFiles);
}
2019-11-19 05:18:28 +03:00
let playwrightVersion = require(path.join(PROJECT_DIR, 'package.json')).version;
if (playwrightVersion.endsWith('-next'))
playwrightVersion = playwrightVersion.substring(0, playwrightVersion.indexOf('-next'));
// Ensure browser versions in browsers.json. This is most important for WebKit
// since its version is hardcoded in Playwright library rather then in browser builds.
// @see https://github.com/microsoft/playwright/issues/15702
{
const browsersJSONPath = path.join(__dirname, '..', '..', 'packages/playwright-core/browsers.json');
const browsersJSON = JSON.parse(await fs.promises.readFile(browsersJSONPath, 'utf8'));
for (const browser of browsersJSON.browsers) {
if (versions[browser.name])
browser.browserVersion = versions[browser.name];
}
writeAssumeNoop(browsersJSONPath, JSON.stringify(browsersJSON, null, 2) + '\n', dirtyFiles);
}
// Update device descriptors
{
2021-10-11 17:52:17 +03:00
const devicesDescriptorsSourceFile = path.join(PROJECT_DIR, 'packages', 'playwright-core', 'src', 'server', 'deviceDescriptorsSource.json')
const devicesDescriptors = require(devicesDescriptorsSourceFile)
for (const deviceName of Object.keys(devicesDescriptors)) {
switch (devicesDescriptors[deviceName].defaultBrowserType) {
case 'chromium':
devicesDescriptors[deviceName].userAgent = devicesDescriptors[deviceName].userAgent.replace(
/(.*Chrome\/)(.*?)( .*)/,
`$1${versions.chromium}$3`
).replace(
/(.*Edg\/)(.*?)$/,
`$1${versions.chromium}`
)
break;
case 'firefox':
devicesDescriptors[deviceName].userAgent = devicesDescriptors[deviceName].userAgent.replace(
/^(.*Firefox\/)(.*?)( .*?)?$/,
`$1${versions.firefox}$3`
).replace(/^(.*rv:)(.*)(\).*?)$/, `$1${versions.firefox}$3`)
break;
case 'webkit':
devicesDescriptors[deviceName].userAgent = devicesDescriptors[deviceName].userAgent.replace(
/(.*Version\/)(.*?)( .*)/,
`$1${versions.webkit}$3`
)
break;
default:
break;
}
}
const invalidConfigurations = Object.entries(devicesDescriptors).filter(([_, deviceDescriptor]) => deviceDescriptor.isMobile && deviceDescriptor.defaultBrowserType === 'firefox').map(([deviceName, deviceDescriptor]) => deviceName);
if (invalidConfigurations.length > 0)
throw new Error(`Invalid Device Configurations. isMobile with Firefox not supported: ${invalidConfigurations.join(', ')}`);
writeAssumeNoop(devicesDescriptorsSourceFile, JSON.stringify(devicesDescriptors, null, 2), dirtyFiles);
}
// Validate links/code snippet langs
2021-01-09 03:36:52 +03:00
{
const langs = ['js', 'java', 'python', 'csharp'];
const documentationRoot = path.join(PROJECT_DIR, 'docs', 'src');
for (const lang of langs) {
try {
let documentation = parseApi(path.join(documentationRoot, 'api'));
documentation.filterForLanguage(lang);
if (lang === 'js') {
const testDocumentation = parseApi(path.join(documentationRoot, 'test-api'), path.join(documentationRoot, 'api', 'params.md'));
testDocumentation.filterForLanguage('js');
const testReporterDocumentation = parseApi(path.join(documentationRoot, 'test-reporter-api'));
testReporterDocumentation.filterForLanguage('js');
documentation = documentation.mergeWith(testDocumentation).mergeWith(testReporterDocumentation);
}
// This validates member links.
documentation.setLinkRenderer(() => undefined);
// This validates code snippet groups in comments.
documentation.setCodeGroupsTransformer(lang, tabs => tabs.map(tab => tab.spec));
documentation.generateSourceCodeComments();
const mdLinks = [];
const mdSections = new Set();
for (const cls of documentation.classesArray) {
const filePath = path.join(documentationRoot, 'api', 'class-' + cls.name.toLowerCase() + '.md');
for (const member of cls.membersArray)
mdSections.add(filePath + '#' + toKebabCase(cls.name).toLowerCase() + '-' + toKebabCase(member.name).toLowerCase());
for (const event of cls.eventsArray)
mdSections.add(filePath + '#' + toKebabCase(cls.name).toLowerCase() + '-event-' + toKebabCase(event.name).toLowerCase());
}
for (const filePath of getAllMarkdownFiles(documentationRoot)) {
if (!filePath.includes(`-${lang}`) && langs.some(other => other !== lang && filePath.includes(`-${other}`)))
continue;
// Standardise naming and remove the filter in the file name
// Also, Internally (playwright.dev generator) we merge test-api and test-reporter-api into api.
const canonicalName = filePath.replace(/(-(js|python|csharp|java))+/, '').replace(/(\/|\\)(test-api|test-reporter-api)(\/|\\)/, `${path.sep}api${path.sep}`);
mdSections.add(canonicalName);
const data = fs.readFileSync(filePath, 'utf-8');
let rootNode = md.filterNodesForLanguage(md.parse(data), lang);
// Validates code snippet groups.
rootNode = docs.processCodeGroups(rootNode, lang, tabs => tabs.map(tab => tab.spec));
// Renders links.
documentation.renderLinksInNodes(rootNode);
// Validate links.
{
md.visitAll(rootNode, node => {
if (node.type === 'code') {
const allowedCodeLangs = new Set([
'csharp',
'java',
'js',
'ts',
'python',
'py',
'java',
'powershell',
'batch',
'ini',
'txt',
'html',
'xml',
'yml',
'yaml',
'json',
'groovy',
'html',
'bash',
'sh',
'Dockerfile',
]);
if (!allowedCodeLangs.has(node.codeLang.split(' ')[0]))
throw new Error(`${path.relative(PROJECT_DIR, filePath)} contains code block with invalid code block language ${node.codeLang}`);
}
if (node.type.startsWith('h')) {
const hash = mdSectionHash(node.text || '');
mdSections.add(canonicalName + '#' + hash);
}
if (!node.text)
return;
for (const [, mdLinkName, mdLink] of node.text.matchAll(/\[([\w\s\d]+)\]\((.*?)\)/g)) {
const isExternal = mdLink.startsWith('http://') || mdLink.startsWith('https://');
if (isExternal)
continue;
const [beforeHash, hash] = mdLink.split('#');
let linkWithoutHash = canonicalName;
if (beforeHash) {
// Not same-file link.
linkWithoutHash = path.join(path.dirname(filePath), beforeHash);
if (path.extname(linkWithoutHash) !== '.md')
linkWithoutHash += '.md';
}
mdLinks.push({ filePath, linkTarget: linkWithoutHash + (hash ? '#' + hash : ''), name: mdLinkName });
}
});
}
}
const badLinks = [];
for (const { filePath, linkTarget, name } of mdLinks) {
if (!mdSections.has(linkTarget))
badLinks.push(`${path.relative(PROJECT_DIR, filePath)} references to '${linkTarget}' as '${name}' which does not exist.`);
}
if (badLinks.length)
throw new Error('Broken links found:\n' + badLinks.join('\n'));
} catch (e) {
e.message = `While processing "${lang}"\n` + e.message;
throw e;
}
2021-01-09 03:36:52 +03:00
}
}
2021-01-02 02:17:27 +03:00
// Check for missing docs
{
const apiDocumentation = parseApi(path.join(PROJECT_DIR, 'docs', 'src', 'api'));
apiDocumentation.filterForLanguage('js');
2021-10-11 17:52:17 +03:00
const srcClient = path.join(PROJECT_DIR, 'packages', 'playwright-core', 'src', 'client');
2021-01-02 02:17:27 +03:00
const sources = fs.readdirSync(srcClient).map(n => path.join(srcClient, n));
const errors = missingDocs(apiDocumentation, sources, path.join(srcClient, 'api.ts'));
2021-01-02 02:17:27 +03:00
if (errors.length) {
console.log('============================');
console.log('ERROR: missing documentation:');
errors.forEach(e => console.log(e));
console.log('============================')
process.exit(1);
2019-11-19 05:18:28 +03:00
}
}
2021-01-02 02:17:27 +03:00
if (dirtyFiles.size) {
console.log('============================')
console.log('ERROR: generated files have changed, this is only error if happens in CI:');
2021-01-02 02:17:27 +03:00
[...dirtyFiles].forEach(f => console.log(f));
console.log('============================')
process.exit(1);
2019-11-19 05:18:28 +03:00
}
2021-01-02 02:17:27 +03:00
process.exit(0);
}
/**
* @param {string} name
* @param {string} content
* @param {Set<string>} dirtyFiles
*/
function writeAssumeNoop(name, content, dirtyFiles) {
fs.mkdirSync(path.dirname(name), { recursive: true });
const oldContent = fs.existsSync(name) ? fs.readFileSync(name).toString() : '';
2021-01-02 02:17:27 +03:00
if (oldContent !== content) {
fs.writeFileSync(name, content);
dirtyFiles.add(name);
2019-11-19 05:18:28 +03:00
}
}
async function getBrowserVersions() {
const names = ['chromium', 'firefox', 'webkit'];
const browsers = await Promise.all(names.map(name => playwright[name].launch()));
const result = {};
for (let i = 0; i < names.length; i++) {
result[names[i]] = browsers[i].version();
}
await Promise.all(browsers.map(browser => browser.close()));
return result;
}
function mdSectionHash(text) {
return text.toLowerCase().replace(/\s/g, '-').replace(/[^-_a-z0-9]/g, '').replace(/^-+/, '');
}