// 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(); } });