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(', ') 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'}], 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) 43 name: 'returnsCorrectPublicIp', 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 } 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)') 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 71// Factory for scaling suite - needs cluster info from appCfg 72export const runScalingSuite = (scalingIntegration: (opts: any) => Promise<{ passed: boolean; message: string }>): SuiteRunner => { 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}] } 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 98 const tests = testPages.map((pagePath) => ({ 99 name: `${pagePath} loads without errors`, 101 const browser = await chromium.launch() 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) 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 125 await page.goto(`${baseUrl}${pagePath}`, { timeout: 15000 }) 126 await page.waitForLoadState('networkidle', { timeout: 15000 }) 128 return { passed: false, msg: `Navigation failed: ${(navErr as Error).message}` } 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' } 137 await browser.close() 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') 151 name: 'kanikoClusterConfigResolves', 154 return { passed: true, msg: `${kanikoCluster} -> ${k8sCloudName}` } 158 name: 'sharedImageEps.produceSameTag', 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(',') 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 ')}` } 175export const buildConfigSuiteConfig: SuiteConfig = { name: 'buildconfig', runner: runBuildConfigSuite, deps: [] }