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-static-data.mjs']); mkdirSync(screenshotDir, { recursive: true }); serverProcess = await ensurePreviewServer(targetUrl); runCommand(process.execPath, ['scripts/verify-save-retry-flow.mjs'], { VERIFY_URL: 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, env = {}) { const result = spawnSync(command, args, { cwd: process.cwd(), env: { ...process.env, ...env }, stdio: 'inherit' }); if (result.status !== 0) { throw new Error(`Command failed: ${command} ${args.join(' ')}`); } }