1// Generic test runner core - can be used by any app with eptNextjsApp or similar 2import * as _ from 'lodash-es' 25const schemaErrPatternsAI = [ 26 /column "[\w_]+" (of relation "[\w_]+" )?does not exist/i, 27 /relation "[\w_]+" does not exist/i, 29const hasSchemaErr = (results: Record<string, SuiteResult>) => { 30 for (const r of Object.values(results)) { 31 if (r.passed) continue 32 const txt = (r.error?.message || '') + (r.message || '') 33 if (schemaErrPatternsAI.some(p => p.test(txt))) return true 38export type SuiteResult = { 41 testResults: TestResultItem[] 47type StopServerFnc = (() => Promise<void>) | { stop: () => Promise<void>; getOutput: () => string } 49export type SuiteRunnerCtx = { 51 singleTestName?: string // partial name match for --test=suite:name_fragment 54 stopServerRef: { current: StopServerFnc | null } 56 baseUrl: string | undefined 57 mockEth: boolean // Use mock payments instead of real blockchain txs (default: true for ptenv=local) 58 topSuiteName?: string // top-level suite name for --test= hint in testline output 59 isTestdeploy?: boolean // true when invoked via testdeploy action 62export type SuiteRunner = (ctx: SuiteRunnerCtx) => Promise<SuiteResult> 64export type SuiteDep = 'server' | 'db' 66export type SuiteConfig = { 69 deps?: readonly SuiteDep[] // empty/undefined = suite manages everything; 'server' = start dev server; 'db' = DB wrapper (implies server) 70 timeoutMs?: number // per-suite timeout, defaults to 15min 73export const defaultTestSuiteTimeoutMs = 15 * 60 * 1000 // 15 min 75export const combineSuites = (runners: SuiteRunner[]): SuiteRunner => async (ctx) => { 76 const allResults: SuiteResult['testResults'] = [] 78 for (const runner of runners) { 79 const result = await runner(ctx) 80 if (result.skipped) continue 81 allResults.push(...result.testResults) 82 if (!result.passed) allPassed = false 84 return { passed: allPassed, message: allPassed ? 'all passed' : 'some failed', testResults: allResults } 87export type RunTestsCoreConfig = { 90 suiteConfigs: SuiteConfig[] 91 wrapDbEnv?: (opts: { appConfig: any; qsName?: string; reuseConn: boolean; assumeDaemon?: boolean; wrappedFnc: () => Promise<void> }) => Promise<void> 92 preDeployedTests?: () => Promise<void> 93 getFlags?: () => Record<string, boolean> 94 historyFlags?: string[] 97type SingleTest = { suite: string; nameFragment: string } 99const parseSingleTest = (testArg: string | undefined): SingleTest | null => { 100 if (!testArg) return null 101 // Format: suite:name_fragment (e.g., "donate:sender_addr") 102 const colonIdx = testArg.indexOf(':') 104 const suite = testArg.slice(0, colonIdx) 105 const nameFragment = testArg.slice(colonIdx + 1) 108 return { suite, nameFragment } 111const runSuite = async (name: string, fn: () => Promise<SuiteResult>, results: Record<string, SuiteResult>, timeoutMs: number) => { 112 const startTime = Date.now() 114 const abortSignal = AbortSignal.timeout(timeoutMs) 115 const timeoutPromise = new Promise<SuiteResult>((_, reject) => { 116 abortSignal.addEventListener('abort', () => reject(new Error(`Suite timeout after ${Math.round(timeoutMs/1000)}s`))) 118 results[name] = await Promise.race([fn(), timeoutPromise]) 120 const debugH = err.uniqDebugH ? JSON.stringify(err.uniqDebugH) : '' 121 const message = debugH ? `${err.message} ${debugH}` : err.message 122 results[name] = { passed: false, message, error: err, testResults: [] } 124 results[name].durationSec = Math.round((Date.now() - startTime) / 1000) 128export const baseRuntestsCli = { 129 suite: { type: 'csv' as const, desc: 'suites to run (comma-separated)' }, 130 history: { flag: true as const, desc: 'show test history' }, 131 'no-memo': { flag: true as const, desc: 'skip memoization, always run tests' }, 132 skipExtDep: { flag: true as const, hidden: true as const, desc: 'skip extDep tests (ptm bisect)' }, 135export const mkRuntestsCli = (allSuites: string[]) => ({ 137 suite: { type: 'csv' as const, default: allSuites, desc: 'suites to run (comma-separated)' }, 138 ptenv: { type: 'string' as const, default: ptenvLocal, enum: ptenvEnum, desc: 'testlocal|testprod' }, 139 limit: { type: 'int' as const, default: 10 }, 140 verbose: { flag: true as const, desc: 'show all test output' }, 141 test: { type: 'string' as const, hint: 'name', desc: 'run single test by name, e.g. donate:sender_addr' }, 144// Eth-related flags - only include in apps that have eth payment tests (e.g. donateapp) 145// mockEth defaults to true for local, false for deployed. --real-eth forces real payments. 146export const ethRuntestsCli = { 147 'real-eth': { flag: true as const, desc: 'force real ETH payments (default: mock for local, real for deployed)' }, 150export type RunTestsCoreResult = { 152 ranSuites: Record<string, SuiteResult> 153 skippedSuites: Record<string, SuiteResult> 158export type RunTestsOpts = { 162 singleTest: SingleTest | null 167 isTestdeploy?: boolean 170// Core test runner - purely programmatic, no CLI reading. Callers parse CLI and pass opts. 171export const runTestsCore = async ({ 175 config: RunTestsCoreConfig 177}): Promise<RunTestsCoreResult> => { 178 const coreStartTime = Date.now() 179 const { allSuites, localAppCfg, suiteConfigs, wrapDbEnv, getFlags } = config 180 const { ptenv, noMemo, fastFail, singleTest, limit, verbose, mockEth, isTestdeploy } = opts 181 let { testsuites } = opts 183 // Ensure envConf (including secretsMapping) is available for test helpers like genEmailOtp 184 const appCfgEnvConf = getAppCfg()?.envConf || localAppCfg?.envConf 188 if (!noMemo && !singleTest) { 193 return { allPassed: true, ranSuites: {}, skippedSuites: {}, totalTests: 0 } 195 } catch { /* memo check failed, continue with tests */ } // catch:userapproved 199 assertIncludes(allSuites, singleTest.suite, {suite: singleTest.suite, available: allSuites}) 200 testsuites = [singleTest.suite] 203 if (ptenv === ptenvTestprod) { 205 const { name, cluster_name } = appCfg 206 if (name && cluster_name) { 208 if (!health.healthy) { 209 console.log(chalkRed(`Pods not healthy: ${health.message}. Run: ptnode <ep> apply`)) 210 return { allPassed: false, ranSuites: {}, skippedSuites: {}, totalTests: 0 } 215 const etaHint = singleTest ? '' : ` # ${getTimingHint(ep, 'runtests', {ptenv, suites: testsuites})}` 216 console.log(`${chalkCyan('runtests')} ${chalkGray(`--ptenv=${ptenv} --suite=${testsuites.join(',')}${etaHint}`)}`) 218 const results: Record<string, SuiteResult> = {} 220 let exitEarly = false 221 const stopServerRef: { current: StopServerFnc | null } = { current: null } 222 const singleTestName = singleTest?.nameFragment 224 const getBaseUrlAndEnsureServer = async () => { 226 const testPort = appCfg.testSvcPortNo ?? (appCfg.svcPortNo || 3000) 227 const baseUrl = ptenv === ptenvLocal ? `http://127.0.0.1:${testPort}` : appCfg.deployedBaseUrl 228 if (ptenv === ptenvLocal && !stopServerRef.current) { 229 appCfgCtx.enterWith(appCfg) 231 await gqlFetch({baseUrl: baseUrl!, query: '{ gqDeployInfo }', skipTestIp: true}) 233 return { baseUrl, appCfg } 236 // Validate suiteConfigs - fail fast on circular import issues 237 for (let i = 0; i < suiteConfigs.length; i++) { 238 const sc = suiteConfigs[i] 242 // Build lookup for suite configs 243 const suiteConfigMap = Object.fromEntries(suiteConfigs.map(sc => [sc.name, sc])) 245 // Run suites in order 246 const runSuiteWithDeps = async (suiteName: string, suiteConfig: SuiteConfig): Promise<boolean> => { 247 const { runner, deps = [], timeoutMs = defaultTestSuiteTimeoutMs } = suiteConfig 248 // Merge localAppCfg (from app's appCfgF.mjs) with context appCfg (from entrypoint chain) 249 // localAppCfg has test-specific config like healthgqlTestFnc; context has runtime config 251 appCfgCtx.enterWith(appCfg) // ensure getAppCfg() in suite runners sees merged config 252 const testPort = appCfg.testSvcPortNo ?? (appCfg.svcPortNo || 3000) 253 const defaultBaseUrl = ptenv === ptenvLocal ? `http://127.0.0.1:${testPort}` : appCfg.deployedBaseUrl 254 if (ptenv === ptenvLocal) testS3EndpointCtx.enterWith(defaultBaseUrl) // signed-URL test fetches target the isolated test server 256 const doRun = async (baseUrl: string | undefined, cfg: typeof appCfg) => { 257 const ctx: SuiteRunnerCtx = { ptenv, singleTestName, limit, verbose, stopServerRef, appCfg: cfg, baseUrl, mockEth, topSuiteName: suiteName, isTestdeploy } 258 const result = await runSuite(suiteName, () => runner(ctx), results, timeoutMs) 259 return result.passed !== false 262 if (deps.includes('db') && wrapDbEnv) { 266 qsName: 'defaultdb_qs', 269 wrappedFnc: async () => { 270 const { baseUrl } = await getBaseUrlAndEnsureServer() 271 passed = await doRun(baseUrl, appCfg) 277 if (deps.includes('server')) { 278 const { baseUrl, appCfg: serverCfg } = await getBaseUrlAndEnsureServer() 279 return doRun(baseUrl, serverCfg) 282 return doRun(defaultBaseUrl, appCfg) 286 for (const suiteName of testsuites) { 288 const suiteConfig = suiteConfigMap[suiteName] 289 assertDefined(suiteConfig, {suiteName, available: Object.keys(suiteConfigMap)}) 292 allPassed = await runSuiteWithDeps(suiteName, suiteConfig) && allPassed 294 results[suiteName] = { passed: false, message: err.message, testResults: [] } 296 if (fastFail) exitEarly = true 300 portForwardMgr.teardown() 303 const stopFnc = stopServerRef.current 304 if (stopFnc) await (typeof stopFnc === 'function' ? stopFnc() : stopFnc.stop()) 306 const ranSuites = _.pickBy(results, r => !r.skipped) as Record<string, SuiteResult> 307 const skippedSuites = _.pickBy(results, r => r.skipped) as Record<string, SuiteResult> 308 const passedCount = _.values(ranSuites).filter(r => r.passed).length 309 const totalTests = _.sumBy(_.values(ranSuites), r => r.testResults?.length || 0) 312 for (const [suite, result] of _.toPairs(ranSuites)) { 315 for (const [suite, result] of _.toPairs(skippedSuites)) { 316 console.log(`${chalkYellow('SKIP')} [${suite}] ${result.message}`) 318 console.log(allPassed ? chalkGreen(`\nAll ${_.size(ranSuites)} suites passed!`) : chalkRed(`\n${passedCount}/${_.size(ranSuites)} suites passed`)) 320 // Aggregate and print total gas spent across all tests 321 const allTestResults = _.values(ranSuites).flatMap(r => r.testResults || []) 322 const totalGasEth = _.sumBy(allTestResults, r => parseFloat(r.ethGasSpent || '0')) 323 if (totalGasEth > 0) console.log(chalkGray(`runtests gas: ${totalGasEth.toFixed(10)} ETH`)) 325 if (aiTot.tok > 0) console.log(chalkGray(`runtests ai: ${aiTot.tok} tok, $${aiTot.usd.toFixed(4)}`)) 327 if (!allPassed && hasSchemaErr(ranSuites)) { 328 console.log(chalkYellow(`HINT: DB schema error detected. Run: ptnode <db_sync.mjs> migrate`)) 333 const firstFail = _.toPairs(ranSuites).find(([, r]) => !r.passed) 334 const firstFailTest = firstFail ? firstFail[1].testResults.find(t => !t.passed) : undefined 335 const testArg = firstFail && firstFailTest ? `${firstFail[0]}:${getTestFragment(firstFailTest.name)}` : undefined 336 if (lastGoodSha && !process.cwd().includes(bisectWorktreePrefix)) console.log(chalkYellow(`HINT: To bisect: ${bisectHintCmd(epRel, lastGoodSha, testArg)}`)) 339 const durationSec = Math.round((Date.now() - coreStartTime) / 1000) 340 const flags = getFlags?.() 343 return { allPassed, ranSuites, skippedSuites, totalTests, durationSec } 346// Build RunTestsOpts from parsed CLI. Accepts any cli that extends mkRuntestsCli (with or without ethRuntestsCli) 347export const optsFromCli = (cli: ReturnType<typeof parseCli<ReturnType<typeof mkRuntestsCli>>> & Partial<{['real-eth']: boolean}>, allSuites: string[]): RunTestsOpts => { 348 const ptenv = cli.ptenv as Ptenv 350 const verbose = cli.verbose ?? false 351 const singleTest = parseSingleTest(cli.test) 352 // mockEth: default true for local, false for deployed. --real-eth forces real payments. 353 const mockEth = cli['real-eth'] ? false : ptenv === ptenvLocal 356 noMemo: cli['no-memo'] ?? false, 359 testsuites: cli.suite || allSuites, 360 limit: cli.limit ?? 10, 366// Default opts for programmatic callers (like testdeploy) that don't read CLI 367export const defaultRunTestsOpts = (allSuites: string[], ptenv: Ptenv): RunTestsOpts => ({ 372 testsuites: allSuites, 375 mockEth: ptenv === ptenvLocal, 378// CLI wrapper factory for runtests action - handles --history flag 379export const mkRuntests = (config: RunTestsCoreConfig) => { 380 const runtests = async () => { 381 const runtestsCli = mkRuntestsCli(config.allSuites) 390 const opts = optsFromCli(cli, config.allSuites) 391 const result = await silenceEtherealCtx.run(true, () => runTestsCore({ config, opts })) 392 if (!result.allPassed) process.exit(1) 395 runtests.cliSchema = mkRuntestsCli(config.allSuites)