🌳
pt0/deployF/testsF/genericSuiteRunnersAI.mts
1// Generic test suite runners that can be used by any eptNextjsApp
2import { chromium } from 'playwright'
16import { execSync } from 'child_process'
19export const runPublicIpSuite: SuiteRunner = async ({ ptenv, baseUrl, appCfg }) => {
20 if (ptenv !== ptenvTestprod) return skipSuite('Public IP test only runs with --ptenv=testprod')
22 const ipFeatures = {gqlQuotaCfg: !!appCfg?.envConf?.gqlQuotaCfg}
23 const usesRealIp = Object.values(ipFeatures).some(Boolean)
24 if (!usesRealIp) return skipSuite('No IP-dependent features enabled')
26 if (appCfg?.cfProxied) return skipSuite('CF proxy provides real client IP')
28 if (appCfg?.hasBorkedPublicIp) {
29 const enabledFeatures = Object.keys(ipFeatures).filter(k => ipFeatures[k as keyof typeof ipFeatures]).join(', ')
30 return {
31 passed: false,
32 message: `IP-dependent features (${enabledFeatures}) enabled but cluster has hasBorkedPublicIp and is not cf-proxied — real client IPs unavailable (NAT/SNAT). Enable cfProxied or disable IP-dependent features for this ep.`,
33 testResults: [{idx: 0, name: 'returnsCorrectPublicIp', passed: false, msg: 'hasBorkedPublicIp + IP-dependent features + not cf-proxied'}],
34 }
35 }
37 const {mkTestGqlFetch} = appCfg || {}
38 const testGqlFetch = mkTestGqlFetch ? await mkTestGqlFetch(ptenv) : null
39 const doFetch = testGqlFetch?.fetch ?? gqlFetch
40 const getKey = testGqlFetch?.mapKey ?? ((k: string) => k)
42 const tests = [{
43 name: 'returnsCorrectPublicIp',
44 fn: async () => {
45 const resp = await doFetch({ baseUrl: baseUrl!, query: '{ gqMyIp }', skipTestIp: true })
46 const ip = resp?.data?.[getKey('gqMyIp')]
47 if (!ip) return { passed: false, msg: `No IP returned: ${JSON.stringify(resp?.errors || resp)}` }
48 if (isPrivateIp(ip)) return { passed: false, msg: `Got private IP: ${ip}` }
49 return { passed: true, msg: ip }
50 }
51 }]
52 return runTestsWithProgress({ tests, suiteName: 'publicip' })
55export const publicipSuiteConfig: SuiteConfig = { name: 'publicip', runner: runPublicIpSuite, deps: [] }
57export const runHealthgqlSuite: SuiteRunner = async ({ ptenv, stopServerRef, appCfg }) => {
58 if (appCfg?.skipHealthgql) return skipSuite('healthgql skipped (appCfg.skipHealthgql)')
59 const result = await runHealthgqlWithServer({ ptenv, stopServerRef })
60 if (!result) return { passed: false, message: 'healthgql returned no result', testResults: [] }
61 return logHealthTestResults(result.resultsH, { hostname: result.hostname, gqlPath: result.gqlPath, responsesH: result.responsesH, diagnosticsH: result.diagnosticsH })
64export const runSendRecvSuite: SuiteRunner = async ({ baseUrl, ptenv, singleTestName, appCfg }) => {
65 const {mkTestGqlFetch} = appCfg || {}
66 const testGqlFetch = mkTestGqlFetch ? await mkTestGqlFetch(ptenv) : null
67 const doFetch = testGqlFetch?.fetch ?? gqlFetch
68 return runSendRecvExceptionSuite({ baseUrl: baseUrl!, ptenv, singleTestName, doFetch })
71// Factory for scaling suite - needs cluster info from appCfg
72export const runScalingSuite = (scalingIntegration: (opts: any) => Promise<{ passed: boolean; message: string }>): SuiteRunner => {
73 return async ({ ptenv, appCfg }): Promise<SuiteResult> => {
74 if (ptenv !== ptenvTestprod) return skipSuite('Scaling test only runs with --ptenv=testprod')
75 const { cluster_name, name } = appCfg
76 if (!cluster_name || !name) return skipSuite('Scaling test requires cluster_name and name in appCfg')
77 const result = await scalingIntegration({ baseUrl: appCfg.deployedBaseUrl, cluster_name, name })
78 return { ...result, testResults: [{idx: 0, name: 'scalingIntegration', passed: result.passed, msg: result.message}] }
79 }
82// Generic pages suite - catches JS errors & GraphQL failures on page load
83// Opt-in via appCfg.testPages: string[] (e.g., ['/', '/about', '/beer'])
84export const runPagesSuite: SuiteRunner = async ({ ptenv, appCfg, stopServerRef, singleTestName }) => {
85 const testPages: string[] = appCfg.testPages
86 if (!testPages?.length) return skipSuite('No testPages defined in appCfg')
88 const baseUrl = ptenv === ptenvLocal ? `http://127.0.0.1:${appCfg.testSvcPortNo ?? appCfg.svcPortNo}` : appCfg.deployedBaseUrl
90 if (ptenv === ptenvLocal && !stopServerRef?.current) {
91 appCfgCtx.enterWith(appCfg)
92 const server = await startDevServer({ envH: {}, onStop: clearAllCachedConnections })
93 stopServerRef.current = typeof server === 'function' ? server : server.stop
94 }
98 const tests = testPages.map((pagePath) => ({
99 name: `${pagePath} loads without errors`,
100 fn: async () => {
101 const browser = await chromium.launch()
102 try {
103 const page = await browser.newPage()
104 let jsError: Error | null = null, gqlHadError = false, consoleError: string | null = null
106 page.on('pageerror', (err) => { jsError ||= err })
107 page.on('console', (msg) => {
108 if (msg.type() === 'error') {
109 const text = msg.text()
110 if (text.includes('Invariant') || text.includes('Uncaught') || text.includes('Cannot read prop')) {
111 consoleError ||= text.slice(0, 150)
112 }
113 }
114 })
115 const pendingResponses: Promise<void>[] = []
116 page.on('response', (resp) => {
117 if (resp.url().includes('/api/graphql')) {
118 pendingResponses.push(resp.text().catch(() => '').then(body => {
119 if (body.includes('"errors"')) gqlHadError = true
120 }))
121 }
122 })
124 try {
125 await page.goto(`${baseUrl}${pagePath}`, { timeout: 15000 })
126 await page.waitForLoadState('networkidle', { timeout: 15000 })
127 } catch (navErr) {
128 return { passed: false, msg: `Navigation failed: ${(navErr as Error).message}` }
129 }
131 await Promise.all(pendingResponses)
132 if (jsError) return { passed: false, msg: `JS error: ${(jsError as Error).message}` }
133 if (consoleError) return { passed: false, msg: `Console error: ${consoleError}` }
134 if (gqlHadError) return { passed: false, msg: 'GraphQL response had errors' }
135 return { passed: true, msg: 'OK' }
136 } finally {
137 await browser.close()
138 }
139 }
140 }))
142 return runTestsWithProgress({ tests, suiteName: 'pages', singleTestName })
145export const runBuildConfigSuite: SuiteRunner = async ({ ptenv, appCfg }) => {
146 if (ptenv !== ptenvLocal) return skipSuite('buildconfig only runs with --ptenv=testlocal')
147 if (!appCfg?.cluster_name) return skipSuite('buildconfig requires cluster_name')
149 const tests = [
150 {
151 name: 'kanikoClusterConfigResolves',
152 fn: () => {
153 const {kanikoCluster, k8sCloudName} = validateKanikoBuildConfig()
154 return { passed: true, msg: `${kanikoCluster} -> ${k8sCloudName}` }
155 },
156 },
157 {
158 name: 'sharedImageEps.produceSameTag',
159 fn: () => {
160 const sharedImageEps: string[] | undefined = appCfg?.sharedImageEps
161 if (!sharedImageEps?.length) return { passed: true, msg: 'no sharedImageEps configured' }
162 const tags = sharedImageEps.map(epPath => {
163 const out = execSync(`ptnode ${epPath} dockerfile`, { cwd: ptDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30_000 })
164 const matchA = [...out.matchAll(/# fin .+:(dfc\w+)/g)]
165 return matchA.map(m => m[1]).sort().join(',')
166 })
167 const allSame = tags.every(t => t === tags[0])
168 return { passed: allSame, msg: allSame ? `all ${sharedImageEps.length} eps share tag ${tags[0]}` : `tags differ: ${tags.join(' vs ')}` }
169 },
170 },
171 ]
172 return runTestsWithProgress({ tests, suiteName: 'buildconfig' })
175export const buildConfigSuiteConfig: SuiteConfig = { name: 'buildconfig', runner: runBuildConfigSuite, deps: [] }