Files
heros_web/scripts/verify-release-candidate.mjs

477 lines
19 KiB
JavaScript

import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';
const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/');
const screenshotDir = 'dist';
let serverProcess;
let browser;
try {
runCommand('node', ['scripts/verify-campaign-flow-data.mjs']);
runCommand('node', ['scripts/verify-battle-scenario-data.mjs']);
mkdirSync(screenshotDir, { recursive: true });
serverProcess = await ensurePreviewServer(targetUrl);
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const consoleMessages = [];
page.on('console', (message) => {
consoleMessages.push({ type: message.type(), text: message.text() });
});
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => window.localStorage.clear());
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await page.screenshot({ path: `${screenshotDir}/rc-title-first-run.png`, fullPage: true });
const emptySave = await readCampaignSave(page);
assert(!emptySave.current && !emptySave.slot1, `Expected first run without campaign save: ${JSON.stringify(emptySave)}`);
await page.mouse.click(962, 240);
await waitForStoryReady(page);
await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true });
await advanceUntilBattle(page, 'first-battle-zhuo-commandery');
await page.screenshot({ path: `${screenshotDir}/rc-first-battle.png`, fullPage: true });
const firstBattleProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle();
const sideTexts = textValues(scene?.sidePanelObjects);
return {
battleId: state?.battleId,
objectiveText: scene?.objectiveTrackerText?.text,
objectiveSubText: scene?.objectiveTrackerSubText?.text,
sideTexts
};
function textValues(objects = []) {
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
}
});
assert(firstBattleProbe.battleId === 'first-battle-zhuo-commandery', `Expected first battle: ${JSON.stringify(firstBattleProbe)}`);
assert(firstBattleProbe.objectiveText?.startsWith('승리 목표:'), `Expected clear victory objective label: ${JSON.stringify(firstBattleProbe)}`);
assert(firstBattleProbe.objectiveSubText?.includes('패배 조건:'), `Expected clear defeat condition label: ${JSON.stringify(firstBattleProbe)}`);
assert(firstBattleProbe.objectiveSubText?.includes('진군:'), `Expected tactical route in objective tracker: ${JSON.stringify(firstBattleProbe)}`);
assert(
firstBattleProbe.sideTexts.some((text) => text.includes('작전 목표')) &&
!firstBattleProbe.sideTexts.some((text) => text.includes('...')),
`Expected opening tactical message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
);
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
await waitForBattleOutcome(page, 'victory');
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-result.png`, fullPage: true });
const resultProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle();
return {
outcome: state?.battleOutcome,
resultTexts: textValues(scene?.resultObjects)
};
function textValues(objects = []) {
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
}
});
assert(resultProbe.outcome === 'victory', `Expected forced victory result: ${JSON.stringify(resultProbe)}`);
assert(resultProbe.resultTexts.includes('목표 정산'), `Expected result objective settlement title: ${JSON.stringify(resultProbe.resultTexts)}`);
assert(resultProbe.resultTexts.some((text) => text.includes('목표 보상')), `Expected reward panel to name objective rewards: ${JSON.stringify(resultProbe.resultTexts)}`);
assert(!resultProbe.resultTexts.some((text) => text.includes('미달')), `Expected result screen to avoid harsh optional-goal wording: ${JSON.stringify(resultProbe.resultTexts)}`);
await page.mouse.click(738, 642);
await waitForCampAfterBattleResult(page);
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
const firstCampProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
return {
state: window.__HEROS_DEBUG__?.camp(),
summary: scene?.reportSummary?.(),
texts: textValues(scene?.contentObjects)
};
function textValues(objects = []) {
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
}
});
assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`);
assert(firstCampProbe.summary?.includes('보유 군자금'), `Expected camp summary to show held gold: ${JSON.stringify(firstCampProbe)}`);
assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`);
await seedCampaignSave(page, {
battleId: 'fifty-eighth-battle-qishan-retreat',
battleTitle: '기산 후퇴로',
step: 'fifty-eighth-camp',
gold: 7400,
turnNumber: 13,
defeatedEnemies: 16,
selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'huang-quan', 'ma-liang', 'ma-dai']
});
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await page.mouse.click(962, 310);
await waitForCamp(page);
await page.screenshot({ path: `${screenshotDir}/rc-mid-campaign-continue.png`, fullPage: true });
const midCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(midCampState?.campaign?.step === 'fifty-eighth-camp', `Expected mid-campaign save to continue into camp: ${JSON.stringify(midCampState)}`);
assert(midCampState.campBattleId === 'fifty-eighth-battle-qishan-retreat', `Expected mid-campaign battle id: ${JSON.stringify(midCampState)}`);
assert(midCampState.sortiePlan?.selectedCount > 0, `Expected mid-campaign formation state: ${JSON.stringify(midCampState?.sortiePlan)}`);
await seedCampaignSave(page, {
battleId: 'sixty-sixth-battle-wuzhang-final',
battleTitle: '오장원 최종전',
step: 'sixty-sixth-camp',
gold: 9800,
turnNumber: 18,
defeatedEnemies: 20,
selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan']
});
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await page.mouse.click(962, 310);
await waitForCamp(page);
const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`);
await page.screenshot({ path: `${screenshotDir}/rc-final-camp.png`, fullPage: true });
await page.mouse.click(1160, 38);
const finalTransitionScenes = await waitForStoryOrEnding(page);
if (finalTransitionScenes.includes('StoryScene')) {
await waitForStoryReady(page);
await advanceStoryUntilEnding(page);
}
await waitForEnding(page);
await page.screenshot({ path: `${screenshotDir}/rc-ending.png`, fullPage: true });
const endingState = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.());
assert(endingState?.campaignStep === 'ending-complete', `Expected ending-complete state: ${JSON.stringify(endingState)}`);
assert(endingState.latestBattleId === 'sixty-sixth-battle-wuzhang-final', `Expected Wuzhang final as latest battle: ${JSON.stringify(endingState)}`);
await page.keyboard.press('Enter');
await waitForTitle(page);
await page.mouse.click(962, 310);
await waitForEnding(page);
await page.screenshot({ path: `${screenshotDir}/rc-ending-continue.png`, fullPage: true });
const endingContinueState = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
ending: window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.()
}));
assert(
endingContinueState.activeScenes.includes('EndingScene') && endingContinueState.ending?.campaignStep === 'ending-complete',
`Expected title continue to reopen ending: ${JSON.stringify(endingContinueState)}`
);
const errors = consoleMessages.filter((message) => message.type === 'error');
assert(errors.length === 0, `Unexpected browser console errors: ${JSON.stringify(errors)}`);
console.log(`Verified release-candidate user flow at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
}
function withDebugParam(url) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', 'canvas');
return parsed.toString();
}
async function waitForTitle(page) {
await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('TitleScene');
}, undefined, { timeout: 90000 });
}
async function waitForStoryReady(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
return activeScenes.includes('StoryScene') && story?.ready === true;
}, undefined, { timeout: 90000 });
}
async function waitForStoryOrEnding(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('StoryScene') || activeScenes.includes('EndingScene');
}, undefined, { timeout: 30000 });
return page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
}
async function advanceUntilBattle(page, battleId) {
for (let i = 0; i < 48; i += 1) {
const ready = await page.evaluate((expectedBattleId) => {
const state = window.__HEROS_DEBUG__?.battle();
return (
state?.scene === 'BattleScene' &&
state?.battleId === expectedBattleId &&
(state?.phase === 'deployment' || state?.phase === 'idle') &&
state?.mapBackgroundReady === true
);
}, battleId);
if (ready) {
await startDeploymentIfNeeded(page, battleId);
return;
}
await page.keyboard.press('Space');
await page.waitForTimeout(220);
}
await waitForBattleReady(page, battleId);
}
async function waitForBattleReady(page, battleId) {
await page.waitForFunction((expectedBattleId) => {
const state = window.__HEROS_DEBUG__?.battle();
return (
state?.scene === 'BattleScene' &&
state?.battleId === expectedBattleId &&
(state?.phase === 'deployment' || state?.phase === 'idle') &&
state?.mapBackgroundReady === true
);
}, battleId, { timeout: 90000 });
await startDeploymentIfNeeded(page, battleId);
}
async function startDeploymentIfNeeded(page, battleId) {
const state = await page.evaluate((expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === expectedBattleId ? battle : undefined;
}, battleId);
if (state?.phase !== 'deployment') {
return;
}
assert(state.deployedAllyIds?.length >= 3, `Expected deployment to show the selected ally formation: ${JSON.stringify(state)}`);
await page.mouse.click(1085, 637);
await page.waitForFunction((expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === expectedBattleId && battle?.phase === 'idle' && battle?.triggeredBattleEvents?.includes('opening');
}, battleId, { timeout: 30000 });
}
async function waitForBattleOutcome(page, outcome) {
await page.waitForFunction((expectedOutcome) => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.battleOutcome === expectedOutcome && state?.phase === 'resolved' && state?.resultVisible === true;
}, outcome, { timeout: 90000 });
}
async function waitForCamp(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
const scene = window.__HEROS_DEBUG__?.scene('CampScene');
return (
activeScenes.includes('CampScene') &&
window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' &&
(scene?.contentObjects?.length ?? 0) > 0
);
}, undefined, { timeout: 90000 });
}
async function waitForCampAfterBattleResult(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene');
}, undefined, { timeout: 90000 });
for (let i = 0; i < 48; i += 1) {
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
if (activeScenes.includes('CampScene')) {
await waitForCamp(page);
return;
}
await page.keyboard.press('Space');
await page.waitForTimeout(240);
}
await waitForCamp(page);
}
async function advanceStoryUntilEnding(page) {
for (let i = 0; i < 48; i += 1) {
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
if (activeScenes.includes('EndingScene')) {
return;
}
await page.keyboard.press('Space');
await page.waitForTimeout(240);
}
}
async function waitForEnding(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
const ending = window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.();
return activeScenes.includes('EndingScene') && ending?.ready === true && ending?.campaignStep === 'ending-complete';
}, undefined, { timeout: 90000 });
}
async function readCampaignSave(page) {
return page.evaluate(() => {
const read = (key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : undefined;
};
return {
current: read('heros-web:campaign-state'),
slot1: read('heros-web:campaign-state:slot-1')
};
});
}
async function seedCampaignSave(page, options) {
await page.evaluate((seed) => {
const storageKey = 'heros-web:campaign-state';
const current = JSON.parse(window.localStorage.getItem(storageKey) ?? 'null');
if (!current?.firstBattleReport) {
throw new Error('Expected an existing campaign report before seeding RC save.');
}
const now = new Date().toISOString();
const objectives = [
{ id: 'leader', label: '지휘관 격파', achieved: true, status: 'done', detail: '전장 목표 완료', rewardGold: 420 },
{ id: 'survive', label: '본대 보존', achieved: true, status: 'done', detail: '핵심 장수 생존', rewardGold: 260 },
{ id: 'supply', label: '보급선 유지', achieved: true, status: 'done', detail: '수레와 군량 확보', rewardGold: 220 },
{ id: 'quick', label: '빠른 정리', achieved: false, status: 'failed', detail: `${seed.turnNumber}/12턴`, rewardGold: 180 }
];
const units = current.roster?.length ? current.roster : current.firstBattleReport.units;
const bonds = current.bonds?.length ? current.bonds : current.firstBattleReport.bonds;
const report = {
...current.firstBattleReport,
battleId: seed.battleId,
battleTitle: seed.battleTitle,
outcome: 'victory',
turnNumber: seed.turnNumber,
rewardGold: 1080,
defeatedEnemies: seed.defeatedEnemies,
totalEnemies: seed.defeatedEnemies,
objectives,
units,
bonds,
mvp: current.firstBattleReport.mvp ?? { unitId: 'zhuge-liang', name: '제갈량', damageDealt: 320, defeats: 2 },
itemRewards: ['상처약 2', '탁주 1'],
completedCampDialogues: current.completedCampDialogues ?? [],
completedCampVisits: current.completedCampVisits ?? [],
createdAt: now
};
const settlement = {
battleId: report.battleId,
battleTitle: report.battleTitle,
outcome: 'victory',
rewardGold: report.rewardGold,
itemRewards: report.itemRewards,
objectives,
units: units.map((unit) => ({
unitId: unit.id,
name: unit.name,
level: unit.level ?? 1,
exp: unit.exp ?? 0,
hp: unit.hp ?? unit.maxHp ?? 1,
maxHp: unit.maxHp ?? 1,
equipment: unit.equipment
})),
bonds: bonds.map((bond) => ({
id: bond.id,
title: bond.title,
level: bond.level ?? 1,
exp: bond.exp ?? 0,
battleExp: bond.battleExp ?? 0
})),
reserveTraining: [],
completedAt: now
};
const next = {
...current,
updatedAt: now,
step: seed.step,
activeSaveSlot: 1,
gold: seed.gold,
inventory: { ...(current.inventory ?? {}), : 12, 상처약: 6, 탁주: 4 },
selectedSortieUnitIds: seed.selectedSortieUnitIds,
latestBattleId: seed.battleId,
firstBattleReport: report,
battleHistory: {
...(current.battleHistory ?? {}),
[seed.battleId]: settlement
}
};
window.localStorage.setItem(storageKey, JSON.stringify(next));
window.localStorage.setItem(`${storageKey}:slot-1`, JSON.stringify(next));
}, options);
}
async function ensurePreviewServer(url) {
if (await canReach(url)) {
return undefined;
}
const parsed = new URL(url);
const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname);
if (!isLocal) {
throw new Error(`No server responded at ${url}`);
}
const stderr = [];
const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', parsed.port || '4173'], {
cwd: process.cwd(),
env: process.env,
stdio: ['ignore', 'pipe', 'pipe']
});
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
child.stdout.on('data', () => {});
for (let i = 0; i < 120; i += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
child.kill();
throw new Error(`Vite preview did not start at ${url}\n${stderr.join('')}`);
}
async function canReach(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
return response.ok;
} catch {
return false;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function runCommand(command, args) {
const result = spawnSync(command, args, { cwd: process.cwd(), stdio: 'inherit' });
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
}