418 lines
26 KiB
JavaScript
418 lines
26 KiB
JavaScript
// 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, the item drawn back on its
|
||
// bench slot) → 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: true, 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) => { if (!/fonts\.(googleapis|gstatic)/.test(r.url())) 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]);
|
||
const benchSlot = await c.piecePoint(third.uid, 0.5); // where the view draws it in the hand
|
||
|
||
// 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');
|
||
// ... and is drawn back on its bench slot before it is picked up again. The view keeps a dropped piece on the drop
|
||
// tile for 1.3 s (render/app/tune.js DROP_PENDING_MS), then flies it back in a frame-timed tween: with few frames
|
||
// (software-rendered headless Chrome on a loaded machine) the drag aimed where it stood a moment before grabbed the
|
||
// operator under it, whose direction wheel then swallowed the retry (the 0.2.0 candidate's full pass).
|
||
const back = await c.page.waitForFunction((uid, x, y) => {
|
||
const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid);
|
||
return !!r && Math.abs(r.left + r.width / 2 - x) < 2 && Math.abs(r.top + r.height / 2 - y) < 2;
|
||
}, { timeout: 15000, polling: 100 }, third.uid, benchSlot.x, benchSlot.y).then(() => true, () => false);
|
||
assert.ok(back, '取消: the item is drawn back on its bench slot');
|
||
|
||
// 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();
|
||
}
|
||
});
|
||
});
|