1505 lines
38 KiB
JavaScript
1505 lines
38 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { chromium } from 'playwright';
|
|
import {
|
|
desktopBrowserContextOptions,
|
|
desktopBrowserDeviceScaleFactor,
|
|
desktopBrowserViewport,
|
|
desktopBrowserViewportBounds
|
|
} from './desktop-browser-viewport.mjs';
|
|
|
|
const renderer =
|
|
process.env.VERIFY_CAMP_EXPLORATION_CHECKPOINT_RENDERER;
|
|
const rendererNames = ['canvas', 'webgl'];
|
|
const fixture = Object.freeze({
|
|
visitId: 'first-pursuit-scout-tent',
|
|
battleId: 'first-battle-zhuo-commandery',
|
|
campaignStep: 'first-camp',
|
|
actorId: 'jian-yong',
|
|
movementAnchorId: 'zhang-fei',
|
|
choiceId: 'trace-river-ambush',
|
|
itemId: '상처약',
|
|
itemAmount: 1,
|
|
bondId: 'liu-bei__jian-yong',
|
|
bondExp: 10
|
|
});
|
|
|
|
if (!renderer) {
|
|
for (const requestedRenderer of rendererNames) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[fileURLToPath(import.meta.url)],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
VERIFY_CAMP_EXPLORATION_CHECKPOINT_RENDERER:
|
|
requestedRenderer
|
|
},
|
|
stdio: 'inherit'
|
|
}
|
|
);
|
|
if (result.status !== 0) {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
assert(
|
|
rendererNames.includes(renderer),
|
|
`Unsupported camp exploration checkpoint renderer "${renderer}".`
|
|
);
|
|
|
|
const baseUrl =
|
|
process.env.VERIFY_CAMP_EXPLORATION_CHECKPOINT_URL ??
|
|
'http://127.0.0.1:41808/heros_web/';
|
|
const targetUrl = withDebugOptions(baseUrl, renderer);
|
|
const localSeedUrl = withDebugOptions(
|
|
'http://127.0.0.1:41808/heros_web/',
|
|
renderer
|
|
);
|
|
|
|
let serverProcess;
|
|
let seedServerProcess;
|
|
let browser;
|
|
|
|
try {
|
|
mkdirSync('dist', { recursive: true });
|
|
serverProcess = await ensureLocalServer(targetUrl);
|
|
browser = await chromium.launch({
|
|
headless:
|
|
process.env.VERIFY_CAMP_EXPLORATION_CHECKPOINT_HEADLESS !==
|
|
'0',
|
|
args:
|
|
renderer === 'webgl'
|
|
? [
|
|
'--use-angle=swiftshader',
|
|
'--enable-unsafe-swiftshader'
|
|
]
|
|
: []
|
|
});
|
|
const context = await browser.newContext(
|
|
desktopBrowserContextOptions
|
|
);
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30000);
|
|
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
page.on('pageerror', (error) =>
|
|
pageErrors.push(error.stack ?? error.message)
|
|
);
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
|
|
await page.addInitScript(() => {
|
|
const marker =
|
|
'heros-web:qa:camp-exploration-checkpoint-storage-cleared';
|
|
if (window.sessionStorage.getItem(marker) === '1') {
|
|
return;
|
|
}
|
|
window.localStorage.clear();
|
|
window.sessionStorage.setItem(marker, '1');
|
|
});
|
|
await page.goto(targetUrl, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
|
|
let seeded;
|
|
if (isLocalUrl(targetUrl)) {
|
|
seeded = await seedFirstCampVisit(page, fixture);
|
|
} else {
|
|
const bridge = await createSeedBridge(
|
|
browser,
|
|
localSeedUrl,
|
|
fixture
|
|
);
|
|
seedServerProcess = bridge.serverProcess;
|
|
seeded = bridge.seeded;
|
|
await installCampaignSaves(page, bridge.campaignSaves);
|
|
}
|
|
assertSeededVisit(seeded);
|
|
|
|
await reloadToTitle(page);
|
|
assertRuntimeBaseline(
|
|
await readRuntimeProbe(page, 'TitleScene'),
|
|
renderer,
|
|
'seeded first-camp title'
|
|
);
|
|
await continueToExploration(page);
|
|
assertRuntimeBaseline(
|
|
await readRuntimeProbe(
|
|
page,
|
|
'CampVisitExplorationScene'
|
|
),
|
|
renderer,
|
|
'first-camp exploration'
|
|
);
|
|
|
|
const savedPlayer = await createPlayerCheckpoint(page);
|
|
const playerResume = await reloadAndContinueToExploration(page);
|
|
assertExactPlayer(
|
|
playerResume.player,
|
|
savedPlayer,
|
|
`${renderer}: position checkpoint resume`
|
|
);
|
|
assertExactCheckpointPlayer(
|
|
playerResume.campaign.explorationCheckpoint,
|
|
savedPlayer,
|
|
`${renderer}: persisted position checkpoint`
|
|
);
|
|
|
|
const dialogueBeforeReload =
|
|
await openAndAdvanceActorDialogue(page);
|
|
const dialogueResume =
|
|
await reloadAndContinueToExploration(page);
|
|
assertExactPlayer(
|
|
dialogueResume.player,
|
|
dialogueBeforeReload.player,
|
|
`${renderer}: dialogue player resume`
|
|
);
|
|
assertDialogueResume(
|
|
dialogueResume,
|
|
dialogueBeforeReload,
|
|
`${renderer}: actor dialogue resume`
|
|
);
|
|
|
|
const choiceBeforeReload = await advanceToChoicePanel(page);
|
|
const choiceResume =
|
|
await reloadAndContinueToExploration(page);
|
|
await waitForChoiceReady(page);
|
|
const readyChoiceResume = await readExploration(page);
|
|
assertChoiceResume(
|
|
readyChoiceResume,
|
|
choiceBeforeReload,
|
|
`${renderer}: choice panel resume`
|
|
);
|
|
assertRuntimeBaseline(
|
|
await readRuntimeProbe(
|
|
page,
|
|
'CampVisitExplorationScene'
|
|
),
|
|
renderer,
|
|
'resumed choice panel'
|
|
);
|
|
await page.screenshot({
|
|
path:
|
|
`dist/camp-exploration-checkpoint-choice-${renderer}.png`
|
|
});
|
|
|
|
const rewardBefore = rewardSnapshot(
|
|
await readCampaignStates(page)
|
|
);
|
|
assertRewardNotYetApplied(
|
|
rewardBefore,
|
|
`${renderer}: pre-choice reward state`
|
|
);
|
|
|
|
const selectedChoice = readyChoiceResume.choice.choices.find(
|
|
({ id }) => id === fixture.choiceId
|
|
);
|
|
assert(
|
|
selectedChoice,
|
|
`${renderer}: choice ${fixture.choiceId} is unavailable.`
|
|
);
|
|
const choiceBounds = assertFiniteBounds(
|
|
selectedChoice.bounds,
|
|
`${renderer}: ${fixture.choiceId} choice`
|
|
);
|
|
assertBoundsContained(
|
|
choiceBounds,
|
|
desktopBrowserViewportBounds,
|
|
`${renderer}: ${fixture.choiceId} choice`
|
|
);
|
|
await clickSceneBounds(
|
|
page,
|
|
'CampVisitExplorationScene',
|
|
choiceBounds
|
|
);
|
|
await waitForChoiceResponse(page, 0);
|
|
|
|
const rewardAfterChoice = rewardSnapshot(
|
|
await readCampaignStates(page)
|
|
);
|
|
assertRewardAppliedExactlyOnce(
|
|
rewardBefore,
|
|
rewardAfterChoice,
|
|
`${renderer}: first choice commit`
|
|
);
|
|
|
|
const advancedResponse = await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.debugAdvanceDialogue?.() ?? false
|
|
);
|
|
assert.equal(
|
|
advancedResponse,
|
|
true,
|
|
`${renderer}: could not advance the choice response.`
|
|
);
|
|
await waitForChoiceResponse(page, 1);
|
|
const responseBeforeReload = await readExploration(page);
|
|
assert.equal(
|
|
responseBeforeReload.dialogue.sourceNpcId,
|
|
fixture.actorId
|
|
);
|
|
assert.equal(
|
|
responseBeforeReload.campaign.explorationCheckpoint?.overlay
|
|
?.dialogueKey,
|
|
'first-choice-response'
|
|
);
|
|
|
|
const responseResume =
|
|
await reloadAndContinueToExploration(page);
|
|
assertChoiceResponseResume(
|
|
responseResume,
|
|
responseBeforeReload,
|
|
`${renderer}: choice response resume`
|
|
);
|
|
const rewardAfterReload = rewardSnapshot(
|
|
await readCampaignStates(page)
|
|
);
|
|
assert.deepEqual(
|
|
rewardAfterReload,
|
|
rewardAfterChoice,
|
|
`${renderer}: reloading the response duplicated or changed rewards.`
|
|
);
|
|
assertRewardAppliedExactlyOnce(
|
|
rewardBefore,
|
|
rewardAfterReload,
|
|
`${renderer}: post-reload reward state`
|
|
);
|
|
|
|
const automaticReturnStart = await page.evaluate(() => {
|
|
const started =
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.debugAdvanceDialogue?.() ?? false;
|
|
const loop = window.__HEROS_GAME__?.loop;
|
|
const loopSlept =
|
|
started &&
|
|
Boolean(loop && typeof loop.sleep === 'function');
|
|
if (loopSlept) {
|
|
loop.sleep();
|
|
}
|
|
return { started, loopSlept };
|
|
});
|
|
assert.equal(
|
|
automaticReturnStart.started,
|
|
true,
|
|
`${renderer}: could not start the automatic camp return.`
|
|
);
|
|
assert.equal(
|
|
automaticReturnStart.loopSlept,
|
|
true,
|
|
`${renderer}: could not pause the return fade before reload.`
|
|
);
|
|
await page.waitForFunction(() => {
|
|
const currentRaw = window.localStorage.getItem(
|
|
'heros-web:campaign-state'
|
|
);
|
|
const slotRaw = window.localStorage.getItem(
|
|
'heros-web:campaign-state:slot-1'
|
|
);
|
|
const states = [currentRaw, slotRaw]
|
|
.filter(Boolean)
|
|
.map((raw) => JSON.parse(raw));
|
|
return (
|
|
states.length === 2 &&
|
|
states.every(
|
|
(campaign) =>
|
|
!campaign.activeCampVisitId &&
|
|
!campaign.explorationCheckpoint
|
|
)
|
|
);
|
|
});
|
|
const interruptedReturnScenes = await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ?? []
|
|
);
|
|
assert(
|
|
interruptedReturnScenes.includes(
|
|
'CampVisitExplorationScene'
|
|
) &&
|
|
!interruptedReturnScenes.includes('CampScene'),
|
|
`${renderer}: reload must interrupt the 320ms return fade before CampScene starts: ${JSON.stringify(
|
|
interruptedReturnScenes
|
|
)}`
|
|
);
|
|
await reloadToTitle(page);
|
|
await clickContinue(page);
|
|
await waitForCampReady(page, fixture.campaignStep);
|
|
assertRuntimeBaseline(
|
|
await readRuntimeProbe(page, 'CampScene'),
|
|
renderer,
|
|
'interrupted automatic checkpoint-clearing return'
|
|
);
|
|
assertCheckpointCleared(
|
|
await readCampaignStates(page),
|
|
`${renderer}: interrupted automatic return`
|
|
);
|
|
|
|
await reloadToTitle(page);
|
|
await clickContinue(page);
|
|
await waitForCampReady(page, fixture.campaignStep);
|
|
assertCheckpointCleared(
|
|
await readCampaignStates(page),
|
|
`${renderer}: continue after automatic return`
|
|
);
|
|
assert(
|
|
!(await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.()
|
|
.includes('CampVisitExplorationScene')
|
|
)),
|
|
`${renderer}: a cleared checkpoint reopened exploration.`
|
|
);
|
|
|
|
const actionableConsoleErrors = consoleErrors.filter(
|
|
(message) =>
|
|
!message.includes(
|
|
'The AudioContext was not allowed to start'
|
|
)
|
|
);
|
|
assert.deepEqual(
|
|
pageErrors,
|
|
[],
|
|
`${renderer}: page errors: ${pageErrors.join('\n')}`
|
|
);
|
|
assert.deepEqual(
|
|
actionableConsoleErrors,
|
|
[],
|
|
`${renderer}: console errors: ${actionableConsoleErrors.join(
|
|
'\n'
|
|
)}`
|
|
);
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
renderer,
|
|
viewport: {
|
|
...desktopBrowserViewport,
|
|
deviceScaleFactor:
|
|
desktopBrowserDeviceScaleFactor,
|
|
browserZoom: '100%'
|
|
},
|
|
checkpoint: {
|
|
exactPlayerResume: savedPlayer,
|
|
dialogue: {
|
|
sourceNpcId:
|
|
dialogueBeforeReload.dialogue.sourceNpcId,
|
|
lineIndex:
|
|
dialogueBeforeReload.dialogue.lineIndex
|
|
},
|
|
choicePanelResumed:
|
|
choiceResume.choice.open === true,
|
|
response: {
|
|
sourceNpcId:
|
|
responseBeforeReload.dialogue.sourceNpcId,
|
|
lineIndex:
|
|
responseBeforeReload.dialogue.lineIndex
|
|
},
|
|
rewardCommittedOnce: true,
|
|
automaticReturnCleared: true
|
|
},
|
|
pageErrors: pageErrors.length,
|
|
consoleErrors: actionableConsoleErrors.length
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await stopServerProcess(serverProcess);
|
|
await stopServerProcess(seedServerProcess);
|
|
}
|
|
|
|
function withDebugOptions(url, requestedRenderer) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
parsed.searchParams.set('renderer', requestedRenderer);
|
|
return parsed.toString();
|
|
}
|
|
|
|
function isLocalUrl(url) {
|
|
return ['localhost', '127.0.0.1', '0.0.0.0'].includes(
|
|
new URL(url).hostname
|
|
);
|
|
}
|
|
|
|
async function waitForDebugApi(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector('canvas') !== null &&
|
|
window.__HEROS_GAME__ !== undefined &&
|
|
window.__HEROS_DEBUG__ !== undefined,
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function seedFirstCampVisit(page, requested) {
|
|
return page.evaluate(async (seed) => {
|
|
const campaignModule = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const { battleScenarios } = await import(
|
|
'/heros_web/src/game/data/battles.ts'
|
|
);
|
|
const scenario = battleScenarios[seed.battleId];
|
|
if (!scenario) {
|
|
throw new Error(
|
|
`Missing battle scenario ${seed.battleId}.`
|
|
);
|
|
}
|
|
|
|
campaignModule.resetCampaignState();
|
|
campaignModule.setFirstBattleReport({
|
|
battleId: scenario.id,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: 6,
|
|
rewardGold: scenario.baseVictoryGold,
|
|
defeatedEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
totalEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
objectives: [],
|
|
units: scenario.units,
|
|
bonds: (scenario.bonds ?? []).map((bond) => ({
|
|
...bond,
|
|
battleExp: 0
|
|
})),
|
|
itemRewards: [],
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt: '2026-07-28T00:00:00.000Z'
|
|
});
|
|
campaignModule.completeCampaignAftermath(scenario.id);
|
|
campaignModule.setActiveCampVisitId(seed.visitId);
|
|
|
|
const campaign = campaignModule.getCampaignState();
|
|
const slot = campaignModule.readCampaignSaveState(1);
|
|
const bond = campaign.bonds.find(
|
|
({ id }) => id === seed.bondId
|
|
);
|
|
return {
|
|
step: campaign.step,
|
|
latestBattleId: campaign.latestBattleId ?? null,
|
|
activeCampVisitId:
|
|
campaign.activeCampVisitId ?? null,
|
|
slotActiveCampVisitId:
|
|
slot?.activeCampVisitId ?? null,
|
|
checkpoint:
|
|
campaign.explorationCheckpoint ?? null,
|
|
completedVisitCount:
|
|
campaign.completedCampVisits.filter(
|
|
(id) => id === seed.visitId
|
|
).length,
|
|
inventoryItem:
|
|
campaign.inventory[seed.itemId] ?? 0,
|
|
bond: bond
|
|
? {
|
|
level: bond.level,
|
|
exp: bond.exp,
|
|
battleExp: bond.battleExp
|
|
}
|
|
: null
|
|
};
|
|
}, requested);
|
|
}
|
|
|
|
function assertSeededVisit(seeded) {
|
|
assert.equal(seeded.step, fixture.campaignStep);
|
|
assert.equal(seeded.latestBattleId, fixture.battleId);
|
|
assert.equal(
|
|
seeded.activeCampVisitId,
|
|
fixture.visitId
|
|
);
|
|
assert.equal(
|
|
seeded.slotActiveCampVisitId,
|
|
fixture.visitId
|
|
);
|
|
assert.equal(seeded.checkpoint, null);
|
|
assert.equal(seeded.completedVisitCount, 0);
|
|
assert(
|
|
seeded.bond,
|
|
`${renderer}: seed is missing ${fixture.bondId}.`
|
|
);
|
|
}
|
|
|
|
async function createSeedBridge(
|
|
activeBrowser,
|
|
seedUrl,
|
|
requested
|
|
) {
|
|
const localServerProcess = await ensureLocalServer(seedUrl);
|
|
const context = await activeBrowser.newContext(
|
|
desktopBrowserContextOptions
|
|
);
|
|
try {
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30000);
|
|
await page.addInitScript(() => window.localStorage.clear());
|
|
await page.goto(seedUrl, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
const seeded = await seedFirstCampVisit(page, requested);
|
|
const campaignSaves = await readCampaignSaves(page);
|
|
assert(
|
|
Object.keys(campaignSaves).length >= 2,
|
|
'Local seed bridge did not create current and slot saves.'
|
|
);
|
|
return {
|
|
seeded,
|
|
campaignSaves,
|
|
serverProcess: localServerProcess
|
|
};
|
|
} catch (error) {
|
|
await stopServerProcess(localServerProcess);
|
|
throw error;
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
async function installCampaignSaves(page, campaignSaves) {
|
|
await page.evaluate((saves) => {
|
|
Object.keys(window.localStorage)
|
|
.filter((key) =>
|
|
key.startsWith('heros-web:campaign-state')
|
|
)
|
|
.forEach((key) => window.localStorage.removeItem(key));
|
|
for (const [key, value] of Object.entries(saves)) {
|
|
window.localStorage.setItem(key, value);
|
|
}
|
|
}, campaignSaves);
|
|
}
|
|
|
|
async function reloadToTitle(page) {
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await page.waitForFunction(
|
|
() => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const title = debug?.title?.();
|
|
const continueItem = title?.focus?.items?.find(
|
|
({ id }) => id === 'continue'
|
|
);
|
|
return (
|
|
debug?.activeScenes?.().includes('TitleScene') &&
|
|
title?.scene === 'TitleScene' &&
|
|
title?.navigating === false &&
|
|
title?.focus?.scope === 'main' &&
|
|
continueItem?.enabled === true &&
|
|
continueItem?.bounds
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
await afterTwoFrames(page);
|
|
}
|
|
|
|
async function clickContinue(page) {
|
|
const item = await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__
|
|
?.title?.()
|
|
?.focus?.items?.find(({ id }) => id === 'continue') ??
|
|
null
|
|
);
|
|
assert.equal(
|
|
item?.enabled,
|
|
true,
|
|
`${renderer}: title continue button is unavailable.`
|
|
);
|
|
const bounds = assertFiniteBounds(
|
|
item.bounds,
|
|
`${renderer}: title continue button`
|
|
);
|
|
assertBoundsContained(
|
|
bounds,
|
|
desktopBrowserViewportBounds,
|
|
`${renderer}: title continue button`
|
|
);
|
|
await clickSceneBounds(page, 'TitleScene', bounds);
|
|
}
|
|
|
|
async function continueToExploration(page) {
|
|
await clickContinue(page);
|
|
return waitForExplorationReady(page);
|
|
}
|
|
|
|
async function reloadAndContinueToExploration(page) {
|
|
await reloadToTitle(page);
|
|
return continueToExploration(page);
|
|
}
|
|
|
|
async function waitForExplorationReady(page) {
|
|
try {
|
|
await page.waitForFunction(
|
|
(visitId) => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const exploration =
|
|
debug?.campVisitExploration?.();
|
|
return (
|
|
debug
|
|
?.activeScenes?.()
|
|
.includes('CampVisitExplorationScene') &&
|
|
exploration?.ready === true &&
|
|
exploration?.locationId === visitId &&
|
|
exploration?.visit?.id === visitId &&
|
|
exploration?.requiredTexturesReady === true
|
|
);
|
|
},
|
|
fixture.visitId,
|
|
{ timeout: 90000 }
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = await page.evaluate(() => ({
|
|
activeScenes:
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
|
title: window.__HEROS_DEBUG__?.title?.() ?? null,
|
|
exploration:
|
|
window.__HEROS_DEBUG__?.campVisitExploration?.() ??
|
|
null
|
|
}));
|
|
throw new Error(
|
|
`${renderer}: exploration did not become ready: ` +
|
|
JSON.stringify(diagnostic),
|
|
{ cause: error }
|
|
);
|
|
}
|
|
await afterTwoFrames(page);
|
|
const exploration = await readExploration(page);
|
|
assert.equal(
|
|
exploration.campaignStep,
|
|
fixture.campaignStep
|
|
);
|
|
return exploration;
|
|
}
|
|
|
|
async function readExploration(page) {
|
|
return page.evaluate(
|
|
() =>
|
|
window.__HEROS_DEBUG__?.campVisitExploration?.() ??
|
|
null
|
|
);
|
|
}
|
|
|
|
async function createPlayerCheckpoint(page) {
|
|
await page.waitForTimeout(450);
|
|
const teleported = await page.evaluate((targetId) =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.debugTeleportTo?.(targetId) ?? false,
|
|
fixture.movementAnchorId
|
|
);
|
|
assert.equal(
|
|
teleported,
|
|
true,
|
|
`${renderer}: could not create the movement checkpoint.`
|
|
);
|
|
|
|
await page.keyboard.down('ArrowRight');
|
|
await page.waitForTimeout(260);
|
|
await page.keyboard.up('ArrowRight');
|
|
try {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const state =
|
|
window.__HEROS_DEBUG__?.campVisitExploration?.();
|
|
const player = state?.player;
|
|
const checkpoint =
|
|
state?.campaign?.explorationCheckpoint;
|
|
return (
|
|
player &&
|
|
player.moving === false &&
|
|
player.direction === 'east' &&
|
|
checkpoint?.overlay === undefined &&
|
|
Math.abs(checkpoint?.player?.x - player.x) <= 0.05 &&
|
|
Math.abs(checkpoint?.player?.y - player.y) <= 0.05 &&
|
|
checkpoint?.player?.direction === player.direction
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 10000 }
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = await readExploration(page);
|
|
throw new Error(
|
|
`${renderer}: movement checkpoint did not settle: ` +
|
|
JSON.stringify({
|
|
activeScenes:
|
|
await page.evaluate(
|
|
() =>
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ??
|
|
[]
|
|
),
|
|
player: diagnostic?.player ?? null,
|
|
checkpoint:
|
|
diagnostic?.campaign?.explorationCheckpoint ??
|
|
null,
|
|
movement: diagnostic?.movement ?? null
|
|
}),
|
|
{ cause: error }
|
|
);
|
|
}
|
|
const exploration = await readExploration(page);
|
|
const player = exactPlayer(
|
|
exploration.campaign.explorationCheckpoint?.player
|
|
);
|
|
assertExactCheckpointPlayer(
|
|
exploration.campaign.explorationCheckpoint,
|
|
player,
|
|
`${renderer}: newly saved player checkpoint`
|
|
);
|
|
return player;
|
|
}
|
|
|
|
async function openAndAdvanceActorDialogue(page) {
|
|
const interacted = await page.evaluate((actorId) =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.debugInteractWith?.(actorId) ?? false,
|
|
fixture.actorId
|
|
);
|
|
assert.equal(
|
|
interacted,
|
|
true,
|
|
`${renderer}: could not interact with ${fixture.actorId}.`
|
|
);
|
|
await waitForDialogue(page, {
|
|
sourceNpcId: fixture.actorId,
|
|
lineIndex: 0,
|
|
dialogueKey: 'visit-interaction'
|
|
});
|
|
|
|
const advanced = await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.debugAdvanceDialogue?.() ?? false
|
|
);
|
|
assert.equal(
|
|
advanced,
|
|
true,
|
|
`${renderer}: could not advance the actor dialogue.`
|
|
);
|
|
await waitForDialogue(page, {
|
|
sourceNpcId: fixture.actorId,
|
|
lineIndex: 1,
|
|
dialogueKey: 'visit-interaction'
|
|
});
|
|
return readExploration(page);
|
|
}
|
|
|
|
async function waitForDialogue(
|
|
page,
|
|
{ sourceNpcId, lineIndex, dialogueKey }
|
|
) {
|
|
await page.waitForFunction(
|
|
(expected) => {
|
|
const state =
|
|
window.__HEROS_DEBUG__?.campVisitExploration?.();
|
|
const overlay =
|
|
state?.campaign?.explorationCheckpoint?.overlay;
|
|
return (
|
|
state?.dialogue?.active === true &&
|
|
state.dialogue.sourceNpcId ===
|
|
expected.sourceNpcId &&
|
|
state.dialogue.lineIndex === expected.lineIndex &&
|
|
overlay?.type === 'dialogue' &&
|
|
overlay.dialogueKey === expected.dialogueKey &&
|
|
overlay.sourceNpcId === expected.sourceNpcId &&
|
|
overlay.lineIndex === expected.lineIndex
|
|
);
|
|
},
|
|
{ sourceNpcId, lineIndex, dialogueKey },
|
|
{ timeout: 10000 }
|
|
);
|
|
await afterTwoFrames(page);
|
|
}
|
|
|
|
function assertDialogueResume(actual, expected, label) {
|
|
assert.equal(actual.dialogue.active, true, label);
|
|
assert.equal(
|
|
actual.dialogue.sourceNpcId,
|
|
expected.dialogue.sourceNpcId,
|
|
`${label}: source changed.`
|
|
);
|
|
assert.equal(
|
|
actual.dialogue.lineIndex,
|
|
expected.dialogue.lineIndex,
|
|
`${label}: line changed.`
|
|
);
|
|
assert.equal(
|
|
actual.dialogue.totalLines,
|
|
expected.dialogue.totalLines,
|
|
`${label}: dialogue content changed.`
|
|
);
|
|
const actualOverlay =
|
|
actual.campaign.explorationCheckpoint?.overlay;
|
|
const expectedOverlay =
|
|
expected.campaign.explorationCheckpoint?.overlay;
|
|
assert.deepEqual(
|
|
actualOverlay,
|
|
expectedOverlay,
|
|
`${label}: checkpoint overlay changed.`
|
|
);
|
|
}
|
|
|
|
async function advanceToChoicePanel(page) {
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const state = await readExploration(page);
|
|
if (state?.choice?.open) {
|
|
break;
|
|
}
|
|
assert.equal(
|
|
state?.dialogue?.active,
|
|
true,
|
|
`${renderer}: dialogue closed before the choice panel opened.`
|
|
);
|
|
const advanced = await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.debugAdvanceDialogue?.() ?? false
|
|
);
|
|
assert.equal(
|
|
advanced,
|
|
true,
|
|
`${renderer}: failed to advance toward the choice panel.`
|
|
);
|
|
await page.waitForTimeout(80);
|
|
}
|
|
await page.waitForFunction(
|
|
() => {
|
|
const state =
|
|
window.__HEROS_DEBUG__?.campVisitExploration?.();
|
|
const overlay =
|
|
state?.campaign?.explorationCheckpoint?.overlay;
|
|
return (
|
|
state?.dialogue?.active === false &&
|
|
state?.choice?.open === true &&
|
|
state.choice.mode === 'visit' &&
|
|
state.choice.choices.length === 2 &&
|
|
overlay?.type === 'choice' &&
|
|
overlay.mode === 'visit'
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 10000 }
|
|
);
|
|
await afterTwoFrames(page);
|
|
return readExploration(page);
|
|
}
|
|
|
|
async function waitForChoiceReady(page) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const choice =
|
|
window.__HEROS_DEBUG__
|
|
?.campVisitExploration?.()
|
|
?.choice;
|
|
return (
|
|
choice?.open === true &&
|
|
choice?.ready === true &&
|
|
choice.choices.length === 2 &&
|
|
choice.choices.every(
|
|
({ interactive, bounds }) =>
|
|
interactive === true &&
|
|
bounds &&
|
|
Number.isFinite(bounds.x) &&
|
|
Number.isFinite(bounds.y)
|
|
)
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 10000 }
|
|
);
|
|
await afterTwoFrames(page);
|
|
}
|
|
|
|
function assertChoiceResume(actual, expected, label) {
|
|
assert.equal(actual.choice.open, true, label);
|
|
assert.equal(actual.choice.mode, expected.choice.mode);
|
|
assert.equal(
|
|
actual.choice.sourceNpcId,
|
|
expected.choice.sourceNpcId
|
|
);
|
|
assert.deepEqual(
|
|
actual.choice.choices.map(({ id }) => id),
|
|
expected.choice.choices.map(({ id }) => id),
|
|
`${label}: choice IDs changed.`
|
|
);
|
|
assert.deepEqual(
|
|
actual.campaign.explorationCheckpoint?.overlay,
|
|
expected.campaign.explorationCheckpoint?.overlay,
|
|
`${label}: choice checkpoint changed.`
|
|
);
|
|
for (const choice of actual.choice.choices) {
|
|
const bounds = assertFiniteBounds(
|
|
choice.bounds,
|
|
`${label}: ${choice.id}`
|
|
);
|
|
assertBoundsContained(
|
|
bounds,
|
|
desktopBrowserViewportBounds,
|
|
`${label}: ${choice.id}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function waitForChoiceResponse(page, lineIndex) {
|
|
await waitForDialogue(page, {
|
|
sourceNpcId: fixture.actorId,
|
|
lineIndex,
|
|
dialogueKey: 'first-choice-response'
|
|
});
|
|
const state = await readExploration(page);
|
|
assert.equal(state.choice.open, false);
|
|
assert.equal(state.visit.completed, true);
|
|
assert.equal(state.visit.choiceId, fixture.choiceId);
|
|
}
|
|
|
|
function assertChoiceResponseResume(actual, expected, label) {
|
|
assertDialogueResume(actual, expected, label);
|
|
assert.equal(actual.visit.completed, true, label);
|
|
assert.equal(
|
|
actual.visit.choiceId,
|
|
fixture.choiceId,
|
|
`${label}: selected choice changed.`
|
|
);
|
|
assert.equal(
|
|
actual.choice.open,
|
|
false,
|
|
`${label}: response reopened the choice panel.`
|
|
);
|
|
}
|
|
|
|
async function waitForCampReady(page, expectedStep) {
|
|
try {
|
|
await page.waitForFunction(
|
|
(step) => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const camp = debug?.camp?.();
|
|
return (
|
|
debug?.activeScenes?.().includes('CampScene') &&
|
|
camp?.scene === 'CampScene' &&
|
|
camp?.campaignObjectiveJournal?.snapshot &&
|
|
camp?.campaign?.step === step
|
|
);
|
|
},
|
|
expectedStep,
|
|
{ timeout: 90000 }
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = await page.evaluate(() => ({
|
|
activeScenes:
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
|
camp: window.__HEROS_DEBUG__?.camp?.() ?? null,
|
|
exploration:
|
|
window.__HEROS_DEBUG__?.campVisitExploration?.() ??
|
|
null
|
|
}));
|
|
throw new Error(
|
|
`${renderer}: CampScene did not become ready: ` +
|
|
JSON.stringify(diagnostic),
|
|
{ cause: error }
|
|
);
|
|
}
|
|
await afterTwoFrames(page);
|
|
}
|
|
|
|
function exactPlayer(player) {
|
|
assert(
|
|
player &&
|
|
Number.isFinite(player.x) &&
|
|
Number.isFinite(player.y) &&
|
|
['north', 'south', 'east', 'west'].includes(
|
|
player.direction
|
|
),
|
|
`${renderer}: invalid player snapshot ${JSON.stringify(
|
|
player
|
|
)}.`
|
|
);
|
|
return {
|
|
x: player.x,
|
|
y: player.y,
|
|
direction: player.direction
|
|
};
|
|
}
|
|
|
|
function assertExactPlayer(actual, expected, label) {
|
|
assert.deepEqual(
|
|
exactPlayer(actual),
|
|
exactPlayer(expected),
|
|
`${label}: exact x/y/direction changed.`
|
|
);
|
|
}
|
|
|
|
function assertExactCheckpointPlayer(
|
|
checkpoint,
|
|
expected,
|
|
label
|
|
) {
|
|
assert(
|
|
checkpoint,
|
|
`${label}: exploration checkpoint is missing.`
|
|
);
|
|
assert.equal(checkpoint.version, 1, label);
|
|
assert.equal(
|
|
checkpoint.scene,
|
|
'camp-visit-exploration',
|
|
label
|
|
);
|
|
assert.equal(checkpoint.contextId, fixture.visitId, label);
|
|
assert.deepEqual(
|
|
checkpoint.player,
|
|
expected,
|
|
`${label}: checkpoint player differs.`
|
|
);
|
|
}
|
|
|
|
async function readCampaignSaves(page) {
|
|
return page.evaluate(() =>
|
|
Object.fromEntries(
|
|
Object.keys(window.localStorage)
|
|
.filter((key) =>
|
|
key.startsWith('heros-web:campaign-state')
|
|
)
|
|
.sort()
|
|
.map((key) => [
|
|
key,
|
|
window.localStorage.getItem(key)
|
|
])
|
|
)
|
|
);
|
|
}
|
|
|
|
async function readCampaignStates(page) {
|
|
const saves = await readCampaignSaves(page);
|
|
return Object.fromEntries(
|
|
Object.entries(saves).map(([key, value]) => [
|
|
key,
|
|
JSON.parse(value)
|
|
])
|
|
);
|
|
}
|
|
|
|
function rewardSnapshot(states) {
|
|
const entries = Object.entries(states);
|
|
assert(
|
|
entries.length >= 2,
|
|
`${renderer}: expected current and slot campaign saves.`
|
|
);
|
|
return Object.fromEntries(
|
|
entries.map(([key, state]) => {
|
|
const bond = state.bonds.find(
|
|
({ id }) => id === fixture.bondId
|
|
);
|
|
const reportBond = state.firstBattleReport?.bonds.find(
|
|
({ id }) => id === fixture.bondId
|
|
);
|
|
assert(
|
|
bond && reportBond,
|
|
`${renderer}: ${key} is missing ${fixture.bondId}.`
|
|
);
|
|
return [
|
|
key,
|
|
{
|
|
completedVisitCount:
|
|
state.completedCampVisits.filter(
|
|
(id) => id === fixture.visitId
|
|
).length,
|
|
reportCompletedVisitCount:
|
|
state.firstBattleReport.completedCampVisits.filter(
|
|
(id) => id === fixture.visitId
|
|
).length,
|
|
choiceId:
|
|
state.campVisitChoiceIds[fixture.visitId] ??
|
|
null,
|
|
inventory: { ...state.inventory },
|
|
bond: {
|
|
level: bond.level,
|
|
exp: bond.exp,
|
|
battleExp: bond.battleExp
|
|
},
|
|
reportBond: {
|
|
level: reportBond.level,
|
|
exp: reportBond.exp,
|
|
battleExp: reportBond.battleExp
|
|
}
|
|
}
|
|
];
|
|
})
|
|
);
|
|
}
|
|
|
|
function assertRewardNotYetApplied(snapshot, label) {
|
|
for (const [key, state] of Object.entries(snapshot)) {
|
|
assert.equal(
|
|
state.completedVisitCount,
|
|
0,
|
|
`${label}: ${key} already completed the visit.`
|
|
);
|
|
assert.equal(
|
|
state.reportCompletedVisitCount,
|
|
0,
|
|
`${label}: ${key} report already completed the visit.`
|
|
);
|
|
assert.equal(
|
|
state.choiceId,
|
|
null,
|
|
`${label}: ${key} already remembers a choice.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertRewardAppliedExactlyOnce(
|
|
before,
|
|
after,
|
|
label
|
|
) {
|
|
assert.deepEqual(
|
|
Object.keys(after),
|
|
Object.keys(before),
|
|
`${label}: campaign save keys changed.`
|
|
);
|
|
for (const key of Object.keys(before)) {
|
|
const previous = before[key];
|
|
const current = after[key];
|
|
assert.equal(
|
|
current.completedVisitCount,
|
|
1,
|
|
`${label}: ${key} visit completion count mismatch.`
|
|
);
|
|
assert.equal(
|
|
current.reportCompletedVisitCount,
|
|
1,
|
|
`${label}: ${key} report completion count mismatch.`
|
|
);
|
|
assert.equal(
|
|
current.choiceId,
|
|
fixture.choiceId,
|
|
`${label}: ${key} choice mismatch.`
|
|
);
|
|
assert.equal(
|
|
current.inventory[fixture.itemId] ?? 0,
|
|
(previous.inventory[fixture.itemId] ?? 0) +
|
|
fixture.itemAmount,
|
|
`${label}: ${key} inventory reward mismatch.`
|
|
);
|
|
for (const [itemId, amount] of Object.entries(
|
|
current.inventory
|
|
)) {
|
|
if (itemId !== fixture.itemId) {
|
|
assert.equal(
|
|
amount,
|
|
previous.inventory[itemId],
|
|
`${label}: ${key} changed unrelated item ${itemId}.`
|
|
);
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
current.bond,
|
|
{
|
|
level: previous.bond.level,
|
|
exp: previous.bond.exp + fixture.bondExp,
|
|
battleExp:
|
|
previous.bond.battleExp + fixture.bondExp
|
|
},
|
|
`${label}: ${key} campaign bond reward mismatch.`
|
|
);
|
|
assert.deepEqual(
|
|
current.reportBond,
|
|
current.bond,
|
|
`${label}: ${key} report bond diverged.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertCheckpointCleared(states, label) {
|
|
const entries = Object.entries(states);
|
|
assert(
|
|
entries.length >= 2,
|
|
`${label}: expected current and slot saves.`
|
|
);
|
|
for (const [key, state] of entries) {
|
|
assert.equal(
|
|
state.activeCampVisitId ?? null,
|
|
null,
|
|
`${label}: ${key} retained the active visit.`
|
|
);
|
|
assert.equal(
|
|
state.explorationCheckpoint ?? null,
|
|
null,
|
|
`${label}: ${key} retained the exploration checkpoint.`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function clickSceneBounds(page, sceneKey, bounds) {
|
|
const point = await page.evaluate(
|
|
({ key, requestedBounds }) => {
|
|
const scene = window.__HEROS_DEBUG__?.scene(key);
|
|
const canvasBounds =
|
|
document.querySelector('canvas')?.getBoundingClientRect();
|
|
if (!scene || !canvasBounds) {
|
|
return null;
|
|
}
|
|
return {
|
|
x:
|
|
canvasBounds.left +
|
|
(requestedBounds.x +
|
|
requestedBounds.width / 2) *
|
|
canvasBounds.width /
|
|
scene.scale.width,
|
|
y:
|
|
canvasBounds.top +
|
|
(requestedBounds.y +
|
|
requestedBounds.height / 2) *
|
|
canvasBounds.height /
|
|
scene.scale.height
|
|
};
|
|
},
|
|
{ key: sceneKey, requestedBounds: bounds }
|
|
);
|
|
assert(
|
|
point &&
|
|
Number.isFinite(point.x) &&
|
|
Number.isFinite(point.y),
|
|
`Unable to map ${sceneKey} bounds ${JSON.stringify(
|
|
bounds
|
|
)}.`
|
|
);
|
|
await page.mouse.click(point.x, point.y);
|
|
}
|
|
|
|
function assertFiniteBounds(bounds, label) {
|
|
assert(
|
|
bounds &&
|
|
Number.isFinite(bounds.x) &&
|
|
Number.isFinite(bounds.y) &&
|
|
Number.isFinite(bounds.width) &&
|
|
Number.isFinite(bounds.height) &&
|
|
bounds.width > 0 &&
|
|
bounds.height > 0,
|
|
`${label}: invalid bounds ${JSON.stringify(bounds)}.`
|
|
);
|
|
return bounds;
|
|
}
|
|
|
|
function assertBoundsContained(bounds, container, label) {
|
|
assert(
|
|
bounds.x >= container.x &&
|
|
bounds.y >= container.y &&
|
|
bounds.x + bounds.width <=
|
|
container.x + container.width &&
|
|
bounds.y + bounds.height <=
|
|
container.y + container.height,
|
|
`${label}: ${JSON.stringify(bounds)} is outside ` +
|
|
JSON.stringify(container)
|
|
);
|
|
}
|
|
|
|
async function afterTwoFrames(page) {
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
requestAnimationFrame(() =>
|
|
requestAnimationFrame(resolve)
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
async function readRuntimeProbe(page, sceneKey) {
|
|
return page.evaluate((key) => {
|
|
const game = window.__HEROS_GAME__;
|
|
const scene = game?.scene.getScene(key);
|
|
const canvas = document.querySelector('canvas');
|
|
const canvasBounds = canvas?.getBoundingClientRect();
|
|
return {
|
|
viewport: {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
dpr: window.devicePixelRatio,
|
|
zoom: window.visualViewport?.scale ?? 1
|
|
},
|
|
canvas: canvas
|
|
? {
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
bounds: canvasBounds
|
|
? {
|
|
x: canvasBounds.x,
|
|
y: canvasBounds.y,
|
|
width: canvasBounds.width,
|
|
height: canvasBounds.height
|
|
}
|
|
: null
|
|
}
|
|
: null,
|
|
renderer: {
|
|
requested: new URLSearchParams(
|
|
window.location.search
|
|
).get('renderer'),
|
|
type: game?.renderer?.type ?? null,
|
|
name: game?.renderer?.constructor?.name ?? null
|
|
},
|
|
scene: scene
|
|
? {
|
|
width: scene.scale.width,
|
|
height: scene.scale.height
|
|
}
|
|
: null
|
|
};
|
|
}, sceneKey);
|
|
}
|
|
|
|
function assertRuntimeBaseline(
|
|
runtime,
|
|
requestedRenderer,
|
|
label
|
|
) {
|
|
assert.deepEqual(
|
|
runtime.viewport,
|
|
{
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height,
|
|
dpr: desktopBrowserDeviceScaleFactor,
|
|
zoom: 1
|
|
},
|
|
`${label}: viewport, DPR, or browser zoom mismatch.`
|
|
);
|
|
assert.deepEqual(
|
|
runtime.canvas,
|
|
{
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height,
|
|
bounds: {
|
|
x: 0,
|
|
y: 0,
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height
|
|
}
|
|
},
|
|
`${label}: canvas did not fill the CSS viewport.`
|
|
);
|
|
assert.deepEqual(
|
|
runtime.scene,
|
|
{
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height
|
|
},
|
|
`${label}: Phaser scene size mismatch.`
|
|
);
|
|
assert.equal(
|
|
runtime.renderer.requested,
|
|
requestedRenderer,
|
|
`${label}: renderer query mismatch.`
|
|
);
|
|
assert.equal(
|
|
runtime.renderer.type,
|
|
requestedRenderer === 'canvas' ? 1 : 2,
|
|
`${label}: expected ${requestedRenderer}: ` +
|
|
JSON.stringify(runtime.renderer)
|
|
);
|
|
}
|
|
|
|
async function ensureLocalServer(url) {
|
|
if (await canReach(url)) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = new URL(url);
|
|
if (
|
|
!['localhost', '127.0.0.1', '0.0.0.0'].includes(
|
|
parsed.hostname
|
|
)
|
|
) {
|
|
throw new Error(`No server responded at ${url}`);
|
|
}
|
|
|
|
const stderr = [];
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
'node_modules/vite/bin/vite.js',
|
|
'--host',
|
|
'127.0.0.1',
|
|
'--port',
|
|
parsed.port || '41808',
|
|
'--strictPort'
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
}
|
|
);
|
|
child.stderr.on('data', (chunk) =>
|
|
stderr.push(chunk.toString())
|
|
);
|
|
child.stdout.on('data', () => {});
|
|
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
if (await canReach(url)) {
|
|
return child;
|
|
}
|
|
await delay(250);
|
|
}
|
|
|
|
await stopServerProcess(child);
|
|
throw new Error(
|
|
`Vite server 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));
|
|
}
|
|
|
|
async function stopServerProcess(child) {
|
|
if (!child || child.exitCode !== null) {
|
|
return;
|
|
}
|
|
const exited = new Promise((resolve) =>
|
|
child.once('exit', resolve)
|
|
);
|
|
child.kill();
|
|
await Promise.race([exited, delay(5000)]);
|
|
if (child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
await Promise.race([exited, delay(2000)]);
|
|
}
|
|
}
|