222 lines
11 KiB
JavaScript
222 lines
11 KiB
JavaScript
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 expectedCommit = process.env.PUBLIC_QA_COMMIT;
|
|
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)}`
|
|
);
|
|
}
|
|
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);
|
|
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('Repeat Runs'), `Expected deployed release dashboard repeat runs section: ${dashboard.slice(0, 800)}`);
|
|
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}`);
|
|
}
|
|
|
|
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) {
|
|
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 assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|