12export const getTestExceptionHeaders = (): Record<string, string> => { 14 return headerName ? {[headerName]: '1'} : {} 17let currentTestIp: string | undefined 18const randomOctet = () => Math.floor(Math.random() * 255) 19export const rotateTestIp = () => { currentTestIp = `10.250.${randomOctet()}.${randomOctet()}` } 20export const getTestIpHeaders = (): Record<string, string> => { 22 return (headerName && currentTestIp) ? {[headerName]: currentTestIp} : {} 25// Shared gqlFetch for test suites - supports auth token and extra headers 26export const gqlFetch = async ({baseUrl, query, variables, gqUserTok, extraHeaders, skipTestIp}: { 29 variables?: Record<string, unknown> 31 extraHeaders?: Record<string, string> 34 const headers: Record<string, string> = {'Content-Type': 'application/json', 'Connection': 'close', ...getTestExceptionHeaders(), ...(skipTestIp ? {} : getTestIpHeaders()), ...extraHeaders} 35 if (gqUserTok) headers['Authorization'] = `Bearer ${gqUserTok}` 36 const doFetch = () => fetch(`${baseUrl}/api/graphql`, { 39 body: JSON.stringify({query, variables}), 41 const maxRetries = 3, retryDelayMs = 2000 42 for (let attempt = 0; attempt <= maxRetries; attempt++) { 43 const resp = await doFetch().catch(() => doFetch()) 44 const respContentType = resp.headers.get('content-type') ?? '' 45 if (respContentType.includes('application/json')) return resp.json() 46 if (attempt < maxRetries) { 50 return {errors: [{message: `gqlFetch got ${respContentType || 'empty content-type'} (status ${resp.status})`, extensions: {code: 'NON_JSON_RESPONSE'}}]} 54// Check if GraphQL response has errors matching patterns (case-insensitive message or exact code) 55export const hasGqlErrMatch = (resp: unknown, ...patterns: string[]): boolean => 56 !!(resp as {errors?: Array<{message?: string, extensions?: {code?: string}}>})?.errors?.some( 57 (e) => patterns.some(p => e.message?.toLowerCase().includes(p) || e.extensions?.code === p) 60// Generate string of repeated chars (for size limit tests) 61export const genTestStr = (len: number) => 'x'.repeat(len) 64type TestResult = { passed: boolean; msg: string; skipped?: boolean; ethGasSpent?: string; aiCostUsd?: string; aiTokUsed?: number } 65type TestFn = () => Promise<TestResult> | TestResult 66type Test = { name: string; fn: TestFn; repoWideState?: boolean; extDep?: boolean } 68export const skipTest = (reason: string): TestResult & {skipped: true} => ({ passed: true, msg: reason, skipped: true }) 70export const skipIfLocal = (reason = 'requires --ptenv=testprod') => skipTest(reason) 71export const skipIfTestprod = (reason = 'local only') => skipTest(reason) 77// Suites that write test data (orders, payments, ipfs uploads) must only run against smoke deployments. 78// smokeOverlay sets appCfg.name to '<app>-smoke'; manual prod `runtests --ptenv=testprod` skips. 80 ctx.ptenv === ptenvTestprod && !(ctx.appCfg?.name ?? '').endsWith('-smoke') 81 ? skipSuite('writes test data — smoke deployment only') 84// Create a test collector - returns {tests, test} where test() adds to tests array 85export const createTestCollector = () => { 86 const tests: Test[] = [] 87 const test = (name: string, fn: TestFn, opts?: {repoWideState?: boolean; extDep?: boolean}) => tests.push({name, fn, ...opts}) 91export type TestResultItem = { idx: number; name: string; passed: boolean; msg: string; skipped?: boolean; ethGasSpent?: string; aiCostUsd?: string; aiTokUsed?: number; repoWideState?: boolean; extDep?: boolean } 93export const sumAiUsage = (testResults: {aiCostUsd?: string, aiTokUsed?: number}[]) => ({ 94 tok: testResults.reduce((s, r) => s + (r.aiTokUsed ?? 0), 0), 95 usd: testResults.reduce((s, r) => s + parseFloat(r.aiCostUsd || '0'), 0), 101 singleTestName?: string // partial name match (case-insensitive) 105type RunTestsResult = { 108 testResults: TestResultItem[] 112// Extract a short unique-ish fragment from test name for --test hint 113// "payment detection: captures sender_addr" → "payment.captures" 114// "getFullEpImportTree-returns-paths-for-registered-ep" → "returns-paths" 115export const getTestFragment = (name: string): string => { 116 const colonIdx = name.indexOf(':') 118 const prefix = name.slice(0, colonIdx).split(/\s+/)[0] 119 const desc = name.slice(colonIdx + 1).trim().split(/\s+/)[0] 120 return `${prefix}.${desc}` 122 const segs = name.split('-') 123 if (segs.length >= 3) return segs.slice(1, 3).join('-') 124 return segs[0] || name.slice(0, 12) 127// Format single test line: " idx/total PASS --test=suite:fragment # fullTestName msg" 128export const formatTestLine = (opts: {idx: number, total: number, passed: boolean, skipped?: boolean, suiteName?: string, testName: string, msg?: string, repoWideState?: boolean, extDep?: boolean}): string => { 129 const {idx, total, passed, skipped, suiteName, testName, msg, repoWideState, extDep} = opts 131 const fragment = getTestFragment(testName) 132 const testFlag = suiteName ? `--test=${suiteName}:${fragment}` : `--test=${fragment}` 133 const tag = repoWideState ? '[repoWideState]' : extDep ? '[extDep]' : '' 134 const marker = !passed && tag ? ` ${tag}` : '' 135 const comment = msg ? `# ${testName} ->${marker} ${msg}` : `# ${testName}` 136 return ` ${idx}/${total} ${icon} ${testFlag} ${chalkGray(comment)}` 139// Print verbose test results to console 140export const printVerboseResults = (results: TestResultItem[], opts: {singleIdx?: number, totalTests?: number, suiteName?: string, repoWideState?: boolean} = {}) => { 141 const {singleIdx, totalTests, suiteName, repoWideState} = opts 142 const n = totalTests ?? (singleIdx !== undefined ? 1 : results.length) 143 results.forEach((r) => { 144 const displayIdx = singleIdx !== undefined ? 1 : r.idx + 1 145 console.log(formatTestLine({idx: displayIdx, total: n, passed: r.passed, skipped: r.skipped, suiteName, testName: r.name, msg: r.msg, repoWideState: r.repoWideState ?? repoWideState, extDep: r.extDep})) 149// Normalize test name for matching: "." matches ": " or ":" (so fragment "gm.creates" matches "gm: creates") 150const normalizeForMatch = (s: string) => s.toLowerCase().replace(/:\s*/g, '.') 152const resolveTestIdx = (tests: Test[], singleTestName?: string): number | undefined => { 153 if (!singleTestName) return undefined 154 const needle = normalizeForMatch(singleTestName) 155 const matches = tests.map((t, i) => ({i, name: t.name})).filter(t => normalizeForMatch(t.name).includes(needle)) 156 throwIf(() => matches.length === 0, {singleTestName, available: tests.map(t => t.name)}) 157 throwIf(() => matches.length > 1, {singleTestName, matches: matches.map(m => m.name)}) 161export const runTestsWithProgress = async ({ tests, suiteName, singleTestName, failFast }: RunTestsOpts): Promise<RunTestsResult> => { 162 const singleTestIdx = resolveTestIdx(tests, singleTestName) 164 const testResults: TestResultItem[] = [] 165 for (let i = 0; i < tests.length; i++) { 166 if (singleTestIdx !== undefined && i !== singleTestIdx) continue 168 const { name, fn, repoWideState, extDep } = tests[i] 170 let passed: boolean, msg: string, skipped: boolean | undefined, ethGasSpent: string | undefined, aiCostUsd: string | undefined, aiTokUsed: number | undefined 171 if (skipExtDep && extDep) { 172 passed = true; msg = 'extDep (skipped: --skipExtDep)'; skipped = true 175 const result = await fn() 176 passed = result.passed 178 skipped = result.skipped 179 ethGasSpent = result.ethGasSpent 180 aiCostUsd = result.aiCostUsd 181 aiTokUsed = result.aiTokUsed 184 msg = err.uniqDebugH ? `${err.message}: ${JSON.stringify(err.uniqDebugH)}` : err.message 187 testResults.push({ idx: i, name, passed, msg, skipped, ethGasSpent, aiCostUsd, aiTokUsed, repoWideState, extDep }) 188 printVerboseResults([testResults.at(-1)!], {singleIdx: singleTestIdx, totalTests: tests.length, suiteName, repoWideState}) 189 if (failFast !== false && !passed) break 191 const passedCount = testResults.filter(r => r.passed).length 192 const failedTest = testResults.find(r => !r.passed) 193 return { passed: testResults.every(r => r.passed), message: `${passedCount}/${testResults.length} ${suiteName} tests passed`, testResults, failedAt: failedTest?.idx } 196export const recordSuiteResult = ({ep, ptenv, suiteName, passed, message, startTime}: { 197 ep: string, ptenv: Ptenv, suiteName: string, passed: boolean, message: string, startTime: number 199 recordTestResult({ep, ptenv, ranSuites: {[suiteName]: {passed, message}}, durationSec: Math.round((Date.now() - startTime) / 1000)}) 202export const withStubFetch = async <T,>(stub: (url: string, init: any) => any, fn: () => Promise<T>): Promise<T> => { 203 const orig = globalThis.fetch 204 globalThis.fetch = stub as any 205 try { return await fn() } finally { globalThis.fetch = orig } 208export const runTestsStandalone = async ({tests, suiteName, ep, failFast}: { 209 tests: Test[], suiteName: string, ep: string, failFast?: boolean 214 const startTime = Date.now() 215 const result = await runTestsWithProgress({tests, suiteName, singleTestName, failFast}) 216 const aiTot = sumAiUsage(result.testResults) 217 if (aiTot.tok > 0) console.log(chalkGray(`${suiteName} ai: ${aiTot.tok} tok, $${aiTot.usd.toFixed(4)}`)) 218 recordSuiteResult({ep, ptenv: 'testlocal' as Ptenv, suiteName, passed: result.passed, message: result.message, startTime}) 219 if (!result.passed) { 221 const firstFailTest = result.testResults.find(t => !t.passed) 222 const testArg = firstFailTest ? `${suiteName}:${getTestFragment(firstFailTest.name)}` : undefined 223 if (lastGoodSha && !process.cwd().includes(bisectWorktreePrefix)) console.log(chalkYellow(`HINT: To bisect: ${bisectHintCmd(epRel, lastGoodSha, testArg)}`))