import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { chromium } from 'playwright'; const targetUrl = withQaParams(process.env.PUBLIC_QA_URL ?? 'https://comtropy.synology.me/heros_web/'); const screenshotPath = process.env.PUBLIC_QA_SCREENSHOT ?? 'dist/public-deploy-qa.png'; const expectedQaVersion = process.env.PUBLIC_QA_VERSION; const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138'; const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprite|story|texture|unit/i; mkdirSync(dirname(screenshotPath), { recursive: true }); const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); const consoleMessages = []; const pageErrors = []; const requestFailures = []; page.on('console', (message) => { consoleMessages.push({ type: message.type(), text: message.text() }); }); page.on('pageerror', (error) => { pageErrors.push(error.message); }); page.on('requestfailed', (request) => { requestFailures.push({ url: request.url(), type: request.resourceType(), failure: request.failure()?.errorText ?? 'unknown' }); }); await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); await page.waitForFunction(() => { const canvas = document.querySelector('canvas'); return Boolean(canvas && canvas.width >= 960 && canvas.height >= 540); }, undefined, { timeout: 90000 }); await page.waitForTimeout(3000); await page.screenshot({ path: screenshotPath, fullPage: true }); const state = await page.evaluate(() => { const canvas = document.querySelector('canvas'); return { title: document.title, canvasCount: document.querySelectorAll('canvas').length, debugApiPresent: Boolean(window.__HEROS_DEBUG__), gameApiPresent: Boolean(window.__HEROS_GAME__), canvas: canvas ? { width: canvas.width, height: canvas.height, clientWidth: canvas.clientWidth, clientHeight: canvas.clientHeight, pixelProbe: sampleCanvasPixels(canvas) } : undefined }; function sampleCanvasPixels(canvas) { const context = canvas.getContext('2d', { willReadFrequently: true }); if (!context) { return { readable: false, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 }; } const stepX = Math.max(1, Math.floor(canvas.width / 64)); const stepY = Math.max(1, Math.floor(canvas.height / 64)); const colorBuckets = new Set(); let sampleCount = 0; let nonTransparentCount = 0; for (let y = Math.floor(stepY / 2); y < canvas.height; y += stepY) { for (let x = Math.floor(stepX / 2); x < canvas.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 }; } }); assert(state.title === expectedTitle, `Expected deployed page title "${expectedTitle}": ${JSON.stringify(state)}`); assert(state.canvasCount === 1, `Expected exactly one game canvas: ${JSON.stringify(state)}`); assert(state.debugApiPresent === false, `Expected public deploy to keep debug API disabled: ${JSON.stringify(state)}`); assert(state.gameApiPresent === false, `Expected public deploy to keep game handle disabled: ${JSON.stringify(state)}`); assert(state.canvas?.width >= 960 && state.canvas?.height >= 540, `Expected desktop-size game canvas: ${JSON.stringify(state)}`); assert(state.canvas?.pixelProbe?.readable === true, `Expected readable canvas pixels: ${JSON.stringify(state)}`); assert(state.canvas.pixelProbe.sampleCount >= 500, `Expected enough canvas pixel samples: ${JSON.stringify(state)}`); assert(state.canvas.pixelProbe.nonTransparentRatio > 0.5, `Expected non-empty canvas pixels: ${JSON.stringify(state)}`); assert(state.canvas.pixelProbe.distinctColorBuckets >= 12, `Expected visually varied canvas pixels: ${JSON.stringify(state)}`); 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)}`); if (expectedQaVersion) { assert( releaseManifest.qaVersion === expectedQaVersion, `Expected deployed release manifest qaVersion ${expectedQaVersion}: ${JSON.stringify(releaseManifest)}` ); } const relevantLogs = consoleMessages.filter( (message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text)) ); const blockingRequestFailures = requestFailures.filter((request) => request.failure !== 'net::ERR_ABORTED'); assert(relevantLogs.length === 0, `Unexpected public deploy console issues: ${JSON.stringify(relevantLogs, null, 2)}`); assert(pageErrors.length === 0, `Unexpected public deploy page errors: ${JSON.stringify(pageErrors, null, 2)}`); assert(blockingRequestFailures.length === 0, `Unexpected public deploy request failures: ${JSON.stringify(blockingRequestFailures, null, 2)}`); console.log(`Verified public deploy at ${targetUrl}`); console.log(`Verified release manifest ${releaseManifest.shortCommit ?? releaseManifest.commit} (${releaseManifest.qaVersion ?? 'no qaVersion'})`); console.log(`Captured ${screenshotPath}`); } finally { await browser.close(); } function withQaParams(url) { const parsed = new URL(url); parsed.searchParams.set('renderer', 'canvas'); parsed.searchParams.set('v', process.env.PUBLIC_QA_VERSION ?? String(Date.now())); return parsed.toString(); } function isWarning(type) { return type === 'warning' || type === 'warn'; } async function readReleaseManifest(pageUrl) { const manifestUrl = new URL('release-manifest.json', pageUrl); manifestUrl.searchParams.set('v', expectedQaVersion ?? String(Date.now())); const response = await fetch(manifestUrl, { signal: AbortSignal.timeout(90000) }); assert(response.ok, `Expected deployed release manifest at ${manifestUrl.href}: ${response.status} ${response.statusText}`); return response.json(); } function assert(condition, message) { if (!condition) { throw new Error(message); } }