Files
heros_web/scripts/verify-turn-end-risk-browser.mjs

856 lines
26 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 battleId = 'second-battle-yellow-turban-pursuit';
const renderer = process.env.VERIFY_TURN_END_RISK_RENDERER;
const rendererNames = ['canvas', 'webgl'];
if (!renderer) {
for (const requestedRenderer of rendererNames) {
const result = spawnSync(
process.execPath,
[fileURLToPath(import.meta.url)],
{
cwd: process.cwd(),
env: {
...process.env,
VERIFY_TURN_END_RISK_RENDERER: requestedRenderer
},
stdio: 'inherit'
}
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
rendererNames.includes(renderer),
`Unsupported turn-end risk renderer "${renderer}".`
);
const baseUrl =
process.env.VERIFY_TURN_END_RISK_URL ??
'http://127.0.0.1:41811/heros_web/';
const targetUrl = withDebugOptions(baseUrl, renderer);
const screenshotPath =
`dist/verification-turn-end-risk-${renderer}-1920x1080.png`;
const forcedAttacks = Object.freeze([
{ targetIndex: 0, damage: 16, hitRate: 60, criticalRate: 0 },
{ targetIndex: 0, damage: 24, hitRate: 90, criticalRate: 20 },
{ targetIndex: 1, damage: 10, hitRate: 75, criticalRate: 0 },
{ targetIndex: 2, damage: 8, hitRate: 50, criticalRate: 0 }
]);
let serverProcess;
let browser;
try {
mkdirSync('dist', { recursive: true });
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({
headless: process.env.VERIFY_TURN_END_RISK_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(() => window.localStorage.clear());
await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForBattleReady(page);
const runtime = await readRuntimeProbe(page);
assertRuntimeBaseline(runtime, renderer);
await startDeploymentIfNeeded(page);
const setup = await installTurnEndRiskFixture(page);
assert.equal(setup.allyIds.length >= 3, true);
assert.equal(setup.forcedAttackCount, 4);
assert.equal(setup.forcedThreatTargetCount, 3);
assert.deepEqual(
setup.expectedVisibleGroupIds,
setup.expectedGroups.slice(0, 2).map((group) => group.targetId)
);
const prompt = await openManualTurnEndPrompt(page);
assertRiskForecast(prompt, setup, renderer);
assertPromptLayout(prompt, renderer);
const screenshot = await page.screenshot({
path: screenshotPath,
fullPage: true
});
assert(
screenshot.byteLength >= 100_000,
`Expected a painted ${renderer} turn-end risk screenshot: ${screenshot.byteLength} bytes.`
);
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.turnPromptVisible === false &&
battle.activeFaction === 'ally'
);
});
const safeReturn = await page.evaluate(() =>
window.__HEROS_DEBUG__?.battle()
);
assert.equal(
safeReturn?.actedUnitIds?.length,
0,
`${renderer}: safe default must return to command without abandoning unacted allies.`
);
assert.deepEqual(
pageErrors,
[],
`${renderer}: unexpected page errors: ${pageErrors.join(' | ')}`
);
assert.deepEqual(
consoleErrors,
[],
`${renderer}: unexpected console errors: ${consoleErrors.join(' | ')}`
);
console.log(
`Verified ${renderer} early turn-end risk forecast at ` +
`${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
`100% zoom, DPR ${desktopBrowserDeviceScaleFactor}; captured ${screenshotPath}.`
);
} finally {
await browser?.close();
await stopServerProcess(serverProcess);
}
function withDebugOptions(url, requestedRenderer) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', requestedRenderer);
parsed.searchParams.set('debugBattle', battleId);
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
return parsed.toString();
}
async function waitForBattleReady(page) {
await page.waitForFunction(
(expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? [];
return (
window.__HEROS_GAME__ !== undefined &&
battle?.battleId === expectedBattleId &&
['deployment', 'idle'].includes(battle.phase) &&
battle.mapBackgroundReady === true &&
(
battle.phase === 'idle' ||
['ready', 'degraded'].includes(battle.combatAssets?.status)
) &&
activeScenes.includes('BattleScene')
);
},
battleId,
{ timeout: 90000 }
);
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
}
async function startDeploymentIfNeeded(page) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
if (state?.phase === 'idle') {
return;
}
const startButtonBounds =
state?.battleHud?.deploymentPanel?.startButtonBounds;
assert(
state?.battleId === battleId &&
state.phase === 'deployment' &&
startButtonBounds,
`Expected ${battleId} deployment controls: ${JSON.stringify(state)}`
);
await clickSceneBounds(page, startButtonBounds);
await page.waitForFunction(
(expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.battleId === expectedBattleId &&
battle.phase === 'idle' &&
battle.battleHud?.deploymentPanel === null
);
},
battleId,
{ timeout: 90000 }
);
}
async function installTurnEndRiskFixture(page) {
return page.evaluate((attackDefinitions) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const allies = units.filter(
(unit) => unit.faction === 'ally' && unit.hp > 0
);
const enemies = units.filter(
(unit) => unit.faction === 'enemy' && unit.hp > 0
);
if (!scene || allies.length < 3 || enemies.length < attackDefinitions.length) {
return {
allyIds: [],
forcedAttackCount: 0,
forcedThreatTargetCount: 0,
expectedGroups: [],
expectedVisibleGroupIds: [],
expectedDamage: 0,
expectedLethalTargetCount: 0
};
}
scene.hideBattleEventBanner();
scene.hideTurnEndPrompt();
scene.hideSaveSlotPanel();
scene.hideMapMenu();
scene.hideCommandMenu();
scene.clearMarkers();
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.actedUnitIds.clear();
scene.clearEnemyIntentForecast();
const attacks = attackDefinitions.map((definition, index) => ({
...definition,
target: allies[definition.targetIndex],
enemy: enemies[index]
}));
attacks.forEach((attack) => {
const plan = scene.buildEnemyActionPlan(attack.enemy);
scene.enemyIntentForecasts.set(attack.enemy.id, {
...plan,
kind: 'attack',
target: attack.target,
approachTarget: attack.target,
usable: undefined,
destination: { x: attack.enemy.x, y: attack.enemy.y },
preview: {
...(plan.preview ?? {}),
damage: attack.damage,
hitRate: attack.hitRate,
criticalRate: attack.criticalRate
}
});
});
scene.renderSituationPanel('조기 턴 종료 위험 요약 검증');
const expectedGroups = allies.slice(0, 3).map((target) => {
const targeted = attacks.filter(
(attack) => attack.target.id === target.id
);
const expectedDamage = Math.round(targeted.reduce(
(total, attack) => total +
attack.damage *
attack.hitRate /
100 *
(1 + attack.criticalRate / 200),
0
));
const rawDamage = targeted.reduce(
(total, attack) => total + attack.damage,
0
);
const maximumDamage = targeted.reduce(
(total, attack) => total + (
attack.criticalRate > 0
? Math.round(attack.damage * 1.5)
: attack.damage
),
0
);
return {
targetId: target.id,
targetName: target.name,
hp: target.hp,
immediateCount: targeted.length,
approachCount: 0,
expectedDamage,
rawDamage,
maximumDamage,
lethal: maximumDamage >= target.hp,
hitRateMin: Math.min(
...targeted.map((attack) => attack.hitRate)
),
hitRateMax: Math.max(
...targeted.map((attack) => attack.hitRate)
),
concentrated: targeted.length > 1,
attackCount: targeted.length
};
});
return {
allyIds: allies.map((unit) => unit.id),
allyNames: allies.map((unit) => unit.name),
forcedAttackCount: attacks.length,
forcedThreatTargetCount: new Set(
attacks.map((attack) => attack.target.id)
).size,
expectedGroups,
expectedVisibleGroupIds:
expectedGroups.slice(0, 2).map((group) => group.targetId),
expectedDamage: expectedGroups.reduce(
(total, group) => total + group.expectedDamage,
0
),
expectedLethalTargetCount:
expectedGroups.filter((group) => group.lethal).length
};
}, forcedAttacks);
}
async function openManualTurnEndPrompt(page) {
const pointer = await findRightmostEmptyBattleTile(page);
await page.mouse.click(pointer.x, pointer.y, { button: 'right' });
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.mapMenuVisible === true
);
const mapMenu = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.mapMenuLayout
? { ...scene.mapMenuLayout, actions: [...scene.mapMenuLayout.actions] }
: null;
});
const endTurnIndex = mapMenu?.actions.indexOf('endTurn') ?? -1;
assert(
mapMenu && endTurnIndex >= 0,
`Expected an enabled manual turn-end action: ${JSON.stringify(mapMenu)}`
);
const keyboardState = await page.evaluate(
() => window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard
);
assert.equal(
keyboardState?.focusedItem?.action,
'endTurn',
`Expected turn end to be the map menu's safe keyboard default: ${JSON.stringify(keyboardState)}`
);
assert.equal(keyboardState?.focusedItem?.available, true);
await page.keyboard.press('Enter');
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true
);
return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
}
function assertRiskForecast(prompt, setup, requestedRenderer) {
const forecast = prompt?.riskForecast;
const primary = prompt?.buttons?.find((button) => button.id === 'primary');
const secondary =
prompt?.buttons?.find((button) => button.id === 'secondary');
assert.equal(
prompt?.title,
`미행동 ${setup.allyIds.length}명 · 턴 종료 확인`,
`${requestedRenderer}: early turn-end title must disclose every unacted ally.`
);
setup.allyNames.forEach((name) => {
assert(
prompt.body.includes(name),
`${requestedRenderer}: missing unacted ally ${name}: ${prompt.body}`
);
});
assert.equal(forecast?.immediateAttackCount, setup.forcedAttackCount);
assert.equal(forecast?.approachCount, 0);
assert.equal(forecast?.targetCount, setup.forcedThreatTargetCount);
assert.equal(forecast?.expectedDamage, setup.expectedDamage);
assert.equal(
forecast?.lethalTargetCount,
setup.expectedLethalTargetCount
);
assert.equal(forecast?.omittedTargetCount, 1);
assert.deepEqual(
forecast?.visibleGroupIds,
setup.expectedVisibleGroupIds
);
assert.equal(forecast?.groups?.length, setup.expectedGroups.length);
setup.expectedGroups.forEach((expected) => {
const actual = forecast.groups.find(
(group) => group.targetId === expected.targetId
);
const row = prompt.riskRows?.find(
(candidate) => candidate.targetId === expected.targetId
);
assert(actual, `${requestedRenderer}: missing risk group ${expected.targetId}.`);
assert.deepEqual(
{
targetId: actual.targetId,
targetName: actual.targetName,
hp: actual.hp,
immediateCount: actual.immediateCount,
approachCount: actual.approachCount,
expectedDamage: actual.expectedDamage,
rawDamage: actual.rawDamage,
maximumDamage: actual.maximumDamage,
lethal: actual.lethal,
hitRateMin: actual.hitRateMin,
hitRateMax: actual.hitRateMax,
concentrated: actual.concentrated,
attackCount: actual.attacks?.length
},
expected,
`${requestedRenderer}: risk group mismatch for ${expected.targetName}.`
);
if (forecast.visibleGroupIds.includes(expected.targetId)) {
const expectedBadge = expected.concentrated && expected.lethal
? '집중 공격 · 격파 가능'
: expected.lethal
? '격파 가능'
: expected.concentrated
? '집중 공격'
: expected.immediateCount > 0
? '피격 예상'
: '접근 예정';
assert(row, `${requestedRenderer}: missing visible risk row ${expected.targetId}.`);
assert.equal(row.badgeText, expectedBadge);
assert.equal(
row.titleText,
`${expected.targetName} · 현재 HP ${expected.hp}`
);
assert(
!row.titleText.includes('기대 후'),
`${requestedRenderer}: risk row must not imply a deterministic post-turn HP.`
);
assert(
row.metricText.includes(`공격 ${expected.immediateCount}`) &&
row.metricText.includes(`기대 피해 ${expected.expectedDamage}`) &&
row.metricText.includes(`최대 피해 ${expected.maximumDamage}`),
`${requestedRenderer}: incomplete risk metrics for ${expected.targetName}: ${row.metricText}`
);
}
});
assert.deepEqual(
{
focusedAction: prompt.focusedAction,
cancelAction: prompt.cancelAction,
primary: {
label: primary?.label,
meaning: primary?.meaning,
dangerous: primary?.dangerous,
focused: primary?.focused,
escapeCancels: primary?.escapeCancels
},
secondary: {
label: secondary?.label,
meaning: secondary?.meaning,
dangerous: secondary?.dangerous,
focused: secondary?.focused,
escapeCancels: secondary?.escapeCancels
}
},
{
focusedAction: 'primary',
cancelAction: 'primary',
primary: {
label: '계속 지휘',
meaning: 'continue-command',
dangerous: false,
focused: true,
escapeCancels: true
},
secondary: {
label: '미행동 포기 후 종료',
meaning: 'abandon-unacted-and-end',
dangerous: true,
focused: false,
escapeCancels: false
}
},
`${requestedRenderer}: safe focus or explicit danger semantics regressed.`
);
}
function assertPromptLayout(prompt, requestedRenderer) {
const panel = assertFiniteBounds(
prompt?.panelBounds,
`${requestedRenderer} prompt panel`
);
const summary = assertFiniteBounds(
prompt?.summaryBounds,
`${requestedRenderer} prompt summary`
);
const footnote = assertFiniteBounds(
prompt?.footnoteBounds,
`${requestedRenderer} prompt footnote`
);
const rows = prompt?.riskRows ?? [];
const buttons = prompt?.buttons ?? [];
assert.equal(
rows.length,
prompt?.riskForecast?.visibleGroupIds?.length,
`${requestedRenderer}: every visible risk group must have one row.`
);
assert.deepEqual(
rows.map((row) => row.targetId),
prompt.riskForecast.visibleGroupIds,
`${requestedRenderer}: row order must match visible risk priority.`
);
assert.equal(buttons.length, 2, `${requestedRenderer}: expected two prompt buttons.`);
const rowBounds = rows.map((row) => {
const bounds = assertFiniteBounds(
row.bounds,
`${requestedRenderer} risk row ${row.targetId}`
);
const badgeBounds = assertFiniteBounds(
row.badgeBounds,
`${requestedRenderer} risk badge ${row.targetId}`
);
const titleBounds = assertFiniteBounds(
row.titleBounds,
`${requestedRenderer} risk title ${row.targetId}`
);
const metricBounds = assertFiniteBounds(
row.metricBounds,
`${requestedRenderer} risk metrics ${row.targetId}`
);
[badgeBounds, titleBounds, metricBounds].forEach((textBounds, index) => {
assertBoundsContained(
textBounds,
bounds,
`${requestedRenderer} risk row ${row.targetId} text ${index + 1}`
);
});
assert(
!boundsOverlap(titleBounds, badgeBounds),
`${requestedRenderer}: title and warning badge overlap for ${row.targetId}.`
);
assert(
!boundsOverlap(metricBounds, badgeBounds),
`${requestedRenderer}: metrics and warning badge overlap for ${row.targetId}.`
);
assert(
!boundsOverlap(titleBounds, metricBounds),
`${requestedRenderer}: title and metrics overlap for ${row.targetId}.`
);
return bounds;
});
const buttonBounds = buttons.map((button) =>
assertFiniteBounds(
button.bounds,
`${requestedRenderer} prompt button ${button.id}`
)
);
const contentBounds = [summary, ...rowBounds, footnote, ...buttonBounds];
assertBoundsContained(
panel,
desktopBrowserViewportBounds,
`${requestedRenderer} prompt panel in viewport`
);
contentBounds.forEach((bounds, index) => {
assertBoundsContained(
bounds,
panel,
`${requestedRenderer} prompt content ${index + 1} in panel`
);
assertBoundsContained(
bounds,
desktopBrowserViewportBounds,
`${requestedRenderer} prompt content ${index + 1} in viewport`
);
});
for (let leftIndex = 0; leftIndex < contentBounds.length; leftIndex += 1) {
for (
let rightIndex = leftIndex + 1;
rightIndex < contentBounds.length;
rightIndex += 1
) {
assert(
!boundsOverlap(
contentBounds[leftIndex],
contentBounds[rightIndex]
),
`${requestedRenderer}: prompt regions ${leftIndex + 1} and ` +
`${rightIndex + 1} overlap: ${JSON.stringify({
left: contentBounds[leftIndex],
right: contentBounds[rightIndex]
})}`
);
}
}
}
async function readRuntimeProbe(page) {
return page.evaluate(() => {
const game = window.__HEROS_GAME__;
const scene = game?.scene.getScene('BattleScene');
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
};
});
}
function assertRuntimeBaseline(runtime, requestedRenderer) {
assert.deepEqual(runtime.viewport, {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height,
dpr: desktopBrowserDeviceScaleFactor,
zoom: 1
});
assert.deepEqual(runtime.canvas, {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height,
bounds: {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
}
});
assert.deepEqual(runtime.scene, {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
});
assert.equal(runtime.renderer.requested, requestedRenderer);
assert.equal(
runtime.renderer.type,
requestedRenderer === 'canvas' ? 1 : 2,
`Expected ${requestedRenderer} renderer: ${JSON.stringify(runtime.renderer)}`
);
}
async function findRightmostEmptyBattleTile(page) {
const logicalPoint = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return null;
}
for (
let column = scene.layout.visibleColumns - 1;
column >= 0;
column -= 1
) {
for (let row = 0; row < scene.layout.visibleRows; row += 1) {
const x = scene.cameraTileX + column;
const y = scene.cameraTileY + row;
if (scene.isInBounds(x, y) && !scene.isOccupied(x, y)) {
return {
x: scene.tileCenterX(x),
y: scene.tileCenterY(y)
};
}
}
}
return null;
});
assert(logicalPoint, 'Expected a visible empty battlefield tile.');
return sceneLogicalPoint(page, logicalPoint);
}
async function clickSceneBounds(page, bounds) {
const point = await sceneLogicalPoint(page, {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2
});
await page.mouse.click(point.x, point.y);
}
async function sceneLogicalPoint(page, logicalPoint) {
const point = await page.evaluate((requestedPoint) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const canvasBounds =
document.querySelector('canvas')?.getBoundingClientRect();
if (!scene || !canvasBounds || !requestedPoint) {
return null;
}
return {
x:
canvasBounds.left +
requestedPoint.x * canvasBounds.width / scene.scale.width,
y:
canvasBounds.top +
requestedPoint.y * canvasBounds.height / scene.scale.height
};
}, logicalPoint);
assert(
point && Number.isFinite(point.x) && Number.isFinite(point.y),
`Expected a finite BattleScene point: ${JSON.stringify({
logicalPoint,
point
})}`
);
return point;
}
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,
`Expected finite positive bounds for ${label}: ${JSON.stringify(bounds)}`
);
return bounds;
}
function assertBoundsContained(inner, outer, label) {
const tolerance = 1;
assert(
inner.x >= outer.x - tolerance &&
inner.y >= outer.y - tolerance &&
inner.x + inner.width <= outer.x + outer.width + tolerance &&
inner.y + inner.height <= outer.y + outer.height + tolerance,
`${label} escaped its container: ${JSON.stringify({ inner, outer })}`
);
}
function boundsOverlap(left, right) {
const tolerance = 1;
return (
left.x < right.x + right.width - tolerance &&
left.x + left.width > right.x + tolerance &&
left.y < right.y + right.height - tolerance &&
left.y + left.height > right.y + tolerance
);
}
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 || '41811',
'--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)]);
}
}