Files
ark-stronghold-protocol/test/e2e/client.mjs
T

486 lines
24 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 helpers (puppeteer-core + the system Chrome): a real game server in a child process and a `Client`
// per player that drives the real UI with real mouse clicks, keys and canvas drags (no net.request shortcuts).
// Used by test/e2e/coop.e2e.mjs. Not a test file itself (it only exports helpers).
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync } from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
export const OUT = path.join(ROOT, 'test/e2e/out');
export const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const hasChrome = () => existsSync(CHROME);
const CHROME_ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-user-gesture-required', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--mute-audio', '--force-device-scale-factor=1'];
/** The CDP protocol timeout of every Client's browser: one DevTools call that takes longer fails (a hung page fails fast). */
export const PROTOCOL_TIMEOUT_MS = 90000;
/**
* `page.waitForFunction` for a wait that may outlast PROTOCOL_TIMEOUT_MS. Puppeteer awaits the whole poll inside ONE
* `Runtime.callFunctionOn`, so a predicate that is still false when the protocol timeout strikes rejects the wait with the
* bare "Waiting failed" (cause: "Runtime.callFunctionOn timed out") long before the wait's own `timeout` — e.g. an own
* battle at 1× that lasts longer than 90 s. This polls the same predicate in slices shorter than the protocol timeout until
* `timeout`: the condition and the deadline stay the same. Resolves to the predicate's JSHandle, rejects with a
* TimeoutError once `timeout` is over and at once with any other error.
* @param {{ waitForFunction: Function }} page
* @param {Function|string} fn
* @param {{ timeout?: number, polling?: number|string, slice?: number }} [opts]
* @param {...any} args passed to `fn` as with page.waitForFunction
*/
export async function waitForFunctionLong(page, fn, { timeout = 30000, polling = 250, slice = PROTOCOL_TIMEOUT_MS / 3 } = {}, ...args) {
const deadline = Date.now() + timeout;
for (;;) {
try {
return await page.waitForFunction(fn, { timeout: Math.max(1, Math.min(slice, deadline - Date.now())), polling }, ...args);
} catch (e) {
if (e?.name !== 'TimeoutError') throw e;
if (Date.now() >= deadline) {
const err = new Error(`Waiting failed: ${timeout}ms exceeded`, { cause: e });
err.name = 'TimeoutError';
throw err;
}
}
}
}
/** A free TCP port on 127.0.0.1. */
export function freePort() {
return new Promise((resolve, reject) => {
const s = net.createServer();
s.once('error', reject);
s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
});
}
/**
* Start the real server (`node server/index.js`, or test/e2e/fastServer.mjs with `fast` speed-ups) on a free port (or
* `port`: e.g. the port of a server just stopped — a server restart the open clients reconnect to).
* @param {{ port?: number, fast?: { timerScale?: number, combatSpeed?: number, startRound?: 'boss'|'hidden'|number, kit?: number, chess?: string[],
* items?: string[], idleBots?: boolean, kits?: string[][], botChess?: string[], autoPlace?: boolean, eliminate?: number[], stage?: string,
* finishAfter?: number, slowSpawns?: string } }} [opts]
* kits: per-human starter kits (seat order); botChess: the AI seats' kit; autoPlace: the kits go onto the board at the
* first prep; eliminate: humans (seat order) eliminated at the jump; stage: the stage of every match; finishAfter: the
* match ends (RESULT) once that round has settled; slowSpawns: '<humanIdx>:<factor>' — that human's normal battles
* spawn at factor × the scheduled times — fastServer.mjs hooks
* @returns {Promise<{ base: string, port: number, logs: string[], stop: (o?: { hard?: boolean }) => Promise<void> }>}
*/
export async function startRealServer(opts = {}) {
const port = Number.isInteger(opts.port) && opts.port > 0 ? opts.port : await freePort();
const entry = opts.fast ? path.join(ROOT, 'test/e2e/fastServer.mjs') : path.join(ROOT, 'server/index.js');
const env = { ...process.env, PORT: String(port), HOST: '127.0.0.1' };
// under `node --test` the runner's marker would turn fastServer.mjs into a no-op module (it is not a test file)
delete env.NODE_TEST_CONTEXT;
if (opts.fast) {
env.SP_TIMER_SCALE = String(opts.fast.timerScale ?? 1);
env.SP_COMBAT_SPEED = String(opts.fast.combatSpeed ?? 2);
// test hook of fastServer.mjs: the first round of every match (e.g. 'boss') + a starter kit of operators
if (opts.fast.startRound != null) env.SP_START_ROUND = String(opts.fast.startRound);
if (opts.fast.kit != null) env.SP_START_KIT = String(opts.fast.kit);
if (opts.fast.chess?.length) env.SP_START_CHESS = opts.fast.chess.join(',');
if (opts.fast.items?.length) env.SP_START_ITEMS = opts.fast.items.join(',');
if (opts.fast.shop?.length) env.SP_START_SHOP = opts.fast.shop.join(',');
if (opts.fast.idleBots) env.SP_IDLE_BOTS = '1';
if (Array.isArray(opts.fast.kits)) env.SP_START_KITS = JSON.stringify(opts.fast.kits);
if (opts.fast.botChess?.length) env.SP_BOT_CHESS = opts.fast.botChess.join(',');
if (opts.fast.autoPlace) env.SP_AUTO_PLACE = '1';
if (opts.fast.eliminate?.length) env.SP_ELIMINATE = opts.fast.eliminate.join(',');
if (opts.fast.stage) env.SP_STAGE = String(opts.fast.stage);
if (opts.fast.level) env.SP_START_LEVEL = String(opts.fast.level);
if (opts.fast.finishAfter) env.SP_FINISH_AFTER = String(opts.fast.finishAfter);
if (opts.fast.slowSpawns) env.SP_SLOW_SPAWNS = String(opts.fast.slowSpawns);
}
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
const logs = [];
const onData = (buf) => { for (const line of String(buf).split('\n')) if (line.trim()) logs.push(line); if (logs.length > 400) logs.splice(0, logs.length - 400); };
child.stdout.on('data', onData);
child.stderr.on('data', onData);
const base = `http://127.0.0.1:${port}`;
const t0 = Date.now();
for (;;) {
if (child.exitCode != null) throw new Error(`server exited (${child.exitCode}):\n${logs.join('\n')}`);
try {
const res = await fetch(`${base}/healthz`);
if (res.ok) break;
} catch { /* not up yet */ }
if (Date.now() - t0 > 20000) { child.kill('SIGKILL'); throw new Error(`server did not start:\n${logs.join('\n')}`); }
await sleep(150);
}
// the content-hash index (server/http/assetIndex.js, built ~1 s after the start): wait for it, so a page loads the
// served manifest's /assets/~<hash>/… URLs like a player's would (none: SP_ASSET_INDEX=0); bounded, never fatal
for (const t1 = Date.now(); Date.now() - t1 < 20000;) {
const h = await fetch(`${base}/healthz?traffic=1`).then((r) => r.json(), () => null);
if (!h || !h.assetHashes || h.assetHashes.ready) break;
await sleep(150);
}
return {
base, port, logs,
// `{ hard: true }`: SIGKILL at once — a crash: no room.closed 'shutdown' reaches the clients
stop: ({ hard = false } = {}) => new Promise((resolve) => {
if (child.exitCode != null || child.signalCode != null) { resolve(); return; }
const t = setTimeout(() => { child.kill('SIGKILL'); }, 4000);
child.once('exit', () => { clearTimeout(t); resolve(); });
child.kill(hard ? 'SIGKILL' : 'SIGTERM');
}),
};
}
/**
* One player: its own Chrome process with a single, always visible tab (two tabs of one browser would leave one
* hidden — no animation frames, so the Pixi view and visibility checks stall).
*/
export class Client {
constructor(puppeteer, base, label, { w = 1920, h = 1080, prefix = 'coop' } = {}) {
Object.assign(this, { puppeteer, base, label, w, h, prefix, problems: [], log: [], shots: [] });
}
async open(query = '') {
mkdirSync(OUT, { recursive: true });
this.browser = await this.puppeteer.launch({ executablePath: CHROME, headless: true, args: CHROME_ARGS, protocolTimeout: PROTOCOL_TIMEOUT_MS });
const [first] = await this.browser.pages();
const page = first || await this.browser.newPage();
this.page = page;
await page.setViewport({ width: this.w, height: this.h });
page.on('console', (m) => {
if (m.type() === 'error') this.problems.push(`console: ${m.text()} @ ${m.location()?.url || ''}`);
});
page.on('pageerror', (e) => this.problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => {
const err = r.failure()?.errorText || '';
if (err === 'net::ERR_ABORTED' && /\.(mp3|ogg|wav|m4a)(\?|$)/.test(r.url())) return; // media element swaps
this.problems.push(`requestfailed: ${r.url()} ${err}`);
});
page.on('response', (r) => { if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`); });
page.on('dialog', (d) => d.dismiss().catch(() => {}));
await page.goto(`${this.base}/${query}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
}
note(msg) { this.log.push(`[${this.label}] ${msg}`); }
/** Compact snapshot of the client store. */
st() {
return this.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
const priv = s.match.private;
const me = pub?.players?.find((p) => p.playerId === s.me.playerId) || null;
return {
me: s.me.playerId, room: s.room ? { code: s.room.code, inMatch: !!s.room.inMatch, mode: s.room.mode } : null,
phase: pub?.phase ?? null, round: pub?.round ?? 0, bossRound: pub?.bossRound ?? null, status: me?.status ?? null, alive: priv ? priv.alive !== false : true,
camera: document.querySelector('.gm')?.dataset.camera ?? null,
ready: !!priv?.ready, canReady: priv?.canReady !== false, funds: priv?.funds ?? null, level: priv?.shop?.level ?? null,
frozen: !!priv?.shop?.frozen, lp: priv?.lp ?? null, kills: priv?.stats?.kills ?? null, leaks: priv?.stats?.leaks ?? null,
upgradePrice: priv?.shop?.upgradePrice ?? null, refreshPrice: priv?.shop?.refreshPrice ?? null,
deployCap: priv?.deployCap ?? 8, deployCount: priv?.deployCount ?? 0, reward: !!priv?.shop?.rewardOffer,
hand: (priv?.hand || []).filter(Boolean).length, temp: (priv?.temp || []).filter(Boolean).length,
board: (priv?.board || []).length, result: !!s.match.result, field: s.match.field?.fieldId ?? null,
fields: (pub?.fields || []).map((f) => `${f.fieldId}:${f.kind}:${f.live ? 1 : 0}`),
sp: pub?.sp ? { turn: pub.sp.turn, picked: Object.keys(pub.sp.picks || {}).length } : null,
draft: pub?.draft ? { turn: pub.draft.turn, picks: Object.keys(pub.draft.picks || {}).length } : null,
};
});
}
async waitFor(pred, what, timeout = 60000) {
const t0 = Date.now();
let last = null;
while (Date.now() - t0 < timeout) {
last = await this.st();
if (pred(last)) return last;
await sleep(200);
}
throw new Error(`${this.label}: timed out waiting for ${what} (last: ${JSON.stringify(last)})`);
}
async close() {
const child = this.browser?.process();
await this.browser?.close().catch(() => {});
// Chrome's crash/update helpers can inherit stderr after the launched browser exits. Release only our
// exited child's pipes; otherwise readline keeps the test alive after all its assertions have finished.
if (child && (child.exitCode != null || child.signalCode != null)) {
for (const stream of child.stdio) stream?.destroy?.();
}
}
/** Real mouse click on the `nth` visible, uncovered element matching `sel` whose text includes `text`. */
async click(sel, text = null, { timeout = 15000, optional = false, nth = 0, button = 'left', any = false } = {}) {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
const pt = await this.point(sel, text, nth, any);
if (pt) {
await this.page.mouse.click(pt.x, pt.y, { button });
// 准备 with funds left asks first (剩余资金). Accept it so a test that means "start the fight" is not stuck in PREP.
if (sel === '.readybtn') await this.confirmFundsLeft();
return true;
}
await sleep(150);
}
if (optional) return false;
throw new Error(`${this.label}: nothing clickable for ${sel}${text ? ` "${text}"` : ''}`);
}
/** If the 剩余资金 confirm is up, press 准备就绪. No dialog is a no-op. */
async confirmFundsLeft() {
await sleep(250);
const open = await this.page.evaluate(() => {
const t = document.querySelector('.modal__title');
return !!(t && t.textContent.includes('剩余资金'));
});
if (!open) return false;
return this.click('.modal__actions button', '准备就绪', { timeout: 4000 });
}
/** Centre of the nth visible, enabled (unless `any`), uncovered match (or null). */
point(sel, text = null, nth = 0, any = false) {
return this.page.evaluate((sel, text, nth, any) => {
let n = 0;
for (const el of document.querySelectorAll(sel)) {
if (text && !(el.textContent || '').includes(text)) continue;
if (!any && (el.disabled || el.getAttribute('aria-disabled') === 'true')) continue;
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) continue;
const x = r.left + r.width / 2; const y = r.top + r.height / 2;
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) continue;
const top = document.elementFromPoint(x, y);
if (top && !el.contains(top) && !top.contains(el)) continue; // covered
if (n++ < nth) continue;
return { x, y };
}
return null;
}, sel, text, nth, any);
}
async exists(sel) { return !!(await this.page.$(sel)); }
async visible(sel) {
return this.page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return false;
const r = el.getBoundingClientRect();
return r.width > 1 && r.height > 1;
}, sel);
}
async shot(name) {
const file = `${this.prefix}-${name}${this.w === 1280 ? '-720' : ''}.png`;
await this.page.screenshot({ path: path.join(OUT, file) });
this.shots.push(file);
return file;
}
/** Title → lobby with a callsign. */
async enter(name) {
await this.page.waitForSelector('.title-login input', { timeout: 20000 });
await this.click('.title-login input');
await this.page.keyboard.type(name);
await this.click('.title-login button', '开始');
await this.page.waitForSelector('.lobby-screen', { timeout: 20000 });
}
// ---- field view (render engine debug hooks) -------------------------------------------------------------------
viewKind() { return this.page.evaluate(() => globalThis.__SP_VIEW__?.kind ?? null); }
viewStats() { return this.page.evaluate(() => { try { return globalThis.__SP_VIEW__?.raw?.stats?.() ?? null; } catch { return null; } }); }
/** Client point to grab a prep piece (`at` = fraction of the piece rect's height, from the top). */
piecePoint(uid, at = 0.72) {
return this.page.evaluate((uid, at) => {
const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid);
return r && r.width > 0 ? { x: r.left + r.width / 2, y: r.top + r.height * at } : null;
}, uid, at);
}
/** Record the view's pieceDrop events (for diagnostics) and return + clear them. */
drops() {
return this.page.evaluate(() => {
const v = globalThis.__SP_VIEW__;
if (v && !v.__e2eHooked) { v.__e2eHooked = true; globalThis.__e2eDrops = []; v.on('pieceDrop', (e) => globalThis.__e2eDrops.push(e)); v.on('pieceDragStart', (e) => globalThis.__e2eDrops.push({ start: e?.uid })); }
const out = globalThis.__e2eDrops || [];
globalThis.__e2eDrops = [];
return JSON.stringify(out);
});
}
/** Visible toast texts. */
toasts() { return this.page.evaluate(() => [...document.querySelectorAll('.toast')].map((t) => t.textContent.trim()).filter(Boolean)); }
/**
* Client point of a board tile centre (top face). BOARD coordinates: the view's `tileScreen` maps them to where the
* tile is drawn (the Final Assault prep shows the own board on a half of the boss field, mirrored on the right).
*/
tilePoint(row, col) {
return this.page.evaluate((row, col) => {
const raw = globalThis.__SP_VIEW__?.raw;
if (typeof raw?.tileScreen === 'function') {
const t = raw.tileScreen(row, col);
if (t && Number.isFinite(t.x) && Number.isFinite(t.y)) return { x: t.x, y: t.y };
}
const cam = raw?.debug?.cam;
if (!cam) return null;
const z = raw.debug.tiles.heightAt(row, col);
const p = cam.project(col, row, z);
const cr = raw.debug.app.view.getBoundingClientRect();
return { x: cr.left + p.x, y: cr.top + p.y };
}, row, col);
}
/** Press at `from`, move in steps to `to`, optionally screenshot mid-drag, release. */
async drag(from, to, { steps = 14, midShot = null } = {}) {
const m = this.page.mouse;
await m.move(from.x, from.y);
await m.down();
for (let i = 1; i <= steps; i++) {
await m.move(from.x + ((to.x - from.x) * i) / steps, from.y + ((to.y - from.y) * i) / steps);
await sleep(14);
}
await sleep(160);
if (midShot) await this.shot(midShot);
await m.up();
}
/**
* An empty legal tile for a piece: the client's own legality (gameLogic/placement.js boardTargets over the screen's
* placement context — the stage's deploy tiles under the player's terrain 机变 cards and devices, the boss field's
* half in a boss round), so a tile a round's 机变 card took away is never chosen; the data's plain deploy tiles
* (melee on melee tiles, ranged anywhere) when that module cannot be read.
*/
freeTileFor(uid, { prefer = 'gate' } = {}) {
return this.page.evaluate(async (uid, prefer) => {
const { store, data } = globalThis.__SP__;
const s = store.get();
const priv = s.match.private;
const pub = s.match.public;
const st = data.lookup('stages', pub.stageId);
const piece = [...priv.hand, ...priv.temp, ...priv.board].find((p) => p && p.uid === uid);
if (!piece || !st?.deployTiles?.normal) return null;
const order = (list) => {
const a = list.slice();
if (prefer === 'gate') a.sort((x, y) => (y[1] - x[1]) || (x[0] - y[0]));
else a.sort((x, y) => (x[1] - y[1]) || (y[0] - x[0]));
return a;
};
const used = new Set(priv.board.map((p) => `${p.row},${p.col}`));
try {
const P = await import('/js/ui/gameLogic/placement.js');
const C = await import('/js/ui/gameLogic/camera.js');
const ctx = P.placementContext({
priv, stage: st, editable: true, field: C.deployFieldOf(pub, s.me?.playerId ?? s.me),
getChess: (id) => data.lookup('chess', id), getToken: (id) => data.lookup('tokens', id),
getItem: (id) => data.lookup('items', id), getEffect: (id) => data.lookup('effects', id), backups: data.get('backups'),
});
const legal = P.boardTargets(ctx, uid).legal.filter(([r, c]) => !used.has(`${r},${c}`));
if (legal.length) { const [r, c] = order(legal)[0]; return { row: r, col: c }; }
return null;
} catch { /* the plain deploy tiles below */ }
const rec = piece.kind === 'token' ? data.lookup('tokens', piece.id) : data.lookup('chess', piece.id);
const melee = rec?.position === 'MELEE';
const dt = st.deployTiles.normal;
const tiles = melee ? dt.melee : [...dt.rangedOnly, ...dt.melee];
const free = tiles.filter(([r, c]) => !used.has(`${r},${c}`));
if (!free.length) return null;
const [r, c] = order(free)[0];
return { row: r, col: c };
}, uid, prefer);
}
handPieces(kind = null) {
return this.page.evaluate((kind) => {
const priv = globalThis.__SP__.store.get().match.private;
return (priv?.hand || []).map((p, idx) => p && { uid: p.uid, id: p.id, kind: p.kind, golden: !!p.golden, idx })
.filter((p) => p && (!kind || p.kind === kind));
}, kind);
}
boardPieces() {
return this.page.evaluate(() => (globalThis.__SP__.store.get().match.private?.board || []).map((p) => ({
uid: p.uid, id: p.id, kind: p.kind, golden: !!p.golden, row: p.row, col: p.col, items: (p.items || []).length,
})));
}
async isEditable() {
const s = await this.st();
return s.phase === 'PREP' && !s.ready && s.alive;
}
// ---- direction wheel / intents (research 09 §1.2 / §5) --------------------------------------------------------
/** Record every intent the UI sends (wraps net.request once per page; ui/gameActions.js looks it up per call). */
hookRequests() {
return this.page.evaluate(() => {
const n = globalThis.__SP__.net;
if (n.__e2eReq) return;
const orig = n.request.bind(n);
globalThis.__e2eReq = [];
n.request = (t, f, o) => { globalThis.__e2eReq.push([t, JSON.parse(JSON.stringify(f || {}))]); return orig(t, f, o); };
n.__e2eReq = true;
});
}
/** Recorded intents of type `t` (all when null), oldest first. */
requests(t = null) {
return this.page.evaluate((t) => (globalThis.__e2eReq || []).filter((r) => !t || r[0] === t), t);
}
/** Geometry of the open direction wheel (client px): centre + half-diagonal, or null. */
wheel() {
return this.page.evaluate(() => {
const el = document.querySelector('.fwheel__dia');
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, half: r.width / 2 };
});
}
/**
* Choose a direction on the open wheel: press at its centre, drag towards `dir` (UP / RIGHT / DOWN / LEFT) past the
* dead-zone, optionally screenshot while held, release. `dir` null = release in the centre (cancel).
*/
async swipe(dir, { midShot = null, reach = 0.72, board = true } = {}) {
const w = await this.wheel();
if (!w) throw new Error(`${this.label}: no direction wheel open`);
// `dir` is a BOARD direction: on the mirrored right half of the Final Assault prep the screen swipe is RIGHT ↔ LEFT
const mirror = board && await this.page.evaluate(() => !!globalThis.__SP_VIEW__?.raw?.prepField?.()?.mirror);
const sdir = mirror ? ({ RIGHT: 'LEFT', LEFT: 'RIGHT' }[dir] || dir) : dir;
const v = { UP: [0, -1], RIGHT: [1, 0], DOWN: [0, 1], LEFT: [-1, 0] }[sdir] || [0, 0];
const m = this.page.mouse;
await m.move(w.x, w.y);
await m.down();
const steps = 10;
for (let i = 1; i <= steps; i++) { await m.move(w.x + (v[0] * w.half * reach * i) / steps + 1, w.y + (v[1] * w.half * reach * i) / steps + 1); await sleep(16); }
await sleep(200);
if (midShot) await this.shot(midShot);
await m.up();
}
/**
* The equip-replace dialog (ui/equipReplace.js) is open after an item drop: pick equipped item `nth` (0 = the older)
* and 确认替换. Returns false when no dialog is open.
*/
async confirmReplace(nth = 0) {
if (!(await this.exists('.eqr .eqr__opt'))) return false;
await this.click('.eqr__opt', null, { nth });
await this.click('.eqr__ok');
await this.page.waitForFunction(() => !document.querySelector('.eqr'), { timeout: 4000 }).catch(() => {});
return true;
}
/** Board piece `uid` as the store has it ({ row, col, dir }) or null. */
boardPiece(uid) {
return this.page.evaluate((uid) => {
const p = (globalThis.__SP__.store.get().match.private?.board || []).find((x) => x.uid === uid);
return p ? { row: p.row, col: p.col, dir: p.dir ?? null } : null;
}, uid);
}
}
/** Throw when any client logged console errors, page errors, failed requests or HTTP errors. */
export function problemsOf(clients) {
const out = [];
for (const c of clients) for (const p of c.problems) out.push(`${c.label}: ${p}`);
return out;
}