🌳
pt0/deployF/testsF/runTestsHelperAI.mts
8import type { Ptenv } from '../../sharedF/ptenvF.mts'
12export const getTestExceptionHeaders = (): Record<string, string> => {
13 const headerName = getTestExceptionHeaderName()
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> => {
21 const headerName = getTestIpHeaderName()
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}: {
27 baseUrl: string
28 query: string
29 variables?: Record<string, unknown>
30 gqUserTok?: string
31 extraHeaders?: Record<string, string>
32 skipTestIp?: boolean
33}) => {
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`, {
37 method: 'POST',
38 headers,
39 body: JSON.stringify({query, variables}),
40 })
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) {
47 await sleep(retryDelayMs)
48 continue
49 }
50 return {errors: [{message: `gqlFetch got ${respContentType || 'empty content-type'} (status ${resp.status})`, extensions: {code: 'NON_JSON_RESPONSE'}}]}
51 }
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)
58 )
60// Generate string of repeated chars (for size limit tests)
61export const genTestStr = (len: number) => 'x'.repeat(len)
63// Test result types
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)
73import type { SuiteRunnerCtx } from './runTestsCoreAI.mts'
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.
79export const skipUnlessSmokeDeploy = (ctx: SuiteRunnerCtx) =>
80 ctx.ptenv === ptenvTestprod && !(ctx.appCfg?.name ?? '').endsWith('-smoke')
81 ? skipSuite('writes test data — smoke deployment only')
82 : null
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})
88 return {tests, test}
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),
96})
98type RunTestsOpts = {
99 tests: Test[]
100 suiteName: string
101 singleTestName?: string // partial name match (case-insensitive)
102 failFast?: boolean
105type RunTestsResult = {
106 passed: boolean
107 message: string
108 testResults: TestResultItem[]
109 failedAt?: number
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(':')
117 if (colonIdx >= 0) {
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}`
121 }
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
130 const icon = skipped ? chalkYellow('SKIP') : passed ? chalkGreen('PASS') : chalkRed('FAIL')
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}))
146 })
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)})
158 return matches[0].i
161export const runTestsWithProgress = async ({ tests, suiteName, singleTestName, failFast }: RunTestsOpts): Promise<RunTestsResult> => {
162 const singleTestIdx = resolveTestIdx(tests, singleTestName)
163 const skipExtDep = cliFlag('--skipExtDep')
164 const testResults: TestResultItem[] = []
165 for (let i = 0; i < tests.length; i++) {
166 if (singleTestIdx !== undefined && i !== singleTestIdx) continue
167 rotateTestIp()
168 const { name, fn, repoWideState, extDep } = tests[i]
169 resetAcornParseCount() // budget is per-operation; tests are independent operations sharing one process
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
173 } else {
174 try {
175 const result = await fn()
176 passed = result.passed
177 msg = result.msg
178 skipped = result.skipped
179 ethGasSpent = result.ethGasSpent
180 aiCostUsd = result.aiCostUsd
181 aiTokUsed = result.aiTokUsed
182 } catch (err: any) {
183 passed = false
184 msg = err.uniqDebugH ? `${err.message}: ${JSON.stringify(err.uniqDebugH)}` : err.message
185 }
186 }
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
190 }
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
198}) => {
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
210}) => {
211 const epRel = toPtRelPath(ep)
212 if (cliFlag('--history')) { printTestHistory({ep: epRel}); return }
213 const singleTestName = cliArg('--test')
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) {
220 const lastGoodSha = getLastPassingSha({ep})
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)}`))
224 process.exit(1)
225 }