import { mkdirSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { basename, dirname, extname, join } from 'node:path'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const qaCacheVersion = process.env.PUBLIC_QA_VERSION ?? String(Date.now()); const publicBaseUrl = process.env.PUBLIC_QA_URL ?? 'https://comtropy.synology.me/heros_web/'; const targetUrl = withQaParams(publicBaseUrl, 'webgl'); const canvasTargetUrl = withQaParams(publicBaseUrl, 'canvas'); const screenshotPath = process.env.PUBLIC_QA_SCREENSHOT ?? 'dist/public-deploy-qa.png'; const canvasScreenshotPath = process.env.PUBLIC_QA_CANVAS_SCREENSHOT ?? companionScreenshotPath(screenshotPath); const expectedQaVersion = process.env.PUBLIC_QA_VERSION; const expectedCommit = process.env.PUBLIC_QA_COMMIT ?? currentGitCommit(); const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138'; const relevantLogPattern = /asset|audio|cannot|context|failed|map|missing|portrait|renderer|sprite|story|texture|unit|webgl/i; const chromiumLaunchOptions = Object.freeze({ headless: true, args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); mkdirSync(dirname(screenshotPath), { recursive: true }); mkdirSync(dirname(canvasScreenshotPath), { recursive: true }); const browser = await chromium.launch(chromiumLaunchOptions); try { const context = await browser.newContext(desktopBrowserContextOptions); const page = await context.newPage(); await page.addInitScript(() => { window.__HEROS_WEBGL_QA_EVENTS__ = []; window.addEventListener( 'webglcontextlost', (event) => { window.__HEROS_WEBGL_QA_EVENTS__.push({ type: event.type, at: performance.now() }); }, true ); window.addEventListener( 'webglcontextrestored', (event) => { window.__HEROS_WEBGL_QA_EVENTS__.push({ type: event.type, at: performance.now() }); }, true ); }); const webglDiagnostics = observePage(page); await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForGameCanvas(page); await page.waitForTimeout(3000); const webglScreenshot = await page.screenshot({ path: screenshotPath, fullPage: true }); const webglState = await readWebglState(page); assertPublicShellState(webglState, 'WebGL'); assert(webglState.requestedRenderer === 'webgl', `Expected the public URL to request WebGL: ${JSON.stringify(webglState)}`); assert( webglState.webgl?.contextType === 'webgl' || webglState.webgl?.contextType === 'webgl2', `Expected an active public WebGL context: ${JSON.stringify(webglState)}` ); assert( webglState.webgl.drawingBufferWidth === desktopBrowserViewport.width && webglState.webgl.drawingBufferHeight === desktopBrowserViewport.height, `Expected an FHD public WebGL drawing buffer: ${JSON.stringify(webglState)}` ); assert(webglState.webgl.contextLost === false, `Expected the public WebGL context to remain active: ${JSON.stringify(webglState)}`); assert(webglState.webgl.errorCode === webglState.webgl.noErrorCode, `Expected no public WebGL error flag: ${JSON.stringify(webglState)}`); assert(webglState.webglEvents.length === 0, `Expected no public WebGL context loss/restoration events: ${JSON.stringify(webglState)}`); assert(webglScreenshot.byteLength >= 100_000, `Expected a visibly painted public WebGL frame: ${JSON.stringify({ byteLength: webglScreenshot.byteLength })}`); await page.close(); const canvasPage = await context.newPage(); const canvasDiagnostics = observePage(canvasPage); await canvasPage.goto(canvasTargetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForGameCanvas(canvasPage); await canvasPage.waitForTimeout(3000); await canvasPage.screenshot({ path: canvasScreenshotPath, fullPage: true }); const canvasState = await readCanvasState(canvasPage); assertPublicShellState(canvasState, 'Canvas'); assert(canvasState.requestedRenderer === 'canvas', `Expected the compatibility URL to request Canvas: ${JSON.stringify(canvasState)}`); assert(canvasState.canvas?.pixelProbe?.readable === true, `Expected readable Canvas pixels: ${JSON.stringify(canvasState)}`); assert(canvasState.canvas.pixelProbe.sampleCount >= 500, `Expected enough Canvas pixel samples: ${JSON.stringify(canvasState)}`); assert(canvasState.canvas.pixelProbe.nonTransparentRatio > 0.5, `Expected non-empty Canvas pixels: ${JSON.stringify(canvasState)}`); assert(canvasState.canvas.pixelProbe.distinctColorBuckets >= 12, `Expected visually varied Canvas pixels: ${JSON.stringify(canvasState)}`); await canvasPage.close(); const releaseManifest = await readReleaseManifest(targetUrl); assert(releaseManifest.app === 'heros_web', `Expected deployed release manifest app id: ${JSON.stringify(releaseManifest)}`); assert(typeof releaseManifest.commit === 'string' && releaseManifest.commit.length >= 7, `Expected deployed release manifest commit: ${JSON.stringify(releaseManifest)}`); assert(typeof releaseManifest.generatedAt === 'string' && releaseManifest.generatedAt.length > 0, `Expected deployed release manifest timestamp: ${JSON.stringify(releaseManifest)}`); assert( releaseManifest.renderingGate?.requiredRenderer === 'webgl' && releaseManifest.renderingGate?.compatibilityRenderer === 'canvas' && releaseManifest.renderingGate?.viewport?.width === desktopBrowserViewport.width && releaseManifest.renderingGate?.viewport?.height === desktopBrowserViewport.height && releaseManifest.renderingGate?.viewport?.deviceScaleFactor === desktopBrowserDeviceScaleFactor, `Expected the deployed release manifest to require FHD DPR 1 WebGL with Canvas compatibility: ${JSON.stringify(releaseManifest)}` ); if (expectedQaVersion) { assert( releaseManifest.qaVersion === expectedQaVersion, `Expected deployed release manifest qaVersion ${expectedQaVersion}: ${JSON.stringify(releaseManifest)}` ); } if (expectedCommit) { assert( releaseManifest.commit === expectedCommit, `Expected deployed release manifest commit ${expectedCommit}: ${JSON.stringify(releaseManifest)}` ); } if (releaseManifest.qaReport) { const qaReport = await readJsonAsset(targetUrl, releaseManifest.qaReport); assert(qaReport.app === 'heros_web', `Expected deployed QA report app id: ${JSON.stringify(qaReport)}`); assert(Array.isArray(qaReport.results) && qaReport.results.length > 0, `Expected deployed QA report results: ${JSON.stringify(qaReport)}`); assert(typeof qaReport.generatedAt === 'string' && qaReport.generatedAt.length > 0, `Expected deployed QA report timestamp: ${JSON.stringify(qaReport)}`); console.log(`Verified QA report ${releaseManifest.qaReport} with ${qaReport.results.length} battles`); } let deployedQaRepeatReport; if (releaseManifest.qaRepeatReport) { const qaRepeatReport = await readJsonAsset(targetUrl, releaseManifest.qaRepeatReport); deployedQaRepeatReport = qaRepeatReport; assert(qaRepeatReport.app === 'heros_web', `Expected deployed QA repeat report app id: ${JSON.stringify(qaRepeatReport)}`); assert(Array.isArray(qaRepeatReport.runs) && qaRepeatReport.runs.length > 0, `Expected deployed QA repeat report runs: ${JSON.stringify(qaRepeatReport)}`); assert( typeof qaRepeatReport.summary?.runs === 'number' && qaRepeatReport.summary.runs === qaRepeatReport.runs.length, `Expected deployed QA repeat report run summary: ${JSON.stringify(qaRepeatReport)}` ); for (const run of qaRepeatReport.runs) { const runReportAsset = distAssetPath(run.path); assert(runReportAsset, `Expected repeat QA run ${run.run} to include a report path: ${JSON.stringify(run)}`); const runReport = await readJsonAsset(targetUrl, runReportAsset); assert(runReport.app === 'heros_web', `Expected deployed QA repeat run report app id at ${runReportAsset}: ${JSON.stringify(runReport)}`); assert(Array.isArray(runReport.results) && runReport.results.length > 0, `Expected deployed QA repeat run results at ${runReportAsset}: ${JSON.stringify(runReport)}`); assert( typeof runReport.summary?.victories === 'number' && typeof runReport.summary?.failures === 'number', `Expected deployed QA repeat run summary at ${runReportAsset}: ${JSON.stringify(runReport)}` ); } console.log(`Verified QA repeat report ${releaseManifest.qaRepeatReport} with ${qaRepeatReport.runs.length} runs`); } if (releaseManifest.releaseDashboard) { const dashboard = await readTextAsset(targetUrl, releaseManifest.releaseDashboard); const dashboardText = visibleText(dashboard); assert(dashboard.includes('Heros Web Release QA'), `Expected deployed release dashboard title: ${dashboard.slice(0, 200)}`); assert(dashboard.includes(releaseManifest.shortCommit ?? releaseManifest.commit), `Expected deployed release dashboard commit: ${dashboard.slice(0, 400)}`); assert(dashboard.includes('Repeated Battle Outcomes'), `Expected deployed release dashboard battle outcomes section: ${dashboard.slice(0, 800)}`); assert(dashboard.includes('Process Failures'), `Expected deployed release dashboard process failure metric: ${dashboard.slice(0, 800)}`); assert(dashboard.includes('Objective Misses'), `Expected deployed release dashboard objective miss metric: ${dashboard.slice(0, 800)}`); assert(dashboard.includes('Repeated Objective Misses'), `Expected deployed release dashboard objective miss section: ${dashboard.slice(0, 1200)}`); assert(dashboard.includes('Repeat Runs'), `Expected deployed release dashboard repeat runs section: ${dashboard.slice(0, 800)}`); if (typeof deployedQaRepeatReport?.summary?.totalMissedObjectives === 'number') { assert( dashboardText.includes(`Objective Misses ${deployedQaRepeatReport.summary.totalMissedObjectives}`), `Expected deployed release dashboard objective miss total ${deployedQaRepeatReport.summary.totalMissedObjectives}: ${dashboard.slice(0, 1200)}` ); } for (const miss of deployedQaRepeatReport?.missedObjectives ?? []) { const latest = miss.examples?.at(-1); assert( dashboardText.includes(`${miss.battleNo}`) && dashboardText.includes(miss.objectiveId), `Expected deployed release dashboard missed objective ${miss.battleNo}/${miss.objectiveId}: ${dashboard.slice(0, 1600)}` ); if (latest?.failureReason || miss.failureReason) { const reason = latest?.failureReason ?? miss.failureReason; assert( dashboardText.includes(reason), `Expected deployed release dashboard missed objective reason "${reason}": ${dashboard.slice(0, 1600)}` ); } } if (deployedQaRepeatReport && (deployedQaRepeatReport.missedObjectives ?? []).length === 0) { assert( dashboard.includes('No missed objectives recorded.'), `Expected deployed release dashboard empty objective miss state: ${dashboard.slice(0, 1600)}` ); } for (const run of deployedQaRepeatReport?.runs ?? []) { const runReportAsset = distAssetPath(run.path); assert(dashboard.includes(runReportAsset), `Expected deployed release dashboard to link repeat run report ${runReportAsset}: ${dashboard.slice(0, 1200)}`); } console.log(`Verified release dashboard ${releaseManifest.releaseDashboard}`); } assertPageDiagnostics('WebGL public deploy', webglDiagnostics); assertPageDiagnostics('Canvas compatibility deploy', canvasDiagnostics); console.log(`Verified FHD DPR 1 WebGL public deploy at ${targetUrl}`); console.log(`Verified Canvas pixel compatibility at ${canvasTargetUrl}`); console.log(`Verified release manifest ${releaseManifest.shortCommit ?? releaseManifest.commit} (${releaseManifest.qaVersion ?? 'no qaVersion'})`); console.log(`Captured ${screenshotPath} and ${canvasScreenshotPath}`); } finally { await browser.close(); } function withQaParams(url, renderer) { const parsed = new URL(url); parsed.searchParams.set('renderer', renderer); parsed.searchParams.set('v', qaCacheVersion); return parsed.toString(); } function companionScreenshotPath(filePath) { const extension = extname(filePath) || '.png'; return join(dirname(filePath), `${basename(filePath, extname(filePath))}-canvas${extension}`); } function observePage(page) { const diagnostics = { consoleMessages: [], pageErrors: [], requestFailures: [] }; page.on('console', (message) => { diagnostics.consoleMessages.push({ type: message.type(), text: message.text() }); }); page.on('pageerror', (error) => { diagnostics.pageErrors.push(error.message); }); page.on('requestfailed', (request) => { diagnostics.requestFailures.push({ url: request.url(), type: request.resourceType(), failure: request.failure()?.errorText ?? 'unknown' }); }); return diagnostics; } async function waitForGameCanvas(page) { await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); await page.waitForFunction( ({ width, height }) => { const canvas = document.querySelector('canvas'); return Boolean(canvas && canvas.width === width && canvas.height === height); }, desktopBrowserViewport, { timeout: 90000 } ); } async function readWebglState(page) { return page.evaluate(() => { const canvas = document.querySelector('canvas'); const common = readCommonState(canvas); const gl = canvas?.getContext('webgl') ?? canvas?.getContext('webgl2') ?? null; const webgl2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext; const webgl1 = typeof WebGLRenderingContext !== 'undefined' && gl instanceof WebGLRenderingContext; return { ...common, webgl: gl ? { contextType: webgl2 ? 'webgl2' : webgl1 ? 'webgl' : 'unknown', drawingBufferWidth: gl.drawingBufferWidth, drawingBufferHeight: gl.drawingBufferHeight, contextLost: gl.isContextLost(), errorCode: gl.getError(), noErrorCode: gl.NO_ERROR } : null, webglEvents: window.__HEROS_WEBGL_QA_EVENTS__ ?? [] }; function readCommonState(gameCanvas) { const bounds = gameCanvas?.getBoundingClientRect(); return { title: document.title, requestedRenderer: new URLSearchParams(window.location.search).get('renderer'), canvasCount: document.querySelectorAll('canvas').length, debugApiPresent: Boolean(window.__HEROS_DEBUG__), gameApiPresent: Boolean(window.__HEROS_GAME__), viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio }, canvas: gameCanvas ? { width: gameCanvas.width, height: gameCanvas.height, clientWidth: gameCanvas.clientWidth, clientHeight: gameCanvas.clientHeight, bounds: bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null } : null }; } }); } async function readCanvasState(page) { return page.evaluate(() => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); return { title: document.title, requestedRenderer: new URLSearchParams(window.location.search).get('renderer'), canvasCount: document.querySelectorAll('canvas').length, debugApiPresent: Boolean(window.__HEROS_DEBUG__), gameApiPresent: Boolean(window.__HEROS_GAME__), viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio }, canvas: canvas ? { width: canvas.width, height: canvas.height, clientWidth: canvas.clientWidth, clientHeight: canvas.clientHeight, bounds: bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null, pixelProbe: sampleCanvasPixels(canvas) } : null }; function sampleCanvasPixels(gameCanvas) { const context = gameCanvas.getContext('2d', { willReadFrequently: true }); if (!context) { return { readable: false, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 }; } const stepX = Math.max(1, Math.floor(gameCanvas.width / 64)); const stepY = Math.max(1, Math.floor(gameCanvas.height / 64)); const colorBuckets = new Set(); let sampleCount = 0; let nonTransparentCount = 0; for (let y = Math.floor(stepY / 2); y < gameCanvas.height; y += stepY) { for (let x = Math.floor(stepX / 2); x < gameCanvas.width; x += stepX) { const [r, g, b, a] = context.getImageData(x, y, 1, 1).data; sampleCount += 1; if (a > 8) { nonTransparentCount += 1; } colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`); } } return { readable: true, sampleCount, nonTransparentRatio: sampleCount > 0 ? nonTransparentCount / sampleCount : 0, distinctColorBuckets: colorBuckets.size }; } }); } function assertPublicShellState(state, rendererLabel) { assert(state.title === expectedTitle, `Expected deployed page title "${expectedTitle}" for ${rendererLabel}: ${JSON.stringify(state)}`); assert(state.canvasCount === 1, `Expected exactly one ${rendererLabel} game canvas: ${JSON.stringify(state)}`); assert(state.debugApiPresent === false, `Expected public ${rendererLabel} deploy to keep debug API disabled: ${JSON.stringify(state)}`); assert(state.gameApiPresent === false, `Expected public ${rendererLabel} deploy to keep game handle disabled: ${JSON.stringify(state)}`); assert( state.viewport?.width === desktopBrowserViewport.width && state.viewport?.height === desktopBrowserViewport.height && state.viewport?.deviceScaleFactor === desktopBrowserDeviceScaleFactor, `Expected ${rendererLabel} viewport ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR ${desktopBrowserDeviceScaleFactor}: ${JSON.stringify(state)}` ); assert( state.canvas?.width === desktopBrowserViewport.width && state.canvas?.height === desktopBrowserViewport.height && state.canvas?.clientWidth === desktopBrowserViewport.width && state.canvas?.clientHeight === desktopBrowserViewport.height && state.canvas?.bounds?.width === desktopBrowserViewport.width && state.canvas?.bounds?.height === desktopBrowserViewport.height, `Expected exact FHD ${rendererLabel} game canvas dimensions: ${JSON.stringify(state)}` ); } function assertPageDiagnostics(label, diagnostics) { const relevantLogs = diagnostics.consoleMessages.filter( (message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text)) ); const blockingRequestFailures = diagnostics.requestFailures.filter((request) => request.failure !== 'net::ERR_ABORTED'); assert(relevantLogs.length === 0, `Unexpected ${label} console issues: ${JSON.stringify(relevantLogs, null, 2)}`); assert(diagnostics.pageErrors.length === 0, `Unexpected ${label} page errors: ${JSON.stringify(diagnostics.pageErrors, null, 2)}`); assert(blockingRequestFailures.length === 0, `Unexpected ${label} request failures: ${JSON.stringify(blockingRequestFailures, null, 2)}`); } function isWarning(type) { return type === 'warning' || type === 'warn'; } async function readReleaseManifest(pageUrl) { return readJsonAsset(pageUrl, 'release-manifest.json'); } async function readJsonAsset(pageUrl, assetPath) { const assetUrl = new URL(assetPath, pageUrl); assetUrl.searchParams.set('v', expectedQaVersion ?? String(Date.now())); const response = await fetch(assetUrl, { signal: AbortSignal.timeout(90000) }); assert(response.ok, `Expected deployed JSON asset at ${assetUrl.href}: ${response.status} ${response.statusText}`); return response.json(); } async function readTextAsset(pageUrl, assetPath) { const assetUrl = new URL(assetPath, pageUrl); assetUrl.searchParams.set('v', expectedQaVersion ?? String(Date.now())); const response = await fetch(assetUrl, { signal: AbortSignal.timeout(90000) }); assert(response.ok, `Expected deployed text asset at ${assetUrl.href}: ${response.status} ${response.statusText}`); return response.text(); } function distAssetPath(filePath) { if (!filePath) { return ''; } return String(filePath).replaceAll('\\', '/').replace(/^dist\//, '').replace(/^\/+/, ''); } function visibleText(html) { return String(html).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } function currentGitCommit() { try { return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return undefined; } } function assert(condition, message) { if (!condition) { throw new Error(message); } }