import chalk from 'chalk'; import path from 'node:path'; import fs from 'node:fs'; import * as esbuild from 'esbuild'; import moment from 'moment'; import { unitTestFileTemplate, runCode } from '../helpers/unit-tests-helper'; import { writeFile } from '../helpers/write-file'; import { CodeDirectoryName, TopLevelDirectoryName } from '../domain/file-names'; import { readConfig } from '../helpers/read-product-module-definition'; import { tsBuild } from './ts-build'; import { runWithSpinner } from '../helpers/spinner'; import { symbols } from '../helpers/symbols'; const unitTestsDirPath = path.join(TopLevelDirectoryName.Code, CodeDirectoryName.UnitTests); /** * Wrap the esbuild CJS bundle in an IIFE with its own synthetic `module` / `exports` * bindings, then harvest `module.exports.registeredFunctions` onto `globalThis` so unit * tests can call each registered function by name (e.g. `getQuote(data)`). * * Why the IIFE instead of a footer strip: the previous implementation ran * `bundle.replace(/module\.exports\s*=\s*__toCommonJS\([^)]*\)\s*;?\s*$/m, '')` and * relied on the top-level `var registeredFunctions` declaration being visible to the * concatenated unit-test code. That couples this helper to the exact wording of * esbuild's CJS footer (names like `__toCommonJS` are esbuild internals that have * changed across majors and could change again); if a future esbuild renamed the * wrapper, emitted it differently under `--minify`, or switched to a TLA-aware * output, the regex would silently no-op and leave a stray `module.exports = ...` * assignment at the top level of the runner script. * * The IIFE approach reads `module.exports.registeredFunctions` — the *contract* that * esbuild guarantees for a CJS bundle — rather than pattern-matching how it's spelled. * As a bonus, every internal symbol esbuild emits (`__defProp`, `__toCommonJS`, the * `src_exports` object) stays IIFE-scoped instead of leaking into the runner's * top-level scope alongside the user's product-module and test code. */ const prepareTsBundle = (bundle: string): string => { return ` (function () { const module = { exports: {} }; const exports = module.exports; ${bundle} const exported = module.exports && module.exports.registeredFunctions; if (exported) Object.assign(globalThis, exported); })(); `; }; const executeTests = async (opts: { skipTsBuild?: boolean } = {}) => { const config = await readConfig(path.join('./')); const isTypeScript = !!config.settings.typescriptProductModuleCode; let codeFiles: { fileName: string; fileContent: string }[]; if (isTypeScript) { const bundlePath = path.join(TopLevelDirectoryName.Code, CodeDirectoryName.Build, 'index.js'); // In watch mode, only rebuild when the change actually touched source files — unit-test // edits don't affect the compiled bundle. Falls back to rebuilding if the bundle is // missing (first run, or user deleted .build/). if (!opts.skipTsBuild || !fs.existsSync(bundlePath)) { await tsBuild(); } const bundle = fs.readFileSync(bundlePath, { encoding: 'utf8' }); codeFiles = [{ fileName: 'index.js', fileContent: prepareTsBundle(bundle) }]; } else { // TEMPORARY UNTIL PLATFORM DEPLOYED codeFiles = fs .readdirSync(TopLevelDirectoryName.Code) .filter((fn) => fn.slice(-3) === '.js') .map((fn) => ({ fileName: fn, fileContent: fs.readFileSync(path.join(TopLevelDirectoryName.Code, fn), { encoding: 'utf8', }), })); } // Read unit-test files (`.ts`, `.ts.js`, `.js`) and always run them through esbuild's // TS loader before concatenation. We used to only transform `.ts` and pass `.js` through // raw, but that broke on TS modules whose unit tests are named `.ts.js` (a convention // some teams use so IDEs/linters treat them as JS while the source still holds TypeScript // syntax like `quoteData?: any`). Raw TS ending up in the Mocha tmpfile fails Node's // parser with "Unexpected token ':'" on the first type annotation. // // Running plain JS through the TS loader is a no-op for syntax, so uniformly // transforming every test file is simpler than trying to sniff which files contain // TS — and it gives us one consistent target/format for the concatenated output. const unitTestCodeFiles = await runWithSpinner('Preparing unit test files...', () => Promise.all( fs .readdirSync(`${TopLevelDirectoryName.Code}/${CodeDirectoryName.UnitTests}`) .filter((fn) => fn.endsWith('.js') || fn.endsWith('.ts')) .sort() .map(async (fn) => { const raw = fs.readFileSync(path.join(unitTestsDirPath, fn), { encoding: 'utf8' }); const result = await esbuild.transform(raw, { loader: 'ts', target: 'es2022', format: 'cjs', sourcefile: fn, }); return { fileName: fn, fileContent: result.code }; }), ), ); // END TEMPORARY console.log(`${symbols.pointer}Running test suite...\n`); const code = [...codeFiles, ...unitTestCodeFiles].map((cf) => cf.fileContent).join('\n; \n'); const testSummary = await runCode(code); const { passed, failed, pending, tests, duration } = testSummary.summary as { passed: number; failed: number; pending: number; tests: number; duration: string; }; if (failed !== 0) { process.exitCode = 1; } // Mocha never sets the runner start time when no tests run, so the duration reads "NaNms" — omit it. const durationSuffix = duration && !duration.includes('NaN') ? ` (${duration})` : ''; const summaryMessage = `Test suite complete: ${passed} passed · ${failed} failed · ${pending} pending · ${tests} total${durationSuffix}`; console.log('\n' + (failed === 0 ? chalk.green(summaryMessage) : chalk.red(summaryMessage))); }; export const test = async (options: { watch?: boolean; template?: boolean }) => { const unit = true; // !!options.unit; // Defaults to running unit tests const watch = !!options.watch; // Defaults to not watching const template = !!options.template; // Defaults to not creating example test code // SKIPPING THE BELOW WHILST WE WAIT FOR THE PLATFORM CHANGES TO BE DEPLOYED, // ALLOWING THE TOOL TO RUN IN INVALID DIRECTORIES // await checkValidDirectory(); if (unit) { if (template) { const config = await readConfig(path.join('./')); const templateFileName = config.settings.typescriptProductModuleCode ? 'example-tests.ts' : 'example-tests.js'; await writeFile(path.join(unitTestsDirPath, templateFileName), unitTestFileTemplate); console.log( chalk.green( `Example test file created in "${TopLevelDirectoryName.Code} > ${CodeDirectoryName.UnitTests} > ${templateFileName}".`, ), ); } await executeTests(); if (watch) { console.log(chalk.blue(`\n${symbols.info}Watching for code or test file changes.`)); let debounceTime = moment(); // Debounce for 100ms, cause somehow a save gets picked up twice // Separate listeners so we know which tree the change came from. For TS modules the // tsBuild step dominates run time — skipping it when only unit tests changed gives a // meaningful watch-mode latency win on large modules. const unitTestsDirName = CodeDirectoryName.UnitTests; // The code/ watcher: bails out if the change is under unit-tests/ so the dedicated // unit-tests watcher (which passes `skipTsBuild: true`) gets to handle it instead. // Without this filter, macOS fs.watch('./code') fires for unit-tests changes too and // the first-registered listener wins, forcing a full tsBuild on every test edit. const codeListener = async (eventType: string, filename: string | null) => { if ( filename && (filename === unitTestsDirName || filename.startsWith(`${unitTestsDirName}/`) || filename.startsWith(`${unitTestsDirName}${path.sep}`)) ) { return; // unit-tests listener will handle it } if (eventType === 'change' && moment().diff(debounceTime) > 100) { console.log(`${symbols.info}File changed: ${filename}`); debounceTime = moment(); await executeTests({ skipTsBuild: false }); } }; const unitTestsListener = async (eventType: string, filename: string | null) => { if (eventType === 'change' && moment().diff(debounceTime) > 100) { console.log(`${symbols.info}File changed: ${filename}`); debounceTime = moment(); await executeTests({ skipTsBuild: true }); } }; fs.watch(path.join('./', TopLevelDirectoryName.Code), codeListener); fs.watch(path.join('./', unitTestsDirPath), unitTestsListener); } } else { throw new Error(`No test method specified. Try 'rp test -u' instead.`); } };