diff --git a/src/CodeBlockExtractor.ts b/src/CodeBlockExtractor.ts index 1830a00..3568602 100644 --- a/src/CodeBlockExtractor.ts +++ b/src/CodeBlockExtractor.ts @@ -1,45 +1,60 @@ -import * as fsExtra from "fs-extra"; +import * as fs from "fs"; // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class CodeBlockExtractor { + /** + * Matches TS/TSX fences not preceded by an ignore directive. + * Captures: + * 1: 'tsx' if TSX, undefined if 'typescript' or 'ts' + * 2: the code inside the fence + */ static readonly TYPESCRIPT_CODE_PATTERN = - /(?[\r?\n]*))(?:```(?:(?:typescript)|(tsx?))\r?\n)((?:\r?\n|.)*?)(?:(?=```))/gi; + /(?[\r?\n]*))```(?:(?:typescript)|(tsx?))\r?\n([\s\S]*?)(?=```)/gi; - /* istanbul ignore next */ - private constructor() { - // - } + private constructor() {} + /** + * Extract all code blocks into an array (backward-compatible). + */ static async extract( markdownFilePath: string ): Promise<{ code: string; type: "tsx" | "ts" }[]> { - try { - const contents = await CodeBlockExtractor.readFile(markdownFilePath); - return CodeBlockExtractor.extractCodeBlocksFromMarkdown(contents); - } catch (error) { - throw new Error( - `Error extracting code blocks from ${markdownFilePath}: ${ - error instanceof Error ? error.message : error - }` - ); + const blocks: { code: string; type: "tsx" | "ts" }[] = []; + for await (const block of this.iterateBlocks(markdownFilePath)) { + blocks.push(block); } + return blocks; } - private static async readFile(path: string): Promise { - return await fsExtra.readFile(path, "utf-8"); - } + /** + * Async generator that yields code blocks one-by-one with streaming regex parsing. + */ + static async *iterateBlocks( + markdownFilePath: string + ): AsyncGenerator<{ code: string; type: "tsx" | "ts" }> { + const pattern = this.TYPESCRIPT_CODE_PATTERN; + // eslint-disable-next-line functional/no-let + let buffer = ""; - private static extractCodeBlocksFromMarkdown( - markdown: string - ): { code: string; type: "tsx" | "ts" }[] { - const codeBlocks: { code: string; type: "tsx" | "ts" }[] = []; - markdown.replace(this.TYPESCRIPT_CODE_PATTERN, (_, type, code) => { - codeBlocks.push({ - code, - type: type === "tsx" ? "tsx" : "ts", - }); - return code; - }); - return codeBlocks; + const stream = fs.createReadStream(markdownFilePath, { encoding: "utf-8" }); + for await (const chunk of stream) { + buffer += chunk; + // reset regex state + pattern.lastIndex = 0; + // eslint-disable-next-line functional/no-let + let match: RegExpExecArray | null; + + // pull out all complete code blocks + while ((match = pattern.exec(buffer))) { + const tsxType = match[1]; + const code = match[2]; + const type = tsxType === "tsx" ? "tsx" : "ts"; + yield { code, type }; + + // drop processed segment + buffer = buffer.slice(match.index + match[0].length); + pattern.lastIndex = 0; + } + } } } diff --git a/src/SnippetCompiler.ts b/src/SnippetCompiler.ts index e41998f..c1ee9dd 100644 --- a/src/SnippetCompiler.ts +++ b/src/SnippetCompiler.ts @@ -25,7 +25,7 @@ export type SnippetCompilationResult = { export class SnippetCompiler { private readonly compilerConfig: TSNode.CreateOptions; - + private readonly compiler: TSNode.Service; constructor( private readonly workingDirectory: string, private readonly packageDefinition: PackageDefinition, @@ -39,6 +39,7 @@ export class SnippetCompiler { ...(configOptions.config as TSNode.CreateOptions), transpileOnly: false, }; + this.compiler = TSNode.create(this.compilerConfig); } private static loadTypeScriptConfig( @@ -61,16 +62,58 @@ export class SnippetCompiler { return rawString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } + private async *generateBlocks( + files: string[], + substituter: LocalImportSubstituter + ): AsyncGenerator { + for (const file of files) { + yield* this.extractFileCodeBlocks(file, substituter); + } + } + async compileSnippets( - documentationFiles: string[] + documentationFiles: string[], + { concurrency = 4 } = {} ): Promise { try { await this.cleanWorkingDirectory(); await fsExtra.ensureDir(this.workingDirectory); - const examples = await this.extractAllCodeBlocks(documentationFiles); - return await Promise.all( - examples.map(async (example) => await this.testCodeCompilation(example)) + + const results: SnippetCompilationResult[] = []; + const importSubstituter = new LocalImportSubstituter( + this.packageDefinition + ); + const blockIterator = this.generateBlocks( + documentationFiles, + importSubstituter ); + + // eslint-disable-next-line functional/no-let + let hasFailed = false; + + const worker = async () => { + while (!hasFailed) { + const { value: block, done } = await blockIterator.next(); + if (done || !block) { + return; + } + + try { + const result = await this.testCodeCompilation(block); + results.push(result); + if (result.error) { + hasFailed = true; + } + } catch (err) { + hasFailed = true; + throw err; + } + } + }; + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + return results.sort((a, b) => a.index - b.index); } finally { await this.cleanWorkingDirectory(); } @@ -80,34 +123,21 @@ export class SnippetCompiler { return await fsExtra.remove(this.workingDirectory); } - private async extractAllCodeBlocks(documentationFiles: string[]) { - const importSubstituter = new LocalImportSubstituter( - this.packageDefinition - ); - - const codeBlocks = await Promise.all( - documentationFiles.map( - async (file) => - await this.extractFileCodeBlocks(file, importSubstituter) - ) - ); - return codeBlocks.flat(); - } - - private async extractFileCodeBlocks( + private async *extractFileCodeBlocks( file: string, importSubstituter: LocalImportSubstituter - ): Promise { - const blocks = await CodeBlockExtractor.extract(file); - return blocks.map(({ code, type }, index) => { - return { + ): AsyncGenerator { + // eslint-disable-next-line functional/no-let + let index = 0; + for await (const { code, type } of CodeBlockExtractor.iterateBlocks(file)) { + yield { file, type, snippet: code, - index: index + 1, + index: ++index, sanitisedCode: this.sanitiseCodeBlock(importSubstituter, code), }; - }); + } } private sanitiseCodeBlock( @@ -116,15 +146,26 @@ export class SnippetCompiler { ): string { const localisedBlock = importSubstituter.substituteLocalPackageImports(block); - return localisedBlock; + + const moduleSyntaxRegex = /\b(import|export|declare\s+module|export\s*=)\b/; + + const isModuleCode = moduleSyntaxRegex.test(localisedBlock); + + // TODO: allow preventing of wrapping if the block is marked with + if (isModuleCode) { + // keep block as is if recognized as module code (it won't be valid if wrapped in an IIFE) + return localisedBlock; + } + + // otherwise wrap in function scope to isolate types, @see https://github.com/bbc/typescript-docs-verifier/issues/30 + return `(function wrap() {\n${localisedBlock}\n})();`; } private async compile(code: string, type: "ts" | "tsx"): Promise { const id = process.hrtime.bigint().toString(); const codeFile = path.join(this.workingDirectory, `block-${id}.${type}`); await fsExtra.writeFile(codeFile, code); - const compiler = TSNode.create(this.compilerConfig); - compiler.compile(code, codeFile); + this.compiler.compile(code, codeFile); } private removeTemporaryFilePaths( @@ -183,7 +224,13 @@ export class SnippetCompiler { [...example.sanitisedCode.substring(0, start)].filter( (char) => char === "\n" ).length + 1; - linesWithErrors.add(lineNumber); + const iifeOffset = example.sanitisedCode.startsWith( + "(function wrap() {" + ) + ? 1 + : 0; + + linesWithErrors.add(lineNumber - iifeOffset); }); } diff --git a/test/CodeBlockExtractorSpec.ts b/test/CodeBlockExtractorSpec.ts new file mode 100644 index 0000000..222a638 --- /dev/null +++ b/test/CodeBlockExtractorSpec.ts @@ -0,0 +1,185 @@ +import { expect } from "chai"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { CodeBlockExtractor } from "../src/CodeBlockExtractor"; + +describe("CodeBlockExtractor", () => { + const tmp = path.join(__dirname, "fixtures", "CodeBlockExtractorSpec"); + + beforeEach(async () => { + await fs.mkdir(tmp, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + describe("extract", () => { + it("extracts TypeScript code blocks from markdown", async () => { + const content = `# Example +\`\`\`ts +const a = 1; +\`\`\` + +\`\`\`typescript +function foo() {} +\`\`\``; + + const filePath = path.join(tmp, "README.md"); + await fs.writeFile(filePath, content, "utf-8"); + + const blocks = await CodeBlockExtractor.extract(filePath); + expect(blocks).to.have.length(2); + expect(blocks[0].code.trim()).to.equal("const a = 1;"); + expect(blocks[0].type).to.equal("ts"); + expect(blocks[1].code.trim()).to.equal("function foo() {}"); + expect(blocks[1].type).to.equal("ts"); + }); + + it("ignores blocks marked with ", async () => { + const content = `# Ignored Example + +\`\`\`ts +shouldNotAppear() +\`\`\` +\`\`\`ts +shouldAppear() +\`\`\``; + + const filePath = path.join(tmp, "IGNORE.md"); + await fs.writeFile(filePath, content, "utf-8"); + + const blocks = await CodeBlockExtractor.extract(filePath); + expect(blocks).to.have.length(1); + expect(blocks[0].code).to.equal("shouldAppear()\n"); + }); + + it("extracts TypeScript code blocks from markdown", async () => { + const content = `# Example +\`\`\`ts +const a = 1; +\`\`\``; + + const filePath = path.join(tmp, "README.md"); + await fs.writeFile(filePath, content, "utf-8"); + + const blocks = await CodeBlockExtractor.extract(filePath); + expect(blocks).to.have.length(1); + expect(blocks[0].code).to.equal("const a = 1;\n"); + }); + + it("extracts two codeblocks when tail and head are on a single line", async () => { + const content = `# Example +\`\`\`ts +const a = 1; +\`\`\`\`\`\`ts +const b = 2;\`\`\``; + + const filePath = path.join(tmp, "SINGLE_LINE.md"); + await fs.writeFile(filePath, content, "utf-8"); + + const blocks = await CodeBlockExtractor.extract(filePath); + expect(blocks).to.have.length(2); + expect(blocks[0].code).to.equal("const a = 1;\n"); + expect(blocks[1].code).to.equal("const b = 2;"); + }); + + it("should throw an error if the file does not exist", async () => { + // file is never written + await expect( + CodeBlockExtractor.extract(path.join(tmp, `INVALID-${Date.now()}.md`)) + ).to.eventually.rejectedWith(Error, "ENOENT: no such file or directory"); + }); + + it("should extract tsx code blocks", async () => { + const content = `# Example +\`\`\`tsx +const Component = () =>
Hello
; +\`\`\``; + + const filePath = path.join(tmp, "TSX.md"); + await fs.writeFile(filePath, content, "utf-8"); + + const blocks = await CodeBlockExtractor.extract(filePath); + expect(blocks).to.have.length(1); + expect(blocks[0].type).to.equal("tsx"); + expect(blocks[0].code.trim()).to.equal( + "const Component = () =>
Hello
;" + ); + }); + + it("should handle multiple code blocks with different types", async () => { + const content = `# Example +\`\`\`ts +const a = 1; +\`\`\` + +\`\`\`tsx +const b =
2
; +\`\`\` + +\`\`\`typescript +const c = 3; +\`\`\``; + + const filePath = path.join(tmp, "MULTIPLE.md"); + await fs.writeFile(filePath, content, "utf-8"); + + const blocks = await CodeBlockExtractor.extract(filePath); + expect(blocks).to.have.length(3); + expect(blocks[0].type).to.equal("ts"); + expect(blocks[1].type).to.equal("tsx"); + expect(blocks[2].type).to.equal("ts"); + expect(blocks[0].code.trim()).to.equal("const a = 1;"); + expect(blocks[1].code.trim()).to.equal("const b =
2
;"); + expect(blocks[2].code.trim()).to.equal("const c = 3;"); + }); + + it("should handle code blocks with newlines", async () => { + const content = `# Example +\`\`\`ts +const a = 1; +const b = 2; +const c = 3; +\`\`\``; + + const filePath = path.join(tmp, "NEWLINES.md"); + await fs.writeFile(filePath, content, "utf-8"); + + const blocks = await CodeBlockExtractor.extract(filePath); + expect(blocks).to.have.length(1); + expect(blocks[0].code).to.equal( + "const a = 1;\nconst b = 2;\nconst c = 3;\n" + ); + }); + + it("should ignore non-typescript code blocks", async () => { + const content = `# Example +\`\`\`js +const a = 1; +\`\`\` + +\`\`\`shell +echo "hello" +\`\`\` + +\`\`\`ts +const b = 2; +\`\`\` + +\`\`\`json +{ + "key": "value" +} +\`\`\``; + + const filePath = path.join(tmp, "NON_TS.md"); + await fs.writeFile(filePath, content, "utf-8"); + + const blocks = await CodeBlockExtractor.extract(filePath); + expect(blocks).to.have.length(1); + expect(blocks[0].type).to.equal("ts"); + expect(blocks[0].code).to.equal("const b = 2;\n"); + }); + }); +}); diff --git a/test/TestConfiguration.ts b/test/TestConfiguration.ts index bf02d38..fcca95f 100644 --- a/test/TestConfiguration.ts +++ b/test/TestConfiguration.ts @@ -4,4 +4,3 @@ import * as chai from "chai"; import chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); -chai.should(); diff --git a/test/TypeScriptDocsVerifierSpec.ts b/test/TypeScriptDocsVerifierSpec.ts index 57d558b..5bad206 100644 --- a/test/TypeScriptDocsVerifierSpec.ts +++ b/test/TypeScriptDocsVerifierSpec.ts @@ -4,6 +4,7 @@ import * as FsExtra from "fs-extra"; import { Gen } from "verify-it"; import * as TypeScriptDocsVerifier from "../index"; import { PackageDefinition } from "../src/PackageInfo"; +import { expect } from "chai"; const workingDirectory = path.join( os.tmpdir(), @@ -120,9 +121,9 @@ describe("TypeScriptDocsVerifier", () => { "returns an empty array if no code snippets are present", async () => { await createProject(); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([]); } ); @@ -147,9 +148,9 @@ ${wrapSnippet(strings[3], "bash")} { name: "README.md", contents: noTypeScriptMarkdown }, ], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([]); } ); @@ -158,10 +159,9 @@ ${wrapSnippet(strings[3], "bash")} Gen.string, async (filename) => { await createProject(); - return await TypeScriptDocsVerifier.compileSnippets([ - "README.md", - filename, - ]).should.be.rejectedWith(filename); + return expect( + TypeScriptDocsVerifier.compileSnippets(["README.md", filename]) + ).to.be.rejectedWith(filename); } ); @@ -174,9 +174,9 @@ ${wrapSnippet(strings[3], "bash")} await createProject({ markdownFiles: [{ name: fileName, contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets( - fileName - ).should.eventually.eql([ + return expect( + TypeScriptDocsVerifier.compileSnippets(fileName) + ).to.eventually.eql([ { file: fileName, index: 1, @@ -196,9 +196,9 @@ ${wrapSnippet(strings[3], "bash")} await createProject({ markdownFiles: [{ name: fileName, contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets( - fileName - ).should.eventually.eql([ + return expect( + TypeScriptDocsVerifier.compileSnippets(fileName) + ).to.eventually.eql([ { file: fileName, index: 1, @@ -232,9 +232,9 @@ export const bob = () => (
); }, }), }); - return await TypeScriptDocsVerifier.compileSnippets( - fileName - ).should.eventually.eql([ + return expect( + TypeScriptDocsVerifier.compileSnippets(fileName) + ).to.eventually.eql([ { file: fileName, index: 1, @@ -258,9 +258,9 @@ export const bob = () => (
); await createProject({ markdownFiles: [{ name: fileName, contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets( - fileName - ).should.eventually.eql([]); + return expect( + TypeScriptDocsVerifier.compileSnippets(fileName) + ).to.eventually.eql([]); } ); @@ -296,9 +296,9 @@ export const bob = () => (
); }); await createProject({ markdownFiles: markdownFiles }); - return await TypeScriptDocsVerifier.compileSnippets( - fileNames - ).should.eventually.eql(expected); + return expect( + TypeScriptDocsVerifier.compileSnippets(fileNames) + ).to.eventually.include.deep.members(expected); } ); @@ -311,16 +311,16 @@ export const bob = () => (
); await createProject({ markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -333,9 +333,9 @@ export const bob = () => (
); await createProject({ markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets( - [] - ).should.eventually.eql([]); + return expect( + TypeScriptDocsVerifier.compileSnippets([]) + ).to.eventually.eql([]); } ); @@ -358,7 +358,7 @@ export const bob = () => (
); markdownFiles: [{ name: "README.md", contents: markdown }], }); return () => - TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( + expect(TypeScriptDocsVerifier.compileSnippets()).to.eventually.eql( expected ); } @@ -375,16 +375,16 @@ export const bob = () => (
); await createProject({ markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -395,7 +395,7 @@ export const bob = () => (
); await createProject({ markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( + return expect(TypeScriptDocsVerifier.compileSnippets()).to.eventually.eql( [ { file: "README.md", @@ -427,16 +427,16 @@ ${snippet}`; await createProject({ markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -456,16 +456,16 @@ Gen.string() await createProject({ markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -483,23 +483,31 @@ Gen.string() await createProject({ markdownFiles: [{ name: "README.md", contents: markdown }], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.satisfy( + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.satisfy( (results: TypeScriptDocsVerifier.SnippetCompilationResult[]) => { - results.should.have.length(2); - results[0].should.not.have.property("error"); - const errorResult = results[1]; - errorResult.should.have.property("file", "README.md"); - errorResult.should.have.property("index", 2); - errorResult.should.have.property("snippet", invalidSnippet); - errorResult.should.have.property("error"); - errorResult.linesWithErrors.should.deep.equal([1]); - errorResult?.error?.message.should.include("README.md"); - errorResult?.error?.message.should.include("Code Block 2"); - errorResult?.error?.message.should.not.include("block-"); - - Object.values(errorResult.error || {}).forEach((value: unknown) => { - (value as string).should.not.include("block-"); - }); + expect(results).to.have.length(2); + + const errorResult = results.find((result) => result.error != null); + const nonErrorResult = results.find( + (result) => result.error == null + ); + expect(nonErrorResult).to.not.have.property("error"); + expect(errorResult).to.have.property("file", "README.md"); + expect(errorResult).to.have.property("index", 2); + expect(errorResult).to.have.property("snippet", invalidSnippet); + expect(errorResult).to.have.property("error"); + expect(errorResult?.linesWithErrors).to.deep.equal([1]); + expect(errorResult?.error?.message).to.include("README.md"); + expect(errorResult?.error?.message).to.include("Code Block 2"); + expect(errorResult?.error?.message).to.not.include("block-"); + + Object.values(errorResult?.error || {}).forEach( + (value: unknown) => { + expect(value as string).to.not.include("block-"); + } + ); return true; } @@ -527,22 +535,24 @@ Gen.string() }, }), }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.satisfy( + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.satisfy( (results: TypeScriptDocsVerifier.SnippetCompilationResult[]) => { - results.should.have.length(2); - results[0].should.not.have.property("error"); + expect(results).to.have.length(2); + expect(results[0]).to.not.have.property("error"); const errorResult = results[1]; - errorResult.should.have.property("file", "README.md"); - errorResult.should.have.property("index", 2); - errorResult.should.have.property("snippet", invalidSnippet); - errorResult.should.have.property("error"); - errorResult.linesWithErrors.should.deep.equal([1]); - errorResult?.error?.message.should.include("README.md"); - errorResult?.error?.message.should.include("Code Block 2"); - errorResult?.error?.message.should.not.include("block-"); + expect(errorResult).to.have.property("file", "README.md"); + expect(errorResult).to.have.property("index", 2); + expect(errorResult).to.have.property("snippet", invalidSnippet); + expect(errorResult).to.have.property("error"); + expect(errorResult.linesWithErrors).to.deep.equal([1]); + expect(errorResult?.error?.message).to.include("README.md"); + expect(errorResult?.error?.message).to.include("Code Block 2"); + expect(errorResult?.error?.message).to.not.include("block-"); Object.values(errorResult.error || {}).forEach((value: unknown) => { - (value as string).should.not.include("block-"); + expect(value as string).to.not.include("block-"); }); return true; @@ -571,21 +581,23 @@ console.log('This line is also OK'); mainFile, }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.satisfy( + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.satisfy( (results: TypeScriptDocsVerifier.SnippetCompilationResult[]) => { - results.should.have.length(1); + expect(results).to.have.length(1); const errorResult = results[0]; - errorResult.should.have.property("file", "README.md"); - errorResult.should.have.property("index", 1); - errorResult.should.have.property("snippet", invalidSnippet); - errorResult.should.have.property("error"); - errorResult.linesWithErrors.should.deep.equal([4]); - errorResult?.error?.message.should.include("README.md"); - errorResult?.error?.message.should.include("Code Block 1"); - errorResult?.error?.message.should.not.include("block-"); + expect(errorResult).to.have.property("file", "README.md"); + expect(errorResult).to.have.property("index", 1); + expect(errorResult).to.have.property("snippet", invalidSnippet); + expect(errorResult).to.have.property("error"); + expect(errorResult.linesWithErrors).to.deep.equal([4]); + expect(errorResult?.error?.message).to.include("README.md"); + expect(errorResult?.error?.message).to.include("Code Block 1"); + expect(errorResult?.error?.message).to.not.include("block-"); Object.values(errorResult?.error || {}).forEach((value) => { - (value as string).should.not.include("block-"); + expect(value as string).to.not.include("block-"); }); return true; @@ -614,16 +626,16 @@ console.log('This line is also OK'); markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], mainFile, }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -661,16 +673,16 @@ console.log('This line is also OK'); }, }), }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -704,16 +716,16 @@ console.log('This line is also OK'); mainFile, packageJson, }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -749,16 +761,16 @@ console.log('This line is also OK'); mainFile, packageJson, }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -804,16 +816,16 @@ console.log('This line is also OK'); packageJson, }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -861,16 +873,16 @@ console.log('This line is also OK'); packageJson, }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -906,16 +918,16 @@ console.log('This line is also OK'); mainFile, otherFiles: [otherFile], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -957,16 +969,16 @@ console.log('This line is also OK'); mainFile, otherFiles: [otherFile], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -996,16 +1008,16 @@ console.log('This line is also OK'); name: `@bbc/${defaultPackageJson.name}`, }, }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -1045,16 +1057,16 @@ console.log('This line is also OK'); }, otherFiles: [otherFile], }); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -1087,16 +1099,16 @@ console.log('This line is also OK'); packageJson, }; await createProject(projectFiles); - return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( - [ - { - file: "README.md", - index: 1, - snippet, - linesWithErrors: [], - }, - ] - ); + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.eql([ + { + file: "README.md", + index: 1, + snippet, + linesWithErrors: [], + }, + ]); } ); @@ -1136,9 +1148,9 @@ console.log('This line is also OK'); "DOCS.md" ); - return await TypeScriptDocsVerifier.compileSnippets([ - pathToMarkdownFile, - ]).should.eventually.eql([ + return expect( + TypeScriptDocsVerifier.compileSnippets([pathToMarkdownFile]) + ).to.eventually.eql([ { file: pathToMarkdownFile, index: 1, @@ -1158,9 +1170,9 @@ console.log('This line is also OK'); await createProject({ markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], }); - return await TypeScriptDocsVerifier.compileSnippets( - {} - ).should.eventually.eql([ + return expect( + TypeScriptDocsVerifier.compileSnippets({}) + ).to.eventually.eql([ { file: "README.md", index: 1, @@ -1208,10 +1220,12 @@ console.log('This line is also OK'); tsconfigJson ); - return await TypeScriptDocsVerifier.compileSnippets({ - markdownFiles: ["DOCS.md"], - project: tsconfigFilename, - }).should.eventually.eql([ + return expect( + TypeScriptDocsVerifier.compileSnippets({ + markdownFiles: ["DOCS.md"], + project: tsconfigFilename, + }) + ).to.eventually.eql([ { file: "DOCS.md", index: 1, @@ -1261,10 +1275,12 @@ console.log('This line is also OK'); tsconfigJson ); - return await TypeScriptDocsVerifier.compileSnippets({ - project: path.join(tsconfigDirectory, tsconfigFile), - markdownFiles: ["DOCS.md"], - }).should.eventually.eql([ + return expect( + TypeScriptDocsVerifier.compileSnippets({ + project: path.join(tsconfigDirectory, tsconfigFile), + markdownFiles: ["DOCS.md"], + }) + ).to.eventually.eql([ { file: "DOCS.md", index: 1, @@ -1274,5 +1290,73 @@ console.log('This line is also OK'); ]); } ); + + verify.it( + "does not run out of memory when processing a large number of snippets", + async () => { + const snippetLength = 600; + const largeNumberOfSnippets = Array(snippetLength) + .fill(null) + .map( + (_, i) => ` +interface LargeInterface${i} { + prop1: string; + prop2: number; + prop3: boolean; + prop4: { + nested1: string; + nested2: number; + nested3: boolean; + }; + prop5: Array<{ + arrayItem1: string; + arrayItem2: number; + arrayItem3: boolean; + }>; +} + +class LargeClass${i} implements LargeInterface${i} { + prop1: string = "test"; + prop2: number = 123; + prop3: boolean = true; + prop4 = { + nested1: "test", + nested2: 123, + nested3: true + }; + prop5: Array<{ + arrayItem1: string; + arrayItem2: number; + arrayItem3: boolean; + }> = [{ + arrayItem1: "test", + arrayItem2: 123, + arrayItem3: true + }]; +} + +const instance${i} = new LargeClass${i}(); +` + ); + const markdownBlocks = largeNumberOfSnippets.map((snippet) => + wrapSnippet(snippet) + ); + const contents = markdownBlocks.join("\n"); + + await createProject({ + markdownFiles: [{ name: "README.md", contents }], + }); + + return expect( + TypeScriptDocsVerifier.compileSnippets() + ).to.eventually.satisfy( + (results: TypeScriptDocsVerifier.SnippetCompilationResult[]) => { + expect(results).to.have.length(snippetLength); + expect(results.every((result) => !result.error)).to.eql(true); + return true; + } + ); + } + ); }); });