Files
ark-stronghold-protocol/test/ui/followup-023.e2e.test.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

226 lines
14 KiB
JavaScript

// Real browser / server checks for local identity recovery, per-operator voice, install offers and large text.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
let server, browser, base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${server.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
});
after(async () => { await browser?.close(); await server?.close(); });
async function pageIn(ctx, phone = false, random = null) {
const page = await ctx.newPage();
await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 });
if (random != null) await page.evaluateOnNewDocument((value) => { Math.random = () => value; }, random);
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); 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'));
return page;
}
test('closed second window resumes the same server player and match through the recovery UI', async () => {
const ctx = await browser.createBrowserContext();
try {
const a = await pageIn(ctx), b = await pageIn(ctx);
const before = await b.evaluate(async () => {
const { net, store } = globalThis.__SP__;
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
await net.request('room.start', {});
return { id: store.get().me.playerId, code: store.get().room.code };
});
await b.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await a.waitForSelector('[data-testid="resume-local-match"]');
const refused = await a.evaluate(async () => { const { identity } = await import('/js/net.js'); return identity.resume(identity.recoverable()[0].id); });
assert.equal(refused, false, 'live window is not displaced');
assert.equal(await b.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
await b.close();
await a.click('[data-testid="resume-local-match"]');
await a.waitForSelector('.modal .btn--block');
await a.evaluate(() => { globalThis.recoveryDocument = true; });
await a.click('.modal .btn--block');
await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; });
assert.deepEqual(after, before);
assert.equal(await a.evaluate(() => globalThis.recoveryDocument), true, 'welcome arrives in the same document');
} finally { await ctx.close(); }
});
test('a refreshing match window keeps its seat while its init is waiting and another window clicks recovery', async () => {
const ctx = await browser.createBrowserContext();
try {
const guest = await pageIn(ctx, false, 0.01), owner = await pageIn(ctx, false, 0.99);
const before = await owner.evaluate(async () => {
const { net, store } = globalThis.__SP__;
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
await net.request('room.start', {});
return { id: store.get().me.playerId, code: store.get().room.code, token: sessionStorage.getItem('sp.token') };
});
const guestId = await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId);
await owner.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await guest.waitForSelector('[data-testid="resume-local-match"]');
await owner.evaluateOnNewDocument(() => {
// Hold precisely the timeout scheduled immediately after the real identity query.
// Messages still use Chrome's BroadcastChannel; only init's completion is controlled.
const Channel = globalThis.BroadcastChannel, timer = globalThis.setTimeout;
let hold = false;
globalThis.BroadcastChannel = class extends Channel {
constructor(name) { super(name); this.identityChannel = name === 'sp.identity'; }
postMessage(message) {
super.postMessage(message);
if (this.identityChannel && message.type === 'who') hold = true;
}
};
globalThis.setTimeout = (fn, ms, ...args) => {
if (hold) {
hold = false;
globalThis.finishIdentityInit = () => fn(...args);
return 0;
}
return timer(fn, ms, ...args);
};
});
await owner.reload({ waitUntil: 'domcontentloaded' });
await owner.waitForFunction(() => typeof globalThis.finishIdentityInit === 'function');
await guest.bringToFront();
await guest.click('[data-testid="resume-local-match"]');
await guest.waitForSelector('.modal .btn--block');
await guest.click('.modal .btn--block');
await guest.waitForFunction(() => document.body.textContent.includes('此对局仍在其他窗口中'));
assert.equal(await owner.evaluate(() => sessionStorage.getItem('sp.token')), before.token);
await owner.evaluate(() => globalThis.finishIdentityInit());
await owner.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
const after = await owner.evaluate(() => {
const s = globalThis.__SP__.store.get();
return { id: s.me.playerId, code: s.room.code, token: sessionStorage.getItem('sp.token') };
});
assert.deepEqual(after, before);
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId), guestId);
for (const page of [guest, owner]) {
const pong = await page.evaluate(() => globalThis.__SP__.net.request('ping', { c: Date.now() }));
assert.equal(pong.t, 'pong');
assert.equal(await page.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
}
} finally { await ctx.close(); }
});
test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {
const ctx = await browser.createBrowserContext();
try {
const host = await pageIn(ctx), guest = await pageIn(ctx);
const code = await host.evaluate(async () => {
const { net, store } = globalThis.__SP__;
await net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' });
return store.get().room.code;
});
await guest.evaluate(async (code) => {
const { net } = globalThis.__SP__;
await net.request('room.join', { code }); await net.request('room.ready', { ready: true });
}, code);
await host.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK', { polling: 50 });
await guest.evaluate(() => globalThis.__SP__.net.request('g.infoReady', { setupRevision: 0 }));
await host.waitForSelector('.brief-reroll button');
await host.bringToFront();
await host.click('.brief-reroll button');
await guest.waitForSelector('.brief-reroll .btn--primary');
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), 0);
await guest.bringToFront();
// The host is now a background tab: WebSocket/store updates continue, but animation-frame polling can stop.
// Arm its state wait before the vote so the regression covers a result arriving after the initial check.
await Promise.all([
host.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1, { polling: 50 }),
guest.click('.brief-reroll .btn--primary'),
]);
await guest.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1, { polling: 50 });
const state = await guest.evaluate(() => globalThis.__SP__.store.get().match.public);
assert.equal(state.rerollVote, null);
assert.ok(state.players.filter((p) => !p.isBot).every((p) => !p.ready));
await host.bringToFront();
await host.waitForFunction(() => !!document.querySelector('.brief-reroll button:not(:disabled)'));
// Wait out the documented server anti-burst interval before a second request.
await new Promise((resolve) => setTimeout(resolve, 350));
await host.click('.brief-reroll button');
await guest.waitForSelector('.brief-reroll .btn--danger');
await guest.bringToFront();
await guest.click('.brief-reroll .btn--danger');
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote, { polling: 50 });
assert.equal(await host.evaluate(() => globalThis.__SP__.store.get().match.public.setupRevision), 1);
} finally { await ctx.close(); }
});
test('operator voice selection persists across reload and follows global after reset', async () => {
const ctx = await browser.createBrowserContext();
try {
const page = await pageIn(ctx, true);
const open = async () => { await page.evaluate(async () => (await import('/js/ui/loadoutSync.js')).openLoadout()); await page.waitForSelector('.lo-card__pick'); await page.click('.lo-card__pick'); await page.waitForSelector('[data-voice-char] select', { visible: true }); };
await open();
const id = await page.$eval('[data-voice-char]', (e) => e.dataset.voiceChar);
await page.select('[data-voice-char] select', 'jp');
assert.equal(await page.evaluate(async (id) => (await import('/js/audio.js')).audio.voiceOverrides[id], id), 'jp');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online');
await open();
assert.equal(await page.$eval('[data-voice-char] select', (e) => e.value), 'jp');
await page.select('[data-voice-char] select', '');
assert.equal(await page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.settings')).voiceOverrides[id], id), undefined);
} finally { await ctx.close(); }
});
test('install manifest is eligible and a dismissed offer is consumed through the actual button', async () => {
const ctx = browser.defaultBrowserContext();
let page;
try {
page = await pageIn(ctx);
const client = await page.createCDPSession();
const manifest = await client.send('Page.getAppManifest');
assert.deepEqual(manifest.errors, []);
const icons = await page.evaluate(async () => {
const m = await (await fetch('/manifest.json')).json();
const entries = [...m.icons, ...[...document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]')]
.map((link) => ({ src: link.getAttribute('href'), purpose: link.rel }))];
return Promise.all(entries.map(async (entry) => {
const response = await fetch(entry.src);
if (!response.ok) throw new Error(`${entry.src}: ${response.status}`);
const img = new Image(); img.src = entry.src; await img.decode();
return { purpose: entry.purpose, width: img.naturalWidth, height: img.naturalHeight };
}));
});
assert.deepEqual(icons.filter((i) => i.purpose === 'maskable').map((i) => i.width).sort((a, b) => a - b), [192, 512]);
assert.ok(icons.every((i) => i.width > 0 && i.width === i.height));
assert.ok(icons.some((i) => i.purpose === 'apple-touch-icon' && i.width === 180));
const install = await client.send('Page.getInstallabilityErrors');
assert.deepEqual(install.installabilityErrors, []);
await page.evaluate(() => {
const e = new Event('beforeinstallprompt', { cancelable: true });
window.promptCalls = 0;
e.prompt = async () => { window.promptCalls++; return { outcome: 'dismissed' }; };
window.dispatchEvent(e);
});
await page.waitForSelector('[data-testid="pwa-install"]');
await page.click('[data-testid="pwa-install"]');
await page.waitForFunction(() => !document.querySelector('[data-testid="pwa-install"]'));
assert.equal(await page.evaluate(() => window.promptCalls), 1);
} finally { await page?.close(); }
});
test('extra-large settings stay within a landscape phone in every shipped interface language', async () => {
const ctx = await browser.createBrowserContext();
try {
const page = await pageIn(ctx, true);
for (const lang of ['zh-CN', 'en', 'ja', 'ko', 'zh-TW']) {
await page.evaluate(async (lang) => {
await (await import('/js/ui/lang.js')).switchLang(lang);
(await import('/js/ui/settings.js')).updateSettings({ textSize: 'xl' });
}, lang);
await page.click('[data-testid="settings-btn"]');
await page.waitForSelector('.modal .set-list');
const overflow = await page.$eval('.modal__body', (e) => e.scrollWidth - e.clientWidth);
const excess = await page.$eval('.modal__body', (e) => { const right = e.getBoundingClientRect().right; return [...e.querySelectorAll('*')].filter((x) => x.getBoundingClientRect().right > right + 1).map((x) => [x.className, x.textContent.slice(0, 50), Math.round(x.getBoundingClientRect().width)]); });
assert.ok(overflow <= 1, `${lang}: settings overflow ${overflow} ${JSON.stringify(excess)}`);
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.modal .set-list'));
}
} finally { await ctx.close(); }
});
});