Files
ark-stronghold-protocol/test/ui/leftovers.e2e.test.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

407 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Browser E2E of the client leftovers (puppeteer-core + system Chrome; opt-in: SP_E2E=1, ~3 min):
//
// SP_E2E=1 node --test test/ui/leftovers.e2e.test.js
//
// Mock harness (public/dev/game-mock.html, in-process server):
// 1. a third item dropped on a full operator opens the equip-replace dialog (both equipped items with icon / name /
// effect); 取消 and Esc send nothing and change nothing; picking the 2nd item + 确认替换 sends g.equip {replaceUid};
// 2. 最终攻势: the countdown gauge counts the level's 120 s; the red DOT warning shows the drain start once the level time
// ran out and a live indicator while the team LP drains (m.public.overtimeAt);
// 4. solo battle: the pause button sends g.pause {on}, m.public.paused shows the overlay and freezes the countdown,
// Space resumes; no pause control in co-op or in prep.
// Real server (test/e2e/fastServer.mjs, real clicks and canvas drags):
// 1+4. a solo run: equip two items on an operator, drop a third → dialog → 取消 (nothing sent) → pick the OLDER item →
// the real engine destroys exactly that one; then ready → the real local battle → pause (the battle clock and the
// countdown stand still) → 继续作战;
// 3. the server restarts while the briefing is on screen → 「服务器会话已重置,上一局模拟已结束」 and the lobby.
// Screenshots: test/e2e/out/leftover-*.png. Every page must end without console / page / request errors (the restart
// test tolerates only the socket errors of the server's downtime).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, OUT, CHROME, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const ITEMS = (() => {
const raw = JSON.parse(readFileSync(path.join(ROOT, 'data/items.json'), 'utf8'));
const list = Array.isArray(raw) ? raw : Object.values(raw.items || raw);
return new Map(list.filter((x) => x && x.id).map((x) => [x.id, x]));
})();
const attaches = (id) => !String(ITEMS.get(id)?.kind || '').startsWith('consume_on_equip');
/** Three distinct plain equipment items (attach, mergeable pairs never complete: all different). */
const KIT_ITEMS = [...ITEMS.values()].filter((i) => i.itemType === 'EQUIP' && !i.isGolden && attaches(i.id) && i.tier === 1).map((i) => i.id).sort().slice(0, 3);
describe('client leftovers — mock harness', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--force-device-scale-factor=1'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
async function open(query, { w = 1920, h = 1080 } = {}) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&${query}`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
await sleep(900);
return { page, problems };
}
const mockPriv = (page) => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
const mockReqs = (page, t) => page.evaluate((t) => globalThis.__MOCK__.S().requests.filter((r) => r[0] === t), t);
const center = (page, sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const drag = async (page, from, to) => {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + ((to.x - from.x) * i) / 8, from.y + ((to.y - from.y) * i) / 8);
await sleep(80);
await page.mouse.up();
await sleep(500);
};
test('1. equip-replace dialog: lists both equipped items, cancel / Esc change nothing, the pick is sent as replaceUid', async () => {
// DOM drag targets exist in the fallback view (the engine's own drag is covered by the real-server test below)
const { page, problems } = await open('phase=PREP&render=fallback');
let s = await mockPriv(page);
const target = s.board.find((p) => p.kind === 'chess' && (p.items || []).length === 2);
const item = s.hand.find((p) => p && p.kind === 'item' && attaches(p.id) && !target.items.some((x) => x.id === p.id));
assert.ok(target && item, 'the mock has a full operator and an attachable hand item');
const dropOnTarget = async () => {
await drag(page, await center(page, `.ff-piece[data-uid="${item.uid}"]`), await center(page, `.ff-piece[data-uid="${target.uid}"]`));
await page.waitForSelector('.eqr .eqr__opt', { timeout: 4000 });
};
await dropOnTarget();
const dlg = await page.evaluate(() => ({
opts: [...document.querySelectorAll('.eqr__opt')].map((el) => ({
uid: Number(el.dataset.uid), name: el.querySelector('.eqr__name')?.textContent, desc: (el.querySelector('.eqr__desc')?.textContent || '').length,
icon: !!el.querySelector('.uthumb'), checked: el.getAttribute('aria-checked'),
})),
incoming: document.querySelector('.eqr__card--new .eqr__name')?.textContent,
okDisabled: document.querySelector('.eqr__ok')?.disabled,
lead: document.querySelector('.eqr__lead')?.textContent || '',
}));
assert.deepEqual(dlg.opts.map((o) => o.uid), target.items.map((x) => x.uid), 'both equipped items, oldest first');
assert.deepEqual(dlg.opts.map((o) => o.name), target.items.map((x) => ITEMS.get(x.id).name));
assert.ok(dlg.opts.every((o) => o.icon && o.desc > 0 && o.checked === 'false'), 'icons + effects, nothing preselected');
assert.equal(dlg.incoming, ITEMS.get(item.id).name);
assert.equal(dlg.okDisabled, true, '确认替换 needs a pick');
assert.match(dlg.lead, /被替换的装备将被销毁/);
assert.equal(await page.$('.uframe__btn--destroy'), null, 'no 销毁 anywhere for equipped items');
await page.screenshot({ path: path.join(OUT, 'leftover-replace-mock.png') });
// 取消: nothing sent, nothing changed
await page.click('.eqr__cancel');
await page.waitForFunction(() => !document.querySelector('.eqr'), { timeout: 3000 });
// Esc: same
await dropOnTarget();
await page.click('.eqr__opt:nth-child(1)');
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.eqr'), { timeout: 3000 });
await sleep(300);
assert.deepEqual(await mockReqs(page, 'g.equip'), [], 'cancel / Esc: no g.equip');
s = await mockPriv(page);
assert.deepEqual(s.board.find((p) => p.uid === target.uid).items.map((x) => x.uid), target.items.map((x) => x.uid));
assert.ok(s.hand.some((p) => p && p.uid === item.uid), 'the item stays in the hand');
// pick the SECOND (newer) item → 确认替换
await dropOnTarget();
await page.click('.eqr__opt:nth-child(2)');
await page.waitForFunction(() => document.querySelector('.eqr__opt:nth-child(2)')?.getAttribute('aria-checked') === 'true' && !document.querySelector('.eqr__ok').disabled, { timeout: 2000 });
await sleep(350); // the 将被销毁 flag slides in
await page.screenshot({ path: path.join(OUT, 'leftover-replace-mock-picked.png') });
await page.click('.eqr__ok');
await page.waitForFunction(() => !document.querySelector('.eqr'), { timeout: 3000 });
await sleep(400);
assert.deepEqual(await mockReqs(page, 'g.equip'), [['g.equip', { itemUid: item.uid, targetUid: target.uid, replaceUid: target.items[1].uid }]]);
s = await mockPriv(page);
assert.deepEqual(s.board.find((p) => p.uid === target.uid).items.map((x) => x.uid), [target.items[0].uid, item.uid], 'the picked one is destroyed');
// a loose item still offers 销毁 (tap → underframe)
const loose = s.hand.find((p) => p && p.kind === 'item');
if (loose) {
await page.click(`.ff-piece[data-uid="${loose.uid}"]`);
await page.waitForSelector('.uframe__btn--destroy', { timeout: 3000 });
}
assert.deepEqual(problems, []);
await page.close();
});
test("2. 最终攻势: the 120 s countdown gauge, then the red DOT overtime warning (drain start + live drain)", async () => {
// start of the round: 95 s of 120 left → 4 of 5 bars, no warning yet
let { page, problems } = await open('phase=FINAL_ASSAULT');
const lit = await page.$$eval('.gtop__right .countdown__gauge i.on', (els) => els.length);
assert.equal(lit, 4, 'gauge counts the 120 s level time');
assert.equal(await page.$('.otwarn'), null);
assert.deepEqual(problems, []);
await page.close();
// the level time ran out: the drain starts in ~18 s
({ page, problems } = await open('phase=FINAL_ASSAULT&variant=overtime'));
await page.waitForSelector('.otwarn[data-state="pending"]', { timeout: 3000 });
const pend = await page.$eval('.otwarn', (el) => el.textContent);
assert.match(pend, /DOT/);
assert.match(pend, /\d+\s*秒后全队生命值开始流失/);
const secs = Number(pend.match(/(\d+)\s*秒后/)[1]);
assert.ok(secs > 10 && secs <= 18, `drain in ${secs} s`);
assert.equal(await page.$eval('.gtop__right .countdown', (el) => el.getAttribute('aria-label')), '剩余0秒');
await page.screenshot({ path: path.join(OUT, 'leftover-fa-overtime.png') });
assert.deepEqual(problems, []);
await page.close();
// draining: live indicator, the team LP turns red, the lost counter moves every second
({ page, problems } = await open('phase=FINAL_ASSAULT&variant=drain'));
await page.waitForSelector('.otwarn[data-state="drain"]', { timeout: 3000 });
const d1 = await page.$eval('.otwarn', (el) => el.textContent);
assert.match(d1, /超时 · 生命值 −1\/秒/);
assert.ok(await page.$('.gtop__center .lp--danger'), 'the team LP tower turns red');
const lost = async () => Number((await page.$eval('.otwarn__lost b', (el) => el.textContent)) || 0);
const l1 = await lost();
await sleep(2100);
const l2 = await lost();
assert.ok(l2 >= l1 + 2, `the drain counter is live (${l1} → ${l2})`);
await page.screenshot({ path: path.join(OUT, 'leftover-fa-drain.png') });
assert.deepEqual(problems, []);
await page.close();
({ page, problems } = await open('phase=HIDDEN_CORE&variant=drain'));
await page.waitForSelector('.otwarn[data-state="drain"]', { timeout: 3000 });
assert.deepEqual(problems, []);
await page.close();
});
test('4. solo battle: pause button → g.pause, paused overlay freezes the clock, Space resumes; hidden in co-op / prep', async () => {
let { page, problems } = await open('phase=COMBAT&variant=solo');
await page.waitForSelector('[data-testid="pause"]', { timeout: 3000 });
await page.click('[data-testid="pause"]');
await page.waitForSelector('.pauseov', { timeout: 3000 });
assert.deepEqual(await mockReqs(page, 'g.pause'), [['g.pause', { on: true }]]);
const cd = () => page.$eval('.gtop__right .countdown', (el) => el.getAttribute('aria-label'));
const c1 = await cd();
await sleep(1600);
assert.equal(await cd(), c1, 'the countdown stands still while paused');
assert.equal(await page.$eval('[data-testid="pause"]', (el) => el.getAttribute('aria-pressed')), 'true');
await page.screenshot({ path: path.join(OUT, 'leftover-pause-mock.png') });
await page.keyboard.press('Space');
await page.waitForFunction(() => !document.querySelector('.pauseov'), { timeout: 3000 });
assert.deepEqual(await mockReqs(page, 'g.pause'), [['g.pause', { on: true }], ['g.pause', { on: false }]]);
// the overlay's 继续作战 resumes too
await page.keyboard.press('Space');
await page.waitForSelector('.pauseov', { timeout: 3000 });
await page.click('.pauseov .btn--primary');
await page.waitForFunction(() => !document.querySelector('.pauseov'), { timeout: 3000 });
assert.equal((await mockReqs(page, 'g.pause')).length, 4);
assert.deepEqual(problems, []);
await page.close();
for (const q of ['phase=COMBAT', 'phase=FINAL_ASSAULT', 'phase=PREP&variant=solo']) {
({ page, problems } = await open(q));
assert.equal(await page.$('[data-testid="pause"]'), null, `${q}: no pause control`);
if (q !== 'phase=PREP&variant=solo') {
await page.keyboard.press('Space');
await sleep(300);
assert.deepEqual(await mockReqs(page, 'g.pause'), [], `${q}: Space does not pause`);
}
assert.deepEqual(problems, []);
await page.close();
}
});
});
describe('client leftovers — real server', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
test('1+4. solo: equip-replace dialog on the real engine, then pause / resume the real local battle', { timeout: 6 * 60 * 1000 }, async () => {
assert.equal(KIT_ITEMS.length, 3);
const CHESS = 'chess_char_1_01_a'; // a cheap ranged operator (any legal tile)
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, chess: [CHESS], items: KIT_ITEMS } });
const P = (await import('puppeteer-core')).default;
const c = new Client(P, srv.base, 'solo', { prefix: 'leftover' });
try {
await c.open();
await c.enter('替换');
await c.click('.mode-card', '独立模拟');
await c.click('.diff-card', '标准模拟');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor((s) => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await c.click('.dband', null, { nth: 1 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.hand >= 4, 'prep with the starter kit', 60000);
await sleep(1800);
await c.hookRequests();
// the operator onto the board (drag → direction wheel)
const [unit] = (await c.handPieces('chess')).filter((p) => p.id === CHESS);
assert.ok(unit, 'the kit operator is in the hand');
const tile = await c.freeTileFor(unit.uid);
await c.drag(await c.piecePoint(unit.uid), await c.tilePoint(tile.row, tile.col));
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('RIGHT');
await c.waitFor((s) => s.board > 0, 'placed', 8000);
await sleep(700);
const itemsOf = () => c.page.evaluate((uid) => (globalThis.__SP__.store.get().match.private.board.find((p) => p.uid === uid)?.items || []).map((x) => ({ uid: x.uid, id: x.id })), unit.uid);
const handItem = async (id) => (await c.handPieces('item')).find((p) => p.id === id) || null;
// an item goes to the unit on the tile it is dropped on (user playtest #4 item 1): the operator's own tile
const dropItem = async (it) => {
for (let i = 0; i < 2; i++) {
await c.drag(await c.piecePoint(it.uid, 0.5), await c.tilePoint(tile.row, tile.col));
await sleep(500);
if (await c.exists('.eqr') || !(await handItem(it.id))) return;
}
};
// two items equipped normally (no dialog)
for (const id of KIT_ITEMS.slice(0, 2)) {
const it = await handItem(id);
assert.ok(it, `${id} in the hand`);
await dropItem(it);
assert.equal(await c.exists('.eqr'), false, 'a free slot: no dialog');
await c.page.waitForFunction((uid, id) => (globalThis.__SP__.store.get().match.private.board.find((p) => p.uid === uid)?.items || []).some((x) => x.id === id), { timeout: 5000 }, unit.uid, id);
}
const equipped = await itemsOf();
assert.deepEqual(equipped.map((x) => x.id), KIT_ITEMS.slice(0, 2));
const third = await handItem(KIT_ITEMS[2]);
// the third: dialog → 取消 → nothing sent / changed
await dropItem(third);
await c.page.waitForSelector('.eqr .eqr__opt', { timeout: 4000 });
assert.deepEqual(await c.page.$$eval('.eqr__opt', (els) => els.map((el) => Number(el.dataset.uid))), equipped.map((x) => x.uid));
assert.deepEqual(await c.page.$$eval('.eqr__opt .eqr__name', (els) => els.map((el) => el.textContent)), equipped.map((x) => ITEMS.get(x.id).name));
await c.shot('replace-real');
const sentBefore = (await c.requests('g.equip')).length;
await c.click('.eqr__cancel');
await c.page.waitForFunction(() => !document.querySelector('.eqr'), { timeout: 3000 });
await sleep(500);
assert.equal((await c.requests('g.equip')).length, sentBefore, '取消: no g.equip');
assert.deepEqual(await itemsOf(), equipped, '取消: unchanged');
assert.ok(await handItem(KIT_ITEMS[2]), '取消: the item stays in the hand');
// again → pick the OLDER item (not the server's default either way: explicit replaceUid) → 确认替换
await dropItem(third);
await c.page.waitForSelector('.eqr .eqr__opt', { timeout: 4000 });
await c.click('.eqr__opt', null, { nth: 0 });
await c.click('.eqr__ok');
await c.page.waitForFunction((uid, id) => (globalThis.__SP__.store.get().match.private.board.find((p) => p.uid === uid)?.items || []).some((x) => x.id === id), { timeout: 5000 }, unit.uid, KIT_ITEMS[2]);
const last = (await c.requests('g.equip')).pop();
assert.deepEqual(last[1], { itemUid: third.uid, targetUid: unit.uid, replaceUid: equipped[0].uid });
const after = await itemsOf();
assert.deepEqual(after.map((x) => x.uid).sort(), [equipped[1].uid, third.uid].sort(), 'the engine destroyed exactly the picked item');
assert.equal(await handItem(KIT_ITEMS[0]), null, 'the replaced item is not returned to the hand');
await c.shot('replace-real-after');
// 4) ready → the local battle → pause / resume
await c.click('.readybtn');
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
await c.page.waitForSelector('[data-testid="pause"]', { visible: true, timeout: 15000 });
await sleep(1500);
const btime = () => c.page.evaluate(() => {
const e = [...(globalThis.__SP_RUNNER__?._entries?.values() || [])].find((x) => x.own && x.battle);
return e ? e.battle.time : null;
});
await c.click('[data-testid="pause"]');
await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.public?.paused === true, { timeout: 5000 });
await c.page.waitForSelector('.pauseov', { timeout: 3000 });
await sleep(400);
const t1 = await btime();
const cd1 = await c.page.$eval('.gtop__right .countdown', (el) => el.getAttribute('aria-label'));
await sleep(2000);
assert.equal(await btime(), t1, 'the local battle clock stands still');
assert.equal(await c.page.$eval('.gtop__right .countdown', (el) => el.getAttribute('aria-label')), cd1, 'the countdown stands still');
assert.deepEqual((await c.requests('g.pause')).map((r) => r[1]), [{ on: true }]);
await c.shot('pause-real');
await c.click('.pauseov .btn--primary', '继续作战');
await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.public?.paused === false, { timeout: 5000 });
await c.page.waitForFunction(() => !document.querySelector('.pauseov'), { timeout: 3000 });
await sleep(1200);
const t2 = await btime();
assert.ok(t2 == null || t2 > t1, `the battle runs again (${t1} → ${t2})`);
assert.deepEqual((await c.requests('g.pause')).map((r) => r[1]), [{ on: true }, { on: false }]);
await c.shot('pause-real-resumed');
assert.deepEqual(problemsOf([c]), []);
} finally {
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
await c.close();
await srv.stop();
}
});
test('3. server restart while a match is on screen: toast + back to the lobby', { timeout: 3 * 60 * 1000 }, async () => {
let srv = await startRealServer();
const port = srv.port;
const P = (await import('puppeteer-core')).default;
const c = new Client(P, srv.base, 'restart', { prefix: 'leftover' });
try {
await c.open();
await c.enter('重启');
await c.click('.mode-card', '独立模拟');
await c.click('.diff-card', '标准模拟');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor((s) => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
const before = await c.st();
await sleep(600);
// an imperative dialog left open (the app's own module instance) must not survive onto the lobby
await c.page.evaluate(() => { import('/js/ui/components.js').then((m) => { globalThis.__e2eDlg = m.confirmDialog({ title: '测试对话框', text: '服务器重启前打开' }); }); });
await c.page.waitForSelector('.modal', { timeout: 3000 });
// a crash / kill: no room.closed reaches the client — only the new session's welcome tells
await srv.stop({ hard: true });
await sleep(800);
srv = await startRealServer({ port });
await c.page.waitForFunction(() => [...document.querySelectorAll('.toast')].some((t) => t.textContent.includes('服务器会话已重置,上一局模拟已结束')), { timeout: 30000 });
await c.page.waitForSelector('.lobby-screen', { timeout: 10000 });
const s = await c.st();
assert.notEqual(s.me, before.me, 'a new server session');
assert.equal(s.room, null);
assert.equal(s.phase, null);
assert.equal(await c.exists('.modal'), false, 'no stale dialog');
assert.equal(await c.page.evaluate(() => globalThis.__e2eDlg), false, 'the dismissed confirm resolved as cancelled');
assert.equal(await c.exists('.awayov'), false);
const restoring = await c.page.evaluate(() => globalThis.__SP__.store.get().ui.restoring);
assert.equal(restoring, false);
await c.shot('restart');
// the lobby works on the new server: a new solo run starts
await c.click('.mode-card', '独立模拟');
await c.click('.diff-card', '标准模拟');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor((x) => !!x.room, 'a new solo room on the restarted server');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await c.waitFor((x) => x.phase === 'INFO_CHECK', 'briefing on the restarted server', 30000);
// a graceful stop (Ctrl+C / SIGTERM): room.closed 'shutdown' says so first, the same clean way back — and only once
const resetToast = () => c.page.evaluate(() => [...document.querySelectorAll('.toast')].some((t) => t.textContent.includes('服务器会话已重置')));
await c.page.waitForFunction(() => ![...document.querySelectorAll('.toast')].some((t) => t.textContent.includes('服务器会话已重置')), { timeout: 20000 });
await srv.stop();
await c.page.waitForFunction(() => [...document.querySelectorAll('.toast')].some((t) => t.textContent.includes('服务器维护中')), { timeout: 15000 });
await c.page.waitForSelector('.lobby-screen', { timeout: 10000 });
srv = await startRealServer({ port });
await c.waitFor((x) => x.room == null && x.phase == null, 'lobby after the graceful restart', 30000);
await c.page.waitForFunction(() => globalThis.__SP__.net.status === 'online', { timeout: 30000 });
await sleep(800);
assert.equal(await resetToast(), false, 'the room was already closed by room.closed: no second notice');
await c.shot('restart-graceful');
// only the downtime's socket errors are expected
const other = c.problems.filter((p) => !/WebSocket|ERR_CONNECTION_REFUSED|\/ws\b|net::ERR_/.test(p));
assert.deepEqual(other, []);
} finally {
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
await c.close();
await srv.stop();
}
});
});