perf: stream story assets by page
This commit is contained in:
@@ -1,112 +1,102 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { dirname } from 'node:path';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
import { desktopBrowserContextOptions } from './desktop-browser-viewport.mjs';
|
||||
import {
|
||||
desktopBrowserContextOptions,
|
||||
desktopBrowserDeviceScaleFactor,
|
||||
desktopBrowserViewport
|
||||
} from './desktop-browser-viewport.mjs';
|
||||
|
||||
const targetUrl = withQaParams(process.env.PUBLIC_QA_URL ?? 'https://comtropy.synology.me/heros_web/');
|
||||
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|failed|map|missing|portrait|sprite|story|texture|unit/i;
|
||||
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({ headless: true });
|
||||
const browser = await chromium.launch(chromiumLaunchOptions);
|
||||
|
||||
try {
|
||||
const page = await browser.newPage(desktopBrowserContextOptions);
|
||||
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'
|
||||
});
|
||||
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 page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
|
||||
await page.waitForFunction(() => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
return Boolean(canvas && canvas.width === 1920 && canvas.height === 1080);
|
||||
}, undefined, { timeout: 90000 });
|
||||
await waitForGameCanvas(page);
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
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 })}`);
|
||||
|
||||
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 === 1920 && state.canvas?.height === 1080, `Expected FHD 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 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)}`);
|
||||
|
||||
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,
|
||||
@@ -192,28 +182,200 @@ try {
|
||||
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)}`);
|
||||
assertPageDiagnostics('WebGL public deploy', webglDiagnostics);
|
||||
assertPageDiagnostics('Canvas compatibility deploy', canvasDiagnostics);
|
||||
|
||||
console.log(`Verified public deploy at ${targetUrl}`);
|
||||
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}`);
|
||||
console.log(`Captured ${screenshotPath} and ${canvasScreenshotPath}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
function withQaParams(url) {
|
||||
function withQaParams(url, renderer) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('renderer', 'canvas');
|
||||
parsed.searchParams.set('v', process.env.PUBLIC_QA_VERSION ?? String(Date.now()));
|
||||
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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user