- 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
203 lines
12 KiB
JavaScript
203 lines
12 KiB
JavaScript
// Browser regressions of user playtest #5, UI items 8 / 9, through the mock harness (public/dev/game-mock.html) in
|
||
// headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1.
|
||
//
|
||
// SP_E2E=1 node --test test/ui/playtest5-ui.e2e.test.js
|
||
//
|
||
// 9 during prep no bench (row 7) / temp (row 8) tile is covered by DOM overlays (the shop bar, the corner buttons, the
|
||
// tools) and the field's back row is free of the bond strip — own board and Final Assault half, at the phone
|
||
// landscape viewports (844×390 and 932×430 with and without the iPhone insets, 915×412, 800×360, 914×411,
|
||
// 960×411, 924×424, and the user's Android: 756×366 — its 2772×1272 px screenshot shows the page right of a
|
||
// 141 px black cutout band, 2631×1272 px at DPR ≈ 3.48, the exit and 准备就绪 buttons matching to ±1 px — and
|
||
// 798×366 with a 41 px left inset had the page covered the cutout) and at desktop 16:9 / 16:10 / 3:2 / 21:9, where
|
||
// the official shop camera is unchanged (122 px per bench tile at 1920×1080). Each tile top's projected quad is
|
||
// sampled on a 24 × 24 grid with elementFromPoint (every HUD element hit-testable); a sample counts as covered
|
||
// when the element there paints (background, border, text, image) — v2.3 left 71 % of bench pads 4–9 free at
|
||
// 844×390, 49 % at 800×360 and 54 % at 756×366 (the user's screenshot: pads 3–9 about half covered).
|
||
// 9 an armed shop card (first tap of the two-tap buy) at 756×366 rises ≤ 5 px above the bar and covers ≤ 4 px of
|
||
// the bench pads' near corners (accepted, ui/fieldHost.js hudBands)
|
||
// 8 the corner's ⚙ is the regular gear glyph (ui/gameComponents.js gearPath), square, as large as 📖 / ⛶ (half the
|
||
// grown button on phones)
|
||
// Screenshots: test/e2e/out/p5-*.png.
|
||
|
||
import { test, describe, before, after } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { existsSync, mkdirSync } 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 IPH = (s) => ({ l: s, r: s, t: 0, b: 21 }); // iPhone landscape safe-area insets (px)
|
||
/** name, width, height, touch, insets */
|
||
const VIEWS = [
|
||
['844x390', 844, 390, true, null], ['844x390-notch', 844, 390, true, IPH(47)], ['932x430', 932, 430, true, null],
|
||
['932x430-notch', 932, 430, true, IPH(59)], ['915x412', 915, 412, true, null], ['800x360', 800, 360, true, null],
|
||
['914x411', 914, 411, true, null], ['960x411', 960, 411, true, null], ['924x424', 924, 424, true, null],
|
||
['756x366', 756, 366, true, null], ['798x366-inset41', 798, 366, true, { l: 41, r: 0, t: 0, b: 0 }],
|
||
['1920x1080', 1920, 1080, false, null], ['1680x1050', 1680, 1050, false, null], ['1560x1040', 1560, 1040, false, null],
|
||
['2560x1080', 2560, 1080, false, null],
|
||
];
|
||
const view = (name) => VIEWS.find((v) => v[0] === name);
|
||
|
||
describe('user playtest #5 — UI items 8 / 9 (mock harness, 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(query, { w, h, touch, insets }) {
|
||
const page = await browser.newPage();
|
||
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
|
||
const problems = [];
|
||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||
await page.goto(`${base}/dev/game-mock.html?shot=1&${query}`, { waitUntil: 'networkidle0' });
|
||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
|
||
if (insets) {
|
||
await page.evaluate((i) => {
|
||
const s = document.documentElement.style;
|
||
s.setProperty('--sa-l', `${i.l}px`); s.setProperty('--sa-r', `${i.r}px`); s.setProperty('--sa-t', `${i.t}px`); s.setProperty('--sa-b', `${i.b}px`);
|
||
}, insets);
|
||
await page.evaluate(() => globalThis.__SP_VIEW__.raw.resize());
|
||
}
|
||
await sleep(1800);
|
||
return { page, problems };
|
||
}
|
||
|
||
/** Free fraction of each tile top of the listed board tiles (board coordinates; the boss prep maps them itself). */
|
||
const freeFractions = (page, tiles) => page.evaluate((tiles) => {
|
||
const view = globalThis.__SP_VIEW__.raw;
|
||
const st = document.createElement('style');
|
||
st.textContent = '.gm__hud, .gm__hud * { pointer-events: auto !important; }';
|
||
document.head.appendChild(st);
|
||
const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(c);
|
||
const paints = (el) => {
|
||
if (!el || el.tagName === 'CANVAS' || el.closest('.gm__field')) return false;
|
||
const cs = getComputedStyle(el);
|
||
if (cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
|
||
if (['IMG', 'svg', 'path', 'B', 'SPAN', 'KBD', 'I', 'P', 'LABEL'].includes(el.tagName)) return true;
|
||
if (!clear(cs.backgroundColor) || cs.backgroundImage !== 'none') return true;
|
||
if (['Top', 'Right', 'Bottom', 'Left'].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && !clear(cs[`border${s}Color`]))) return true;
|
||
return [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
||
};
|
||
const inside = (poly, x, y) => {
|
||
let c = false;
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const [xi, yi] = poly[i], [xj, yj] = poly[j];
|
||
if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) c = !c;
|
||
}
|
||
return c;
|
||
};
|
||
const out = tiles.map(([r, c]) => {
|
||
const t = view.tileScreen(r, c);
|
||
if (!t) return { r, c, free: -1 };
|
||
const xs = t.poly.map((p) => p[0]), ys = t.poly.map((p) => p[1]);
|
||
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
|
||
let n = 0, free = 0;
|
||
const N = 24;
|
||
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
|
||
const x = x0 + (x1 - x0) * (i + 0.5) / N, y = y0 + (y1 - y0) * (j + 0.5) / N;
|
||
if (!inside(t.poly, x, y)) continue;
|
||
n++;
|
||
if (x >= 0 && y >= 0 && x < innerWidth && y < innerHeight && !paints(document.elementFromPoint(x, y))) free++;
|
||
}
|
||
return { r, c, free: n ? free / n : 0, s: t.s };
|
||
});
|
||
st.remove();
|
||
return out;
|
||
}, tiles);
|
||
|
||
const BENCH = Array.from({ length: 10 }, (_, c) => [7, c]);
|
||
const TEMP = [4, 5, 6, 7, 8].map((c) => [8, c]);
|
||
const BACK = [4, 5, 6, 7, 8, 9, 10].map((c) => [12, c]); // the playable back row of 战场#01 (cols 0–3: forbidden)
|
||
|
||
test('9: bench, temp and the back row are free of the HUD in prep and in the Final Assault prep', { timeout: 10 * 60 * 1000 }, async () => {
|
||
const report = [];
|
||
for (const [name, w, h, touch, insets] of VIEWS) {
|
||
for (const variant of ['', 'boss']) {
|
||
const { page, problems } = await open(`phase=PREP${variant ? `&variant=${variant}` : ''}`, { w, h, touch, insets });
|
||
const tag = `${name}${variant ? ' (Final Assault prep)' : ''}`;
|
||
const bench = await freeFractions(page, BENCH);
|
||
const temp = await freeFractions(page, TEMP);
|
||
const back = await freeFractions(page, BACK);
|
||
for (const t of [...bench, ...temp]) assert.ok(t.free >= 0.99, `${tag}: ${t.r === 7 ? 'bench' : 'temp'} ${t.c} ${Math.round(t.free * 100)} % free`);
|
||
for (const t of back) assert.ok(t.free >= 0.99, `${tag}: back row ${t.c} ${Math.round(t.free * 100)} % free`);
|
||
// desktop 16:9 keeps the official shop camera
|
||
if (name === '1920x1080' && !variant) assert.ok(Math.abs(bench[5].s - 122) < 1, `1920×1080 px per bench tile ${bench[5].s}`);
|
||
report.push(`${tag}: bench ${Math.round(Math.min(...bench.map((t) => t.free)) * 100)} %, ${Math.round(bench[5].s)} px/tile`);
|
||
if (['844x390', '756x366', '800x360', '1920x1080'].includes(name)) await page.screenshot({ path: path.join(OUT, `p5-bench-${name}${variant ? '-boss' : ''}.png`) });
|
||
assert.deepEqual(problems, [], tag);
|
||
await page.close();
|
||
}
|
||
}
|
||
console.log(report.join('\n'));
|
||
});
|
||
|
||
test('9: an armed shop card (two-tap buy) barely reaches the bench on the user\'s phone', async () => {
|
||
// accepted overlap (ui/fieldHost.js hudBands): the armed card rises 4 px above the bar at 1rem = 40 px and covers
|
||
// the pads' near corners by ≈ 3 px — less than under the official camera at 1920×1080 (13 px / ≈ 11 px)
|
||
const [, w, h, touch] = view('756x366');
|
||
const { page, problems } = await open('phase=PREP', { w, h, touch });
|
||
const cards = await page.$$('.scard:not(.scard--sold)');
|
||
assert.ok(cards.length > 0, 'a shop card to arm');
|
||
await cards[0].tap();
|
||
await sleep(400);
|
||
const r = await page.evaluate(() => {
|
||
const a = document.querySelector('.scard.is-armed');
|
||
if (!a) return null;
|
||
const b = a.getBoundingClientRect(), row = document.querySelector('.shopbar__row').getBoundingClientRect();
|
||
const view = globalThis.__SP_VIEW__.raw;
|
||
let over = 0;
|
||
for (let c = 0; c < 10; c++) {
|
||
const t = view.tileScreen(7, c);
|
||
const xs = t.poly.map((p) => p[0]), y1 = Math.max(...t.poly.map((p) => p[1]));
|
||
if (Math.max(...xs) > b.left && Math.min(...xs) < b.right) over = Math.max(over, y1 - b.top);
|
||
}
|
||
return { lift: row.top - b.top, over };
|
||
});
|
||
assert.ok(r, 'the first tap arms the card');
|
||
assert.ok(r.lift <= 5, `armed card ${r.lift} px above the bar`);
|
||
assert.ok(r.over <= 4, `armed card over the bench pads by ${r.over} px`);
|
||
assert.deepEqual(problems, []);
|
||
await page.close();
|
||
});
|
||
|
||
test('8: the corner gear is the regular glyph, square and as large as its neighbours', async () => {
|
||
const { GLYPHS } = await import('../../public/js/ui/gameComponents.js');
|
||
for (const [name, w, h, touch] of [view('844x390'), view('756x366'), view('1920x1080')]) {
|
||
const { page, problems } = await open('phase=PREP', { w, h, touch });
|
||
const r = await page.evaluate(() => [...document.querySelectorAll('.gm__corner .gm__gear')].map((b) => {
|
||
const svg = b.querySelector('svg.icon');
|
||
const s = svg.getBoundingClientRect(), br = b.getBoundingClientRect();
|
||
return { cls: b.className, w: s.width, h: s.height, bw: br.width, d: svg.querySelector('path')?.getAttribute('d') };
|
||
}));
|
||
assert.equal(r.length, 3, `${name}: ⚙ 📖 ⛶`);
|
||
assert.equal(r[0].d, GLYPHS.gear, `${name}: the gear glyph`);
|
||
for (const b of r) {
|
||
assert.ok(Math.abs(b.w - b.h) < 0.01, `${name}: ${b.cls} square (${b.w}×${b.h})`);
|
||
assert.ok(b.w >= 0.45 * b.bw && b.w <= 0.55 * b.bw, `${name}: ${b.cls} icon ${b.w} in a ${b.bw} button`);
|
||
}
|
||
await page.screenshot({ path: path.join(OUT, `p5-corner-${name}.png`), clip: { x: 0, y: h - 90, width: 420, height: 90 } });
|
||
assert.deepEqual(problems, [], name);
|
||
await page.close();
|
||
}
|
||
});
|
||
});
|