CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
168 lines
11 KiB
JavaScript
168 lines
11 KiB
JavaScript
// Two isolated browser contexts exercise real title/login/recovery UI against an entirely stubbed provider.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { startServer } from '../../server/index.js';
|
|
|
|
const chrome = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
|
const enabled = process.env.SP_E2E === '1' && fs.existsSync(chrome);
|
|
|
|
test('a separately logged-in device restores the match without local saves, and stale or duplicated tabs do not take it back',
|
|
{ skip: !enabled, timeout: 90_000 }, async () => {
|
|
const binding = { list: [{ appCode: 'arknights', bindingList: [
|
|
{ uid: '123', channelName: '官服', nickName: '跨设备博士', isDefault: true },
|
|
] }] };
|
|
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false,
|
|
skland: { storeFile: ':memory:', client: {
|
|
genScan: async () => ({ scanId: 'TEST', scanUrl: 'hypergryph://scan_login?scanId=TEST' }),
|
|
checkScan: async () => ({ state: 'waiting' }),
|
|
tokenByPhonePassword: async () => ({ token: 'FAKE' }),
|
|
getAccountBinding: async () => binding,
|
|
} } });
|
|
let browser;
|
|
const errors = [];
|
|
try {
|
|
const puppeteer = (await import('puppeteer-core')).default;
|
|
browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] });
|
|
const firstContext = await browser.createBrowserContext(), secondContext = await browser.createBrowserContext();
|
|
const pageIn = async (context) => {
|
|
const page = await context.newPage();
|
|
await page.setViewport({ width: 1440, height: 1000 });
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
await page.setRequestInterception(true);
|
|
page.on('request', (request) => {
|
|
if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort();
|
|
else request.continue();
|
|
});
|
|
return page;
|
|
};
|
|
const clickDialog = async (page, label) => {
|
|
// QR loading can disable the tabs between separate readiness and click calls. Check/click atomically.
|
|
await page.waitForFunction((text) => {
|
|
const button = [...document.querySelectorAll('.sp-binding button')]
|
|
.find((element) => element.textContent.trim() === text && !element.disabled);
|
|
if (!button) return false;
|
|
button.click(); return true;
|
|
}, {}, label);
|
|
};
|
|
const login = async (page) => {
|
|
await page.goto(server.url);
|
|
await page.waitForSelector('[data-testid="binding-start"]');
|
|
await page.click('[data-testid="binding-start"]');
|
|
await clickDialog(page, '密码');
|
|
await page.waitForSelector('.sp-binding input[type="tel"]:not([disabled])');
|
|
await page.type('.sp-binding input[type="tel"]', '13800000000');
|
|
await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD');
|
|
await clickDialog(page, '登录并绑定');
|
|
await page.waitForFunction(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent === '跨设备博士');
|
|
await clickDialog(page, '完成');
|
|
};
|
|
const a = await pageIn(firstContext), b = await pageIn(secondContext);
|
|
await login(a);
|
|
await a.click('[data-testid="resume-account-match"]');
|
|
await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="status"]')?.textContent === '当前角色没有可恢复的对局。');
|
|
await a.keyboard.press('Escape');
|
|
await a.waitForSelector('.sp-account-resume', { hidden: true });
|
|
await a.click('[data-testid="binding-start"]');
|
|
await a.waitForSelector('.lobby-screen');
|
|
await a.evaluate(async () => {
|
|
await window.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
|
|
await window.__SP__.net.request('room.start');
|
|
});
|
|
await a.waitForFunction(() => window.__SP__.store.get().match.private && window.__SP__.store.get().room?.inMatch);
|
|
const previous = await a.evaluate(() => {
|
|
const s = window.__SP__.store.get();
|
|
return { code: s.room.code, playerId: s.me.playerId, token: s.me.token, private: s.match.private };
|
|
});
|
|
await login(b);
|
|
assert.equal(await b.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('sp.matches') || '{}')).length), 0);
|
|
assert.equal(await b.evaluate(() => window.__SP__.store.get().match.private), null);
|
|
assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false);
|
|
assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online');
|
|
assert.equal(await b.$('[data-testid="resume-local-match"]'), null, 'account UI no longer offers a local-token recovery');
|
|
await b.click('[data-testid="resume-account-match"]');
|
|
await b.waitForSelector('[data-testid="confirm-account-resume"]');
|
|
assert.match(await b.$eval('[data-testid="confirm-account-resume"]', (el) => el.textContent), new RegExp(previous.code));
|
|
assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online', 'opening the picker does not take over');
|
|
fs.mkdirSync('.cache/skland-preview', { recursive: true });
|
|
await b.$eval('.modal', (element) => Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)));
|
|
await b.screenshot({ path: '.cache/skland-preview/account-recovery.png' });
|
|
await b.setViewport({ width: 844, height: 390 });
|
|
const fits = await b.$eval('.sp-account-resume', (element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight;
|
|
});
|
|
assert.equal(fits, true, 'the recovery picker fits a landscape phone');
|
|
const readable = await b.$eval('.sp-account-resume', (element) => ({
|
|
font: parseFloat(getComputedStyle(element.querySelector('.modal__body')).fontSize),
|
|
hit: element.querySelector('[data-testid="confirm-account-resume"]').getBoundingClientRect().height,
|
|
}));
|
|
assert.ok(readable.font >= 14 && readable.hit >= 44, 'small-screen recovery stays readable and tappable');
|
|
await b.screenshot({ path: '.cache/skland-preview/account-recovery-mobile.png' });
|
|
await b.setViewport({ width: 1440, height: 1000 });
|
|
await b.click('[data-testid="confirm-account-resume"]');
|
|
await b.waitForFunction((code) => {
|
|
const s = window.__SP__.store.get();
|
|
return s.session.entered && s.room?.code === code && !!s.match.private;
|
|
}, {}, previous.code);
|
|
const restored = await b.evaluate(() => window.__SP__.store.get());
|
|
assert.equal(restored.me.playerId, previous.playerId);
|
|
assert.notEqual(restored.me.token, previous.token);
|
|
for (const key of ['funds', 'shop', 'hand', 'board']) assert.deepEqual(restored.match.private[key], previous.private[key]);
|
|
await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED' && window.__SP__.net.status === 'closed');
|
|
const continueHere = async (page) => {
|
|
await page.waitForFunction(() => {
|
|
const button = [...document.querySelectorAll('.conn-banner button')]
|
|
.find((element) => element.textContent.trim() === '在此页面继续' && !element.disabled);
|
|
if (!button) return false;
|
|
button.click(); return true;
|
|
});
|
|
await page.waitForFunction((code) => {
|
|
const s = window.__SP__.store.get();
|
|
return window.__SP__.net.status === 'online' && s.session.entered && s.room?.code === code;
|
|
}, {}, previous.code);
|
|
};
|
|
await continueHere(a);
|
|
await b.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED');
|
|
await continueHere(b);
|
|
const heldToken = await b.evaluate(() => window.__SP__.store.get().me.token);
|
|
await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED');
|
|
await a.reload();
|
|
await a.waitForSelector('[data-testid="bound-callsign"]');
|
|
assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false, 'an old saved entry cannot silently retake the role');
|
|
assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online');
|
|
const duplicate = await pageIn(secondContext);
|
|
await duplicate.evaluateOnNewDocument((token) => {
|
|
sessionStorage.setItem('sp.token', token); sessionStorage.setItem('sp.entered', '1');
|
|
}, heldToken);
|
|
await duplicate.goto(server.url);
|
|
await duplicate.waitForSelector('[data-testid="bound-callsign"]');
|
|
assert.equal(await duplicate.evaluate(() => window.__SP__.store.get().session.entered), false);
|
|
assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online', 'a duplicated tab does not take over either');
|
|
// The match can end while the other device has its recovery dialog open.
|
|
await a.click('[data-testid="resume-account-match"]');
|
|
await a.waitForSelector('[data-testid="confirm-account-resume"]');
|
|
await b.evaluate(() => window.__SP__.net.request('g.leave'));
|
|
await a.click('[data-testid="confirm-account-resume"]');
|
|
await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="alert"]')?.textContent === '此对局已结束或过期,请刷新后重试。');
|
|
assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false);
|
|
assert.equal(await a.$('[data-testid="confirm-account-resume"]'), null);
|
|
// An active page that loses its server session must not stay in an anonymous, unusable lobby.
|
|
const current = server.registry.byToken(heldToken);
|
|
server.registry.remove(current);
|
|
await b.evaluate(() => window.__SP__.net.reconnectNow());
|
|
await b.waitForSelector('[data-testid="bound-callsign"]');
|
|
assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false);
|
|
assert.deepEqual(errors, []);
|
|
} catch (error) {
|
|
const pages = browser ? await browser.pages() : [];
|
|
console.error('Recovery browser state:', await Promise.all(pages.map((page) => page.evaluate(() => ({
|
|
status: window.__SP__?.net.status, entered: window.__SP__?.store.get().session.entered,
|
|
playerId: window.__SP__?.store.get().me.playerId,
|
|
callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent,
|
|
alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent),
|
|
})).catch(() => null))), 'page errors:', errors);
|
|
throw error;
|
|
} finally { await browser?.close(); await server.close(); }
|
|
});
|