- puppeteer-core ^23.11.1 -> ^25.12.0 (dev only); npm audit: 7 high (basic-ftp via @puppeteer/browsers / proxy-agent, extract-zip) -> 0; production audit stays clean - puppeteer-core 25 is ESM-only and needs Node >= 22.12: the render / sim browser suites load it with await import() instead of createRequire - headless: 'new' -> headless: true (same new headless mode; 'new' is no longer a typed value) - fieldview CPU throttle: page.emulateCPUThrottling() instead of the deprecated page.target() CDP session - THIRD-PARTY-NOTICES version, DESIGN test note
257 lines
17 KiB
JavaScript
257 lines
17 KiB
JavaScript
// Browser E2E of the 干员调配 overlay (DESIGN §16) against the real server, headless Chrome (puppeteer-core + system
|
||
// Chrome). Opt-in: SP_E2E=1 node --test test/ui/loadout.e2e.test.js
|
||
//
|
||
// Desktop 1920×1080: lobby → 干员调配 → search 隐现 → S1 + 不装备 (persisted in localStorage, synced with room.loadout)
|
||
// → create a 独立模拟 room → start → the briefing's m.private.loadout carries the choice, the briefing entry reopens the
|
||
// overlay, the solo briefing shows no countdown value from the server (deadline 0) → 准备就绪 closes the overlay
|
||
// (auto-close when INFO_CHECK ends). Phone 844×390 (touch): the roster → tap a card → the detail slides over → back.
|
||
// Module cards: the official module type icons from the local-client art (data/local-assets.json groups.module, optional)
|
||
// or the lettered tiles without it → test/e2e/out/module-icons.png.
|
||
// Every page must finish with zero console errors / page errors / failed requests. Screenshots: test/e2e/out/loadout-*.png
|
||
|
||
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 { fileURLToPath } from 'node:url';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
const INSIDE = 'chess_char_1_01_a';
|
||
/** Whether this machine has the extracted module type icons (local-client art is optional, DESIGN §13). */
|
||
function localModuleIcons() {
|
||
try { return !!JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8'))?.groups?.module; } catch { return false; }
|
||
}
|
||
|
||
describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||
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'] });
|
||
mkdirSync(OUT, { recursive: true });
|
||
});
|
||
after(async () => { await browser?.close(); await srv?.close(); });
|
||
|
||
async function open({ w = 1920, h = 1080, touch = false } = {}) {
|
||
const ctx = await browser.createBrowserContext();
|
||
const page = await ctx.newPage();
|
||
await page.setViewport({ width: w, height: h, hasTouch: touch, isMobile: touch, deviceScaleFactor: 1 });
|
||
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.evaluateOnNewDocument(() => {
|
||
localStorage.setItem('sp.name', '调配测试');
|
||
sessionStorage.setItem('sp.entered', '1');
|
||
});
|
||
await page.goto(`${base}/`, { waitUntil: 'domcontentloaded' });
|
||
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && !!document.querySelector('.lobby-screen'), { timeout: 30000 });
|
||
return { ctx, page, problems };
|
||
}
|
||
const st = (page) => page.evaluate(() => {
|
||
const s = globalThis.__SP__.store.get();
|
||
return { phase: s.match.public?.phase ?? null, deadline: s.match.public?.deadline ?? null, loadout: s.match.private?.loadout ?? null, room: s.room?.code ?? null };
|
||
});
|
||
async function waitSt(page, pred, what, timeout = 20000) {
|
||
const t0 = Date.now();
|
||
let last = null;
|
||
while (Date.now() - t0 < timeout) { last = await st(page); if (pred(last)) return last; await sleep(150); }
|
||
throw new Error(`timed out waiting for ${what}: ${JSON.stringify(last)}`);
|
||
}
|
||
const clickSel = async (page, sel) => {
|
||
await page.waitForSelector(sel, { visible: true, timeout: 10000 });
|
||
await page.click(sel);
|
||
};
|
||
|
||
test('desktop: choose S1 + 不装备 for 隐现, persisted, synced into the solo match, briefing entry, auto-close', async () => {
|
||
const { ctx, page, problems } = await open();
|
||
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
|
||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||
assert.equal(await page.$$eval('.lo-card', (els) => els.length), 112, 'every visible chess');
|
||
await page.screenshot({ path: path.join(OUT, 'loadout-desktop.png') });
|
||
// search → one card
|
||
await page.type('.lo-search input', '隐现');
|
||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||
await page.click('.lo-card');
|
||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||
await page.click('.lo-detail .lo-mod[data-module="none"]');
|
||
// elite-level descriptions toggle
|
||
await page.click('.lo-seg button:nth-child(2)');
|
||
await page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'));
|
||
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
|
||
assert.deepEqual(stored.entries, { [INSIDE]: { skill: 0, module: 'none' } });
|
||
await page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 5000 });
|
||
await page.screenshot({ path: path.join(OUT, 'loadout-detail.png') });
|
||
// close (Esc), create a solo room, start
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }));
|
||
await waitSt(page, (s) => !!s.room, 'room');
|
||
await page.waitForSelector('.room-screen [data-testid="loadout-open"]', { visible: true });
|
||
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
||
const brief = await waitSt(page, (s) => s.phase === 'INFO_CHECK' && s.loadout, 'briefing');
|
||
assert.deepEqual(brief.loadout, { [INSIDE]: { skill: 0, module: 'none' } }, 'the match fights with the loadout');
|
||
assert.equal(brief.deadline, 0, 'solo briefing: no deadline');
|
||
assert.equal(await page.$$eval('.brief .countdown', (els) => els.filter((e) => getComputedStyle(e).display !== 'none').length), 0,
|
||
'no countdown shown in the solo briefing');
|
||
// the briefing entry reopens it; 准备就绪 (INFO_CHECK ends) closes it
|
||
await clickSel(page, '.brief [data-testid="loadout-open"]');
|
||
await page.waitForSelector('.lo', { visible: true });
|
||
await page.screenshot({ path: path.join(OUT, 'loadout-briefing.png') });
|
||
await page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
|
||
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 5000 });
|
||
assert.deepEqual(problems, []);
|
||
await ctx.close();
|
||
});
|
||
|
||
test('solo briefing: an edit made right before 准备就绪 still applies to this match (closing the overlay sends it)', async () => {
|
||
const { ctx, page, problems } = await open();
|
||
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }));
|
||
await waitSt(page, (s) => !!s.room, 'room');
|
||
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
||
await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
|
||
await clickSel(page, '.brief [data-testid="loadout-open"]');
|
||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||
await page.type('.lo-search input', '隐现');
|
||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||
await page.click('.lo-card');
|
||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||
// choose S1, close and confirm at once — well inside the sync's debounce
|
||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||
await page.click('.brief__foot .btn--primary');
|
||
const s = await waitSt(page, (x) => x.phase && x.phase !== 'INFO_CHECK' && x.loadout && x.loadout[INSIDE], 'band draft with the loadout', 8000)
|
||
.catch(async (e) => { throw new Error(`${e.message} — last loadout ${JSON.stringify((await st(page)).loadout)}`); });
|
||
assert.equal(s.loadout[INSIDE].skill, 0, 'the match fights with the edit made just before 准备就绪');
|
||
assert.deepEqual(problems, []);
|
||
await ctx.close();
|
||
});
|
||
|
||
test('co-op briefing (review fix): the overlay shows the INFO_CHECK time left; an edit that misses the lock is told, not lost silently', async () => {
|
||
const { ctx, page, problems } = await open();
|
||
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' }));
|
||
const { room } = await waitSt(page, (s) => !!s.room, 'room');
|
||
// a second human joins: a single human is untimed like solo (Match.soloUntimed, user playtest #4 item 3)
|
||
const mate = await open();
|
||
await mate.page.evaluate((code) => globalThis.__SP__.net.request('room.join', { code }), room);
|
||
await waitSt(mate.page, (s) => s.room === room, 'the teammate in the room');
|
||
await mate.page.evaluate(() => globalThis.__SP__.net.request('room.ready', { ready: true }));
|
||
await page.evaluate(() => globalThis.__SP__.net.request('room.addBot', {}));
|
||
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
||
const brief = await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
|
||
assert.ok(brief.deadline > 0, 'co-op briefing (two humans) is timed (25 s)');
|
||
await clickSel(page, '.brief [data-testid="loadout-open"]');
|
||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||
// the briefing (and its countdown) is hidden under the overlay: the overlay carries the time left
|
||
await page.waitForSelector('.lo-top .lo-deadline', { visible: true, timeout: 3000 });
|
||
assert.match(await page.$eval('.lo-top .lo-deadline', (el) => el.getAttribute('aria-label')), /剩余\d+秒/);
|
||
await page.type('.lo-search input', '隐现');
|
||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||
await page.click('.lo-card');
|
||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||
// edit, and the briefing ends before the debounced send (every human ready: the requests stand in for the timer)
|
||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||
await mate.page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
|
||
await page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
|
||
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 5000 });
|
||
await page.waitForFunction(() => [...document.querySelectorAll('.toast__text')].some((e) => /下一局生效/.test(e.textContent)), { timeout: 5000 });
|
||
const s = await st(page);
|
||
assert.ok(!s.loadout || !s.loadout[INSIDE], 'the running match kept its locked loadout');
|
||
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
|
||
assert.deepEqual(stored.entries[INSIDE], { skill: 0 }, 'kept for the next match');
|
||
assert.deepEqual(problems, []);
|
||
assert.deepEqual(mate.problems, []);
|
||
await mate.ctx.close();
|
||
await ctx.close();
|
||
});
|
||
|
||
test('module cards: official type icons (PRI-X.png / pri-y.png, matched case-insensitively) or lettered tiles', async () => {
|
||
const { ctx, page, problems } = await open();
|
||
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
|
||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||
await page.type('.lo-search input', '烛煌');
|
||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||
await page.click('.lo-card');
|
||
await page.waitForSelector('.lo-detail .lo-mod[data-module="none"]', { visible: true });
|
||
// wait for every module tile to settle (image loaded or lettered fallback)
|
||
await page.waitForFunction(() => [...document.querySelectorAll('.lo-mod:not(.lo-mod--none) .lo-mglyph')]
|
||
.every((g) => g.querySelector('.lo-mglyph__t') || (g.querySelector('img')?.complete && g.querySelector('img').naturalWidth > 0)), { timeout: 5000 });
|
||
const tiles = await page.$$eval('.lo-mod:not(.lo-mod--none)', (els) => els.map((el) => ({
|
||
type: el.querySelector('.lo-mod__type')?.textContent || '',
|
||
src: el.querySelector('.lo-mglyph img')?.getAttribute('src') || null,
|
||
letter: el.querySelector('.lo-mglyph__t')?.textContent || null,
|
||
})));
|
||
assert.deepEqual(tiles.map((t) => t.type).sort(), ['PRI-X', 'PRI-Y']);
|
||
if (localModuleIcons()) {
|
||
for (const t of tiles) assert.match(t.src || '', new RegExp(`^/assets/local/module/${t.type}\\.png$`, 'i'), JSON.stringify(t));
|
||
assert.ok(await page.$('.lo-minfo__title .lo-minfo__icon'), 'the selected module\'s info shows its icon too');
|
||
} else {
|
||
for (const t of tiles) assert.equal(t.letter, t.type.slice(-1), JSON.stringify(t));
|
||
}
|
||
await page.hover('.lo-mod[data-module]:not(.is-on):not(.lo-mod--none)');
|
||
await page.screenshot({ path: path.join(OUT, 'module-icons.png') });
|
||
assert.deepEqual(problems, []);
|
||
await ctx.close();
|
||
});
|
||
|
||
test('phone 844×390 (touch): roster + detail side by side, tier / class filters, tap to choose', async () => {
|
||
const { ctx, page, problems } = await open({ w: 844, h: 390, touch: true });
|
||
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||
await page.screenshot({ path: path.join(OUT, 'loadout-phone.png') });
|
||
// review fix: the shared Button / TextField shrink to 6–7 px text on phones — the overlay's own controls stay readable
|
||
// and tappable (返回, 全部恢复默认, the detail's 恢复默认, the search input)
|
||
const ctl = await page.evaluate(() => Object.fromEntries(['.lo-back', '.lo-top__right .btn', '.lo-dhead__reset', '.lo-search .field__input'].map((sel) => {
|
||
const el = document.querySelector(sel);
|
||
const b = el.getBoundingClientRect();
|
||
return [sel, { h: Math.round(b.height), fs: parseFloat(getComputedStyle(el).fontSize) }];
|
||
})));
|
||
for (const [sel, v] of Object.entries(ctl)) {
|
||
assert.ok(v.fs >= 11, `${sel} text ≥ 11 px (${JSON.stringify(v)})`);
|
||
if (sel !== '.lo-search .field__input') assert.ok(v.h >= 28, `${sel} ≥ 28 px tall (${JSON.stringify(v)})`);
|
||
}
|
||
await page.tap('.lo-chip--t6');
|
||
await page.waitForFunction(() => [...document.querySelectorAll('.lo-card')].every((c) => c.classList.contains('lo-card--t6')));
|
||
await page.tap('.lo-chip--prof[title="狙击"]');
|
||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length >= 1);
|
||
await page.tap('.lo-card');
|
||
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
||
assert.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'side by side');
|
||
// every skill option is reachable inside the viewport (the detail body scrolls)
|
||
const r = await page.$eval('.lo-detail .lo-skill:last-child', (el) => { el.scrollIntoView({ block: 'nearest' }); const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; });
|
||
assert.ok(r.top >= 0 && r.bottom <= 390, JSON.stringify(r));
|
||
await page.tap('.lo-detail .lo-skill:last-child');
|
||
await page.waitForFunction(() => document.querySelector('.lo-detail .lo-skill:last-child').classList.contains('is-on'));
|
||
await page.screenshot({ path: path.join(OUT, 'loadout-phone-detail.png') });
|
||
assert.deepEqual(problems, []);
|
||
await ctx.close();
|
||
});
|
||
|
||
test('small phone 667×375 (touch): the detail slides over the roster and back', async () => {
|
||
const { ctx, page, problems } = await open({ w: 667, h: 375, touch: true });
|
||
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
|
||
await page.tap('.lo-card');
|
||
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
||
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'detail covers the roster');
|
||
await page.screenshot({ path: path.join(OUT, 'loadout-small-phone.png') });
|
||
await page.tap('.lo-detail-back');
|
||
await page.waitForSelector('.lo-roster', { visible: true });
|
||
assert.deepEqual(problems, []);
|
||
await ctx.close();
|
||
});
|
||
});
|