diff --git a/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js b/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js index 8e68c7ec6d..fb71c2b165 100644 --- a/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js +++ b/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js @@ -27,6 +27,7 @@ const {parse} = require('@babel/parser') const fs = require('node:fs') const os = require('node:os') const path = require('node:path') +const {Worker} = require('node:worker_threads') const ts = require('typescript') const plugin = require('../babel-plugin-lexicon-leaf-imports') @@ -526,7 +527,7 @@ describe('app callsites: transformed sources typecheck', () => { */ test( 'every file importing the barrel', - () => { + async () => { const consumers = [] ;(function walk(dir) { for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { @@ -605,23 +606,36 @@ describe('app callsites: transformed sources typecheck', () => { const options = loadAppCompilerOptions() + /* + * The baseline and shadow typechecks are independent CPU-bound + * programs, so each runs in its own worker thread and the two proceed + * in parallel. The worker returns diagnostics as plain records (see + * lexiconTypecheckWorker.js). + */ function diagnose(overlays) { - const host = createOverlayHost(options, overlays) - const program = ts.createProgram(consumers, options, host) - const byFile = new Map() - for (const file of consumers) { - const sf = program.getSourceFile(file) - if (!sf) throw new Error(`${file} missing from program`) - byFile.set(file, fileDiagnostics(program, sf)) - } - return byFile + return new Promise((resolve, reject) => { + const worker = new Worker( + path.join(__dirname, '..', 'lexiconTypecheckWorker.js'), + {workerData: {consumers, overlays, options}}, + ) + worker.once('message', byFile => + resolve(new Map(Object.entries(byFile))), + ) + worker.once('error', reject) + worker.once('exit', code => { + if (code !== 0) { + reject(new Error(`typecheck worker exited with code ${code}`)) + } + }) + }) } - const baseline = diagnose(baselineOverlays) - const shadow = diagnose(shadowOverlays) + const [baseline, shadow] = await Promise.all([ + diagnose(baselineOverlays), + diagnose(shadowOverlays), + ]) - const diagKey = d => - `TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}` + const diagKey = d => `TS${d.code}: ${d.message}` const regressions = [] for (const file of consumers) { const known = new Set(baseline.get(file).map(diagKey)) @@ -630,8 +644,16 @@ describe('app callsites: transformed sources typecheck', () => { } } if (regressions.length > 0) { + const details = regressions + .slice(0, 20) + .map(d => + d.fileName + ? `${d.fileName}:${d.line} TS${d.code}: ${d.message}` + : `TS${d.code}: ${d.message}`, + ) + .join('\n') throw new Error( - `plugin introduced diagnostics in app sources:\n${formatDiagnostics(regressions)}`, + `plugin introduced diagnostics in app sources:\n${details}`, ) } }, diff --git a/plugins/lexiconTypecheckWorker.js b/plugins/lexiconTypecheckWorker.js new file mode 100644 index 0000000000..a84757238b --- /dev/null +++ b/plugins/lexiconTypecheckWorker.js @@ -0,0 +1,48 @@ +/* + * Worker thread for the "app callsites" test in + * __tests__/babel-plugin-lexicon-leaf-imports.test.js: typechecks the + * consumer files with the given overlay contents and reports error + * diagnostics. The baseline and shadow typechecks are independent CPU-bound + * programs, so the test runs one worker for each in parallel. + * + * Lives outside __tests__/ so Jest does not collect it as a test suite. + * ts.Diagnostic objects do not survive structured clone, so diagnostics are + * flattened to plain {code, message, fileName, line} records here. + */ +const {parentPort, workerData} = require('node:worker_threads') +const ts = require('typescript') + +const {consumers, overlays, options} = workerData + +const host = ts.createCompilerHost(options) +const origGetSourceFile = host.getSourceFile.bind(host) +const origFileExists = host.fileExists.bind(host) +const origReadFile = host.readFile.bind(host) +host.fileExists = f => overlays.has(f) || origFileExists(f) +host.readFile = f => overlays.get(f) ?? origReadFile(f) +host.getSourceFile = (f, lang, ...rest) => + overlays.has(f) + ? ts.createSourceFile(f, overlays.get(f), lang) + : origGetSourceFile(f, lang, ...rest) + +const program = ts.createProgram(consumers, options, host) +const byFile = {} +for (const file of consumers) { + const sf = program.getSourceFile(file) + if (!sf) throw new Error(`${file} missing from program`) + byFile[file] = [ + ...program.getSyntacticDiagnostics(sf), + ...program.getSemanticDiagnostics(sf), + ] + .filter(d => d.category === ts.DiagnosticCategory.Error) + .map(d => ({ + code: d.code, + message: ts.flattenDiagnosticMessageText(d.messageText, ' '), + fileName: d.file?.fileName, + line: + d.file && d.start !== undefined + ? d.file.getLineAndCharacterOfPosition(d.start).line + 1 + : undefined, + })) +} +parentPort.postMessage(byFile)