201 lines
9.7 KiB
JavaScript
201 lines
9.7 KiB
JavaScript
// Real server and normal UI operations only: create/join, ready, skip and confirm.
|
|
// No mocked draft, injected game state, direct network requests or fixed seed.
|
|
// SP_E2E=1 node --test test/ui/band-draft-skip.e2e.test.js
|
|
import { test, describe, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const OUT = path.join(ROOT, 'test/e2e/out/band-draft-skip');
|
|
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
|
const SKIP = '.draft-detail__btns .btn:not(.btn--primary)';
|
|
|
|
describe('strategy skip eligibility (real server, normal browser operations)', {
|
|
skip: !ENABLED && 'set SP_E2E=1 and have Chrome to run',
|
|
}, () => {
|
|
let srv;
|
|
let browser;
|
|
let base;
|
|
const observations = [];
|
|
|
|
before(async () => {
|
|
mkdirSync(OUT, { recursive: true });
|
|
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', '--no-first-run', '--mute-audio', '--disable-background-timer-throttling'] });
|
|
});
|
|
after(async () => {
|
|
try {
|
|
writeFileSync(path.join(OUT, 'observations.json'), JSON.stringify(observations, null, 2));
|
|
} finally {
|
|
const proc = browser?.process();
|
|
try { await browser?.close(); } finally {
|
|
for (const stream of [proc?.stdout, proc?.stderr]) stream?.destroy();
|
|
await srv?.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
async function state(page) {
|
|
return page.evaluate(() => {
|
|
const s = globalThis.__SP__.store.get();
|
|
return { me: s.me.playerId, code: s.room?.code, phase: s.match.public?.phase,
|
|
draft: s.match.public?.draft, bandId: s.match.private?.bandId };
|
|
});
|
|
}
|
|
async function click(page, selector, text = '') {
|
|
await page.waitForFunction((sel, txt) => [...document.querySelectorAll(sel)]
|
|
.some((el) => !el.disabled && el.textContent.includes(txt)), { timeout: 10000 }, selector, text);
|
|
const buttons = await page.$$(selector);
|
|
for (const button of buttons) {
|
|
const match = await button.evaluate((el, txt) => !el.disabled && el.textContent.includes(txt), text);
|
|
if (match) { await button.click(); return; }
|
|
}
|
|
assert.fail(`no enabled ${selector} ${text}`);
|
|
}
|
|
async function player(name, suffix = '') {
|
|
const context = await browser.createBrowserContext();
|
|
const page = await context.newPage();
|
|
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
|
const errors = [];
|
|
const frames = [];
|
|
page.on('pageerror', (e) => errors.push(e.stack || e.message));
|
|
const cdp = await page.createCDPSession();
|
|
await cdp.send('Network.enable');
|
|
// Observe only: retain skip requests and replies, never handshake tokens or session data.
|
|
cdp.on('Network.webSocketFrameSent', ({ response }) => {
|
|
try { const m = JSON.parse(response.payloadData); if (m.t === 'g.bandSkip') frames.push({ direction: 'sent', message: m }); } catch { /* non-JSON */ }
|
|
});
|
|
cdp.on('Network.webSocketFrameReceived', ({ response }) => {
|
|
try { const m = JSON.parse(response.payloadData); if (m.t === 'error') frames.push({ direction: 'received', message: m }); } catch { /* non-JSON */ }
|
|
});
|
|
await page.goto(base + '/' + suffix, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('.title-login input');
|
|
await page.type('.title-login input', name);
|
|
await click(page, '.title-login button', '开始');
|
|
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online');
|
|
return { context, page, errors, frames };
|
|
}
|
|
async function draft(humans, mode = 'coop') {
|
|
const clients = [await player('轮选测试1')];
|
|
const host = clients[0].page;
|
|
await click(host, '.mode-card', mode === 'solo' ? '独立模拟' : '同盟模拟');
|
|
await click(host, '.create-box button', mode === 'solo' ? '开始独立模拟' : '创建同盟');
|
|
await host.waitForFunction(() => !!globalThis.__SP__.store.get().room?.code);
|
|
const code = (await state(host)).code;
|
|
for (let i = 1; i < humans; i++) {
|
|
const c = await player(`轮选测试${i + 1}`, `?room=${code}`);
|
|
clients.push(c);
|
|
await c.page.waitForFunction((code) => globalThis.__SP__.store.get().room?.code === code, {}, code);
|
|
await click(c.page, '.room-bar__right button', '准备就绪');
|
|
}
|
|
if ((await state(host)).phase !== 'INFO_CHECK') await click(host, '.room-bar__right button', '开始模拟');
|
|
for (const c of clients) {
|
|
await c.page.waitForSelector('.brief__foot .btn--primary');
|
|
await click(c.page, '.brief__foot .btn--primary', '准备就绪');
|
|
}
|
|
await host.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'BAND_DRAFT');
|
|
return clients;
|
|
}
|
|
async function current(clients) {
|
|
const s = await state(clients[0].page);
|
|
for (const c of clients) if ((await state(c.page)).me === s.draft.turn) {
|
|
await c.page.waitForFunction(() => {
|
|
const s = globalThis.__SP__.store.get();
|
|
return s.match.public?.draft?.turn === s.me.playerId;
|
|
});
|
|
return c;
|
|
}
|
|
assert.fail('current human not found');
|
|
}
|
|
async function capture(c, name) {
|
|
await c.page.waitForSelector(SKIP);
|
|
const s = await state(c.page);
|
|
const disabled = await c.page.$eval(SKIP, (el) => el.disabled);
|
|
const toasts = await c.page.$$eval('.toast', (els) => els.map((el) => el.textContent));
|
|
const file = `${name}.png`;
|
|
await c.page.screenshot({ path: path.join(OUT, file), fullPage: true });
|
|
const result = { name, ...s, disabled, toasts, file, frames: c.frames.slice() };
|
|
observations.push(result);
|
|
return result;
|
|
}
|
|
async function confirm(c) {
|
|
await click(c.page, '.dband:not(.is-taken)');
|
|
await click(c.page, '.draft-detail__btns .btn--primary', '确认选择');
|
|
await c.page.waitForFunction(() => !!globalThis.__SP__.store.get().match.private?.bandId);
|
|
}
|
|
async function close(clients) {
|
|
const runtimeErrors = clients.flatMap((c) => c.errors);
|
|
try { assert.deepEqual(runtimeErrors, [], 'no page errors during normal operations'); } finally {
|
|
for (const c of clients) await c.context.close();
|
|
observations.push({ name: 'browser-cleanup', runtimeErrors,
|
|
errorsAfterClose: clients.flatMap((c) => c.errors) });
|
|
}
|
|
}
|
|
|
|
for (const humans of [2, 3, 4]) test(`${humans} humans: initial/middle turns enabled, last disabled with quota left`, { timeout: 120000 }, async () => {
|
|
const clients = await draft(humans);
|
|
try {
|
|
for (let i = 0; i < humans; i++) {
|
|
const c = await current(clients);
|
|
const s = await capture(c, `${humans}p-position-${i + 1}`);
|
|
assert.equal(s.draft.skipsLeft[s.me], 1, 'no player has skipped');
|
|
assert.equal(s.disabled, i === humans - 1, 'skip only while another player remains');
|
|
if (i === humans - 1) {
|
|
const before = c.frames.length;
|
|
// A real click on the disabled button must send nothing and leave the turn unchanged.
|
|
await c.page.click(SKIP);
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
assert.equal((await state(c.page)).draft.turn, s.me);
|
|
assert.equal(c.frames.length, before);
|
|
}
|
|
await confirm(c);
|
|
}
|
|
await clients[0].page.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase !== 'BAND_DRAFT');
|
|
} finally { await close(clients); }
|
|
});
|
|
|
|
test('spent quota stays disabled even with another unpicked player; waiting/selected players disabled', { timeout: 120000 }, async () => {
|
|
const clients = await draft(3);
|
|
try {
|
|
const first = await current(clients);
|
|
const firstId = (await state(first.page)).me;
|
|
const waiting = clients.find((c) => c !== first);
|
|
assert.equal((await capture(waiting, '3p-waiting')).disabled, true);
|
|
await click(first.page, SKIP, '跳过');
|
|
await first.page.waitForFunction((id) => globalThis.__SP__.store.get().match.public.draft.turn !== id, {}, firstId);
|
|
const second = await current(clients);
|
|
await click(second.page, SKIP, '跳过');
|
|
const secondId = (await state(second.page)).me;
|
|
await second.page.waitForFunction((id) => globalThis.__SP__.store.get().match.public.draft.turn !== id, {}, secondId);
|
|
await confirm(await current(clients));
|
|
await first.page.waitForFunction((id) => globalThis.__SP__.store.get().match.public.draft.turn === id, {}, firstId);
|
|
const spent = await capture(first, '3p-spent-quota-with-successor');
|
|
assert.equal(spent.draft.skipsLeft[firstId], 0);
|
|
assert.equal(spent.draft.order.filter((id) => !spent.draft.picks[id]).length, 2);
|
|
assert.equal(spent.disabled, true);
|
|
const before = first.frames.length;
|
|
await first.page.click(SKIP);
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
assert.equal(first.frames.length, before);
|
|
await confirm(first);
|
|
assert.equal((await capture(first, '3p-already-selected')).disabled, true);
|
|
await confirm(await current(clients));
|
|
} finally { await close(clients); }
|
|
});
|
|
|
|
test('solo draft has no skip button', { timeout: 60000 }, async () => {
|
|
const clients = await draft(1, 'solo');
|
|
try {
|
|
assert.equal(await clients[0].page.$(SKIP), null);
|
|
await confirm(clients[0]);
|
|
} finally { await close(clients); }
|
|
});
|
|
});
|