实现账号绑定、持久登录与跨设备对局恢复
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
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
This commit is contained in:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
@@ -0,0 +1,167 @@
|
||||
// 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(); }
|
||||
});
|
||||
Reference in new issue
Block a user