tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

doctest.ts (9856B)


      1 #! /usr/bin/env -S node --test-reporter spec
      2 
      3 /**
      4 * @license
      5 * Copyright 2023 Google Inc.
      6 * SPDX-License-Identifier: Apache-2.0
      7 */
      8 
      9 /**
     10 * `@puppeteer/doctest` tests `@example` code within a JavaScript file.
     11 *
     12 * There are a few reasonable assumptions for this tool to work:
     13 *
     14 * 1. Examples are written in block comments, not line comments.
     15 * 2. Examples do not use packages that are not available to the file it exists
     16 *    in. (Note the package will always be available).
     17 * 3. Examples are strictly written between code fences (\`\`\`) on separate
     18 *    lines. For example, \`\`\`console.log(1)\`\`\` is not allowed.
     19 * 4. Code is written using ES modules.
     20 *
     21 * By default, code blocks are interpreted as JavaScript. Use \`\`\`ts to change
     22 * the language. In general, the format is "\`\`\`[language] [ignore] [fail]".
     23 *
     24 * If there are several code blocks within an example, they are concatenated.
     25 */
     26 import 'source-map-support/register.js';
     27 
     28 import assert from 'node:assert';
     29 import {createHash} from 'node:crypto';
     30 import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises';
     31 import {basename, dirname, join, relative, resolve} from 'node:path';
     32 import {test} from 'node:test';
     33 import {pathToFileURL} from 'node:url';
     34 
     35 import {transform, type Output} from '@swc/core';
     36 import {parse as parseJs} from 'acorn';
     37 import {parse, type Tag} from 'doctrine';
     38 import {Glob} from 'glob';
     39 import {packageDirectory} from 'pkg-dir';
     40 import {
     41  SourceMapConsumer,
     42  SourceMapGenerator,
     43  type RawSourceMap,
     44 } from 'source-map';
     45 import yargs from 'yargs';
     46 import {hideBin} from 'yargs/helpers';
     47 
     48 // This is 1-indexed.
     49 interface Position {
     50  line: number;
     51  column: number;
     52 }
     53 
     54 interface Comment {
     55  file: string;
     56  text: string;
     57  position: Position;
     58 }
     59 
     60 interface ExtractedSourceLocation {
     61  // File path to the original source code.
     62  origin: string;
     63  // Mappings from the extracted code to the original code.
     64  positions: Array<{
     65    // The 1-indexed line number for the extracted code.
     66    extracted: number;
     67    // The position in the original code.
     68    original: Position;
     69  }>;
     70 }
     71 
     72 interface ExampleCode extends ExtractedSourceLocation {
     73  language: Language;
     74  code: string;
     75  fail: boolean;
     76 }
     77 
     78 const enum Language {
     79  JavaScript,
     80  TypeScript,
     81 }
     82 
     83 const CODE_FENCE = '```';
     84 const BLOCK_COMMENT_START = ' * ';
     85 
     86 const {files = []} = await yargs(hideBin(process.argv))
     87  .scriptName('@puppeteer/doctest')
     88  .command('* <files..>', `JSDoc @example code tester.`)
     89  .positional('files', {
     90    describe: 'Files to test',
     91    type: 'string',
     92  })
     93  .array('files')
     94  .version(false)
     95  .help()
     96  .parse();
     97 
     98 for await (const file of new Glob(files, {})) {
     99  void test(file, async context => {
    100    const testDirectory = await createTestDirectory(file);
    101    context.after(async () => {
    102      if (!process.env['KEEP_TESTS']) {
    103        await rm(testDirectory, {force: true, recursive: true});
    104      }
    105    });
    106    const tests = [];
    107    for (const example of await extractJSDocComments(file).then(
    108      extractExampleCode,
    109    )) {
    110      tests.push(
    111        context.test(
    112          `${file}:${example.positions[0]!.original.line}:${
    113            example.positions[0]!.original.column
    114          }`,
    115          async () => {
    116            await run(testDirectory, example);
    117          },
    118        ),
    119      );
    120    }
    121    await Promise.all(tests);
    122  });
    123 }
    124 
    125 async function createTestDirectory(file: string) {
    126  const dir = await packageDirectory({cwd: dirname(file)});
    127  if (!dir) {
    128    throw new Error(`Could not find package root for ${file}.`);
    129  }
    130 
    131  return await mkdtemp(join(dir, 'doctest-'));
    132 }
    133 
    134 async function run(tempdir: string, example: Readonly<ExampleCode>) {
    135  const path = getTestPath(tempdir, example.code);
    136  await compile(example.language, example.code, path, example);
    137  try {
    138    await import(pathToFileURL(path).toString());
    139    if (example.fail) {
    140      throw new Error(`Expected failure.`);
    141    }
    142  } catch (error) {
    143    if (!example.fail) {
    144      throw error;
    145    }
    146  }
    147 }
    148 
    149 function getTestPath(dir: string, code: string) {
    150  return join(
    151    dir,
    152    `doctest-${createHash('md5').update(code).digest('hex')}.js`,
    153  );
    154 }
    155 
    156 async function compile(
    157  language: Language,
    158  sourceCode: string,
    159  filePath: string,
    160  location: ExtractedSourceLocation,
    161 ) {
    162  const output = await compileCode(language, sourceCode);
    163  const map = await getExtractSourceMap(output.map, filePath, location);
    164  await writeFile(filePath, inlineSourceMap(output.code, map));
    165 }
    166 
    167 function inlineSourceMap(code: string, sourceMap: RawSourceMap) {
    168  return `${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(
    169    JSON.stringify(sourceMap),
    170  ).toString('base64')}`;
    171 }
    172 
    173 async function getExtractSourceMap(
    174  map: string,
    175  generatedFile: string,
    176  location: ExtractedSourceLocation,
    177 ) {
    178  const sourceMap = JSON.parse(map) as RawSourceMap;
    179  sourceMap.file = basename(generatedFile);
    180  sourceMap.sourceRoot = '';
    181  sourceMap.sources = [
    182    relative(dirname(generatedFile), resolve(location.origin)),
    183  ];
    184  const consumer = await new SourceMapConsumer(sourceMap);
    185  const generator = new SourceMapGenerator({
    186    file: consumer.file,
    187    sourceRoot: consumer.sourceRoot,
    188  });
    189  // We want descending order of the `generated` property.
    190  const positions = [...location.positions].reverse();
    191  consumer.eachMapping(mapping => {
    192    // Note `mapping.originalLine` is the line number with respect to the
    193    // extracted, raw code.
    194    const {extracted, original} = positions.find(({extracted}) => {
    195      return mapping.originalLine >= extracted;
    196    })!;
    197 
    198    // `original.line` will account for `extracted`, so we need to subtract
    199    // `extracted` to avoid duplicity. We also subtract 1 because `extracted` is
    200    // 1-indexed.
    201    mapping.originalLine -= extracted - 1;
    202 
    203    generator.addMapping({
    204      ...mapping,
    205      original: {
    206        line: mapping.originalLine + original.line - 1,
    207        column: mapping.originalColumn + original.column - 1,
    208      },
    209      generated: {
    210        line: mapping.generatedLine,
    211        column: mapping.generatedColumn,
    212      },
    213    });
    214  });
    215  return generator.toJSON();
    216 }
    217 
    218 const LANGUAGE_TO_SYNTAX = {
    219  [Language.TypeScript]: 'typescript',
    220  [Language.JavaScript]: 'ecmascript',
    221 } as const;
    222 
    223 async function compileCode(language: Language, code: string) {
    224  return (await transform(code, {
    225    sourceMaps: true,
    226    inlineSourcesContent: false,
    227    jsc: {
    228      parser: {
    229        syntax: LANGUAGE_TO_SYNTAX[language],
    230      },
    231      target: 'es2022',
    232    },
    233  })) as Required<Output>;
    234 }
    235 
    236 const enum Option {
    237  Ignore = 'ignore',
    238  Fail = 'fail',
    239 }
    240 
    241 function* extractExampleCode(
    242  comments: Iterable<Readonly<Comment>>,
    243 ): Iterable<Readonly<ExampleCode>> {
    244  interface Context {
    245    language: Language;
    246    fail: boolean;
    247    start: number;
    248  }
    249  for (const {file, text, position: loc} of comments) {
    250    const {tags} = parse(text, {
    251      unwrap: true,
    252      tags: ['example'],
    253      lineNumbers: true,
    254      preserveWhitespace: true,
    255    });
    256    for (const {description, lineNumber} of tags as Array<
    257      Tag & {lineNumber: number}
    258    >) {
    259      if (!description) {
    260        continue;
    261      }
    262      const lines = description.split('\n');
    263      const blocks: ExampleCode[] = [];
    264      let context: Context | undefined;
    265      for (let i = 0; i < lines.length; i++) {
    266        const line = lines[i]!;
    267        const borderIndex = line.indexOf(CODE_FENCE);
    268        if (borderIndex === -1) {
    269          continue;
    270        }
    271        if (context) {
    272          blocks.push({
    273            language: context.language,
    274            code: lines.slice(context.start, i).join('\n'),
    275            origin: file,
    276            positions: [
    277              {
    278                extracted: 1,
    279                original: {
    280                  line: loc.line + lineNumber + context.start,
    281                  column:
    282                    loc.column + borderIndex + BLOCK_COMMENT_START.length + 1,
    283                },
    284              },
    285            ],
    286            fail: context.fail,
    287          });
    288          context = undefined;
    289          continue;
    290        }
    291        const [tag, ...options] = line
    292          .slice(borderIndex + CODE_FENCE.length)
    293          .split(' ');
    294        if (options.includes(Option.Ignore)) {
    295          // Ignore the code sample.
    296          continue;
    297        }
    298        const fail = options.includes(Option.Fail);
    299        // Code starts on the next line.
    300        const start = i + 1;
    301        if (!tag || tag.match(/js|javascript/)) {
    302          context = {language: Language.JavaScript, fail, start};
    303        } else if (tag.match(/ts|typescript/)) {
    304          context = {language: Language.TypeScript, fail, start};
    305        }
    306      }
    307      // Merging the blocks into a single block.
    308      yield blocks.reduce(
    309        (context, {language, code, positions: [position], fail}, index) => {
    310          assert(position);
    311          return {
    312            origin: file,
    313            language: language || context.language,
    314            code: `${context.code}\n${code}`,
    315            positions: [
    316              ...context.positions,
    317              {
    318                ...position,
    319                extracted:
    320                  context.code.split('\n').length +
    321                  context.positions.at(-1)!.extracted -
    322                  // We subtract this because of the accumulated '\n'.
    323                  (index - 1),
    324              },
    325            ],
    326            fail: fail || context.fail,
    327          };
    328        },
    329      );
    330    }
    331  }
    332 }
    333 
    334 async function extractJSDocComments(file: string) {
    335  const contents = await readFile(file, 'utf8');
    336  const comments: Comment[] = [];
    337  parseJs(contents, {
    338    ecmaVersion: 'latest',
    339    sourceType: 'module',
    340    locations: true,
    341    sourceFile: file,
    342    onComment(isBlock, text, _, __, loc) {
    343      if (isBlock) {
    344        comments.push({file, text, position: loc!});
    345      }
    346    },
    347  });
    348  return comments;
    349 }