import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import Mocha from 'mocha'; import { readConfig } from './read-product-module-definition'; const mockClassNames = [ 'QuotePackage', 'Application', 'Policy', 'MemberModuleChange', 'PolicyMembersUpdatedChanges', 'ProductModulePolicyMembersUpdatedChanges', 'MemberQuotePackage', 'InvalidRequestError', 'ValidationError', 'RequotePolicy', 'ReactivationOption', 'AlterationPackage', 'AlteredPolicy', 'AlteredApplication', 'ApplicationAlterationPackage', ]; const rootHelpersCode = ` // Todo: the below needs to be converted to mock/stub/or actual functions // rootClient, const createUuid = () => uuid(); class QuotePackage { constructor(init) { Object.assign(this, init); } }; class Application { constructor(init) { Object.assign(this, init); } }; class Policy { constructor(init) { Object.assign(this, init); } }; class MemberModuleChange { constructor(init) { Object.assign(this, init); } }; class PolicyMembersUpdatedChanges { constructor(init) { Object.assign(this, init); } }; class ProductModulePolicyMembersUpdatedChanges { constructor(init) { Object.assign(this, init); } }; class MemberQuotePackage { constructor(init) { Object.assign(this, init); } }; class InvalidRequestError extends Error { constructor(message) { super(); this.name = this.constructor.name; this.message = message; } }; class ValidationError extends Error { constructor(fields) { super(); this.name = this.constructor.name; this.message = "Validation failed"; this.fields = fields; } } class RequotePolicy { constructor(init) { Object.assign(this, init); } }; class ReactivationOption { constructor(init) { Object.assign(this, init); } }; class AlterationPackage { constructor(init) { Object.assign(this, init); } }; class AlteredPolicy { constructor(init) { Object.assign(this, init); } }; class AlteredApplication { constructor(init) { Object.assign(this, init); } }; class ApplicationAlterationPackage { constructor(init) { Object.assign(this, init); } }; // Expose mock classes on globalThis to match Root platform runtime globalThis.QuotePackage = QuotePackage; globalThis.Application = Application; globalThis.Policy = Policy; globalThis.MemberModuleChange = MemberModuleChange; globalThis.PolicyMembersUpdatedChanges = PolicyMembersUpdatedChanges; globalThis.ProductModulePolicyMembersUpdatedChanges = ProductModulePolicyMembersUpdatedChanges; globalThis.MemberQuotePackage = MemberQuotePackage; globalThis.InvalidRequestError = InvalidRequestError; globalThis.ValidationError = ValidationError; globalThis.RequotePolicy = RequotePolicy; globalThis.ReactivationOption = ReactivationOption; globalThis.AlterationPackage = AlterationPackage; globalThis.AlteredPolicy = AlteredPolicy; globalThis.AlteredApplication = AlteredApplication; globalThis.ApplicationAlterationPackage = ApplicationAlterationPackage; `; const stripExports = (code: string): string => code.replaceAll(/^export\s+default\s+/gm, '').replaceAll(/^export\s+/gm, ''); const ensureFinalSemicolon = (str: string) => { return /;(\s*)?$/.test(str.trim()) ? `${str} ` : `${str}; `; }; /** * Run Mocha tests directly in the Node.js process. * * @async * @function run * @param {string} code - The string containing product module code and unit tests. * @return {Promise<{data: Object, summary: Object}>} An object containing the results of the test. */ export const runCode = async (code: string) => { const config = await readConfig(path.join('./')); const modulesPath = path.resolve(__dirname, '..', '..', '..', 'node_modules'); const helpersPath = path.resolve(__dirname, '..', 'helpers'); // Save original env values to restore after test run const origOrgId = process.env.ORGANIZATION_ID; const origEnv = process.env.ENVIRONMENT; process.env.ORGANIZATION_ID = config.organizationId; process.env.ENVIRONMENT = 'sandbox'; const userCodeWithLibs = ` const { describe, context, it, specify, before, after, beforeEach, afterEach } = require('${modulesPath.replaceAll( '\\', '/', )}/mocha'); const { should, expect, assert } = require('${modulesPath.replaceAll('\\', '/')}/chai'); const Joi = require('${helpersPath.replaceAll('\\', '/')}/joi'); const moment = require('${modulesPath.replaceAll('\\', '/')}/moment').utc; const dayjs = require('${modulesPath.replaceAll('\\', '/')}/dayjs'); const dayjsUtc = require('${modulesPath.replaceAll('\\', '/')}/dayjs/plugin/utc'); dayjs.extend(dayjsUtc); const { randomUUID } = require('node:crypto'); const uuid = randomUUID; const { z } = require('${modulesPath.replaceAll('\\', '/')}/zod'); const Zod = z; const zod = { z }; ${rootHelpersCode} ${stripExports(code)}`; // Use randomUUID for the filename — `Date.now()` at millisecond resolution can collide when // fs.watch fires rapidly (debounced save-all, concurrent runs). We do NOT wipe the dir here: // overlapping watch-mode invocations could yank a sibling's tmpFile out from under Mocha. // Cleanup happens in the 'end' handler below, scoped to this run's own tmpFile only. const tmpDir = path.join(os.tmpdir(), 'rp-tests'); if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true }); // Opportunistic janitorial cleanup: remove leftover test files older than 1 hour from // prior crashed / SIGKILLed runs. A live Mocha invocation writes and unlinks its file // within seconds, so a 1-hour threshold can't touch an in-flight sibling — and it stops // long watch-mode sessions with occasional crashes from accumulating indefinitely. const ONE_HOUR_MS = 60 * 60 * 1000; try { const now = Date.now(); for (const entry of fs.readdirSync(tmpDir)) { if (!entry.startsWith('test-') || !entry.endsWith('.cjs')) continue; const entryPath = path.join(tmpDir, entry); try { const stat = fs.statSync(entryPath); if (now - stat.mtimeMs > ONE_HOUR_MS) fs.unlinkSync(entryPath); } catch { // Entry raced another process or is gone; ignore. } } } catch { // Dir was removed between existsSync and readdirSync; ignore. } const tmpFile = path.join(tmpDir, `test-${randomUUID()}.cjs`); fs.writeFileSync(tmpFile, ensureFinalSemicolon(userCodeWithLibs)); const mocha = new Mocha({}); mocha.addFile(tmpFile); const runner = mocha.run(); let suiteStart: number; let suiteEnd; let runnerStart: number; const data: Record[] = []; let obj: { suite?: Record; parent_suite?: Record; depth?: number; tests?: { passed: boolean; description: string }[]; duration?: string; } = {}; let passCount = 0; let failCount = 0; let pendingCount = 0; let nestedLevels = 0; let suiteCount = 0; return new Promise<{ summary: Record; data: Record[] }>((resolve) => { runner.on('suite', (e: any) => { if (!runnerStart) runnerStart = Date.now(); suiteCount += 1; suiteStart = Date.now(); if (obj.suite) { data.push(obj); obj = {}; } const { title, parent } = e; if (parent?.title) obj.parent_suite = parent.title; let nextParent = e.parent; let nextTitle; let count = 0; while (nextTitle !== '') { try { nextParent = nextParent.parent; nextTitle = nextParent.title; count += 1; } catch { break; } } obj.depth = count; if (count > nestedLevels) nestedLevels = count; if (title) obj.suite = title; }); runner.on('test end', (error: { title?: string; state?: string }) => { const { title, state } = error; if (title && state) { if (!obj.tests) obj.tests = []; obj.tests.push({ description: title, passed: state === 'passed', }); if (state === 'passed') { passCount += 1; } else if (state === 'pending') { pendingCount += 1; } else { failCount += 1; } } }); runner.on('fail', (test: any, err: Error) => { console.error(err.message); if (err.stack) { console.error(err.stack); } }); runner.on('suite end', () => { suiteEnd = Date.now(); obj.duration = `${suiteEnd - suiteStart}ms`; }); runner.on('end', () => { if (obj.suite) { data.push(obj); obj = {}; } const runnerEnd = Date.now(); const summary = { passed: passCount, failed: failCount, pending: pendingCount, tests: passCount + failCount + pendingCount, suites: suiteCount, depth: nestedLevels, runnerEnd, duration: `${runnerEnd - runnerStart}ms`, }; // Clear require cache to avoid stale state in watch mode try { delete require.cache[require.resolve(tmpFile)]; } catch { // File may not be in cache yet } // Only unlink our own tmpFile — never rm the whole tmpDir here. Overlapping watch-mode // invocations can share the dir, and recursive removal would pull sibling files out // from under another in-flight Mocha run. if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); // Restore original env values. Note: `process.env.X = undefined` coerces to the // literal string `"undefined"` (Node stringifies all env assignments), so we must // `delete` the key when the original value was unset. Without this, watch-mode // re-runs leave `ORGANIZATION_ID = "undefined"` behind — truthy, and downstream // `if (!process.env.ORGANIZATION_ID)` branches silently misbehave. if (origOrgId === undefined) delete process.env.ORGANIZATION_ID; else process.env.ORGANIZATION_ID = origOrgId; if (origEnv === undefined) delete process.env.ENVIRONMENT; else process.env.ENVIRONMENT = origEnv; // Clean up mock classes from globalThis to avoid polluting the host process for (const name of mockClassNames) { delete (globalThis as Record)[name]; } resolve({ summary, data }); }); }); }; export const unitTestFileTemplate = `// This file is used by the 'rp test -u' unit testing command and allows you to write and run unit tests locally. // This file is automatically commented out by the CLI tool when being pushed to Root. // This ensures that it does not interfere with production execution. describe('getQuote', function () { const quoteData = { cover_amount: 200000*100, cover_period: '1_year', basic_income_per_month: 50000*100, education_status: 'undergraduate_degree', smoker: false, gender: 'female', age: 19, }; it('should return an integer suggested premium', function () { const quotePackage = getQuote(quoteData); expect(quotePackage.suggested_premium).to.be.a('number') expect(quotePackage.suggested_premium % 1).to.equal(0) }); it('should return a suggested premium of R99.94 (in cents)', function () { const quotePackage = getQuote(quoteData); expect(quotePackage.suggested_premium).to.equal(9994); // in cents }); }); `;