Source code
Revision control
Copy as Markdown
Other Tools
#! /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;
}