// Real title and modal, stub account provider. No real account, SMS, authorization or CAPTCHA is exercised. import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { startServer } from '../../server/index.js'; import { SklandCaptchaError } from '../../server/skland/client.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('title requires binding, reads the nickname, keeps channel choice and handles CAPTCHA, SMS and restored entry', { skip: enabled ? false : 'set SP_E2E=1 and CHROME_PATH (needs Chrome)', timeout: 60000, }, async () => { const binding = { list: [{ appCode: 'arknights', bindingList: [ { uid: '123', nickName: '官服博士', channelName: '官服', gameId: 1 }, { uid: '123', nickName: 'B服博士', channelName: 'B服', gameId: 2, isDefault: true }, ] }] }; let polls = 0, scans = 0, passwordProof, smsSends = 0; const client = { genScan: async () => { scans++; return { scanId: `SCAN${scans}`, scanUrl: `hypergryph://scan_login?scanId=SCAN${scans}` }; }, checkScan: async () => (++polls > 1 ? { state: 'confirmed', scanCode: 'CODE' } : { state: 'waiting', awaitingConfirmation: true }), tokenByScanCode: async () => ({ token: 'SCAN-TOKEN' }), getGrantCode: async () => 'GRANT', getCred: async () => ({ cred: 'CRED', credToken: 'SIGN' }), getBinding: async () => ({ binding, cred: 'CRED', credToken: 'SIGN' }), sendPhoneCode: async () => { smsSends++; }, tokenByPhoneCode: async () => ({ token: 'SMS-TOKEN' }), tokenByPhonePassword: async (_phone, _password, captcha) => { if (!captcha) throw new SklandCaptchaError({ geetestId: 'FAKE', challenge: 'ORIGINAL' }, 'password'); passwordProof = captcha; return { token: 'PASSWORD-TOKEN' }; }, getAccountBinding: async () => binding, }; const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client } }); let browser; try { const puppeteer = (await import('puppeteer-core')).default; browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] }); let page; const errors = []; const newPage = async () => { const tab = await browser.newPage(); await tab.setViewport({ width: 1440, height: 1000 }); tab.on('pageerror', (error) => errors.push(error.message)); await tab.setRequestInterception(true); tab.on('request', (request) => { if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort(); else request.continue(); }); return tab; }; page = await newPage(); await page.goto(server.url); await page.waitForSelector('[data-testid="binding-start"]'); const clickText = async (text) => { await page.waitForFunction((value) => [...document.querySelectorAll('.sp-binding button')] .some((button) => button.textContent.trim() === value && !button.disabled), {}, text); await page.evaluate((value) => [...document.querySelectorAll('.sp-binding button')] .find((button) => button.textContent.trim() === value).click(), text); }; const open = async () => { await page.click(await page.$('[data-testid="account-binding-entry"]') ? '[data-testid="account-binding-entry"]' : '[data-testid="binding-start"]'); await page.waitForSelector('.sp-binding'); await page.waitForFunction(() => window.__SP__?.net.status === 'online' && !document.querySelector('.sp-binding .btn[aria-busy="true"]')); }; const callsign = () => page.evaluate(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent || ''); const expectName = async (name) => { try { await page.waitForFunction((value) => (document.querySelector('[data-testid="bound-callsign"]')?.textContent || '') === value, { timeout: 12000 }, name); assert.equal(await page.$('.title-login input'), null, 'the callsign is never an input'); assert.equal(!!await page.$('.sp-binding-callsign'), !!name, 'the callsign area only exists after binding'); if (name) assert.ok(await page.$('.sp-binding-callsign__icon'), 'the nickname keeps its person icon'); } catch (error) { console.error('Callsign state:', await page.evaluate(() => ({ callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent, action: document.querySelector('[data-testid="binding-start"]')?.textContent, playerId: window.__SP__?.store.get().me.playerId, connection: window.__SP__?.net.status, alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent), })), 'page errors:', errors); throw error; } }; const expectAction = async (text) => page.waitForFunction((value) => document.querySelector('[data-testid="binding-start"]')?.textContent.trim() === value, {}, text); await expectAction('绑定账号'); await expectName(''); assert.equal(await page.$('.title-login label'), null); // Old manual callsigns and a cached entered flag must not bypass the server binding check. await page.evaluate(() => { localStorage.setItem('sp.name', '旧的手填代号'); sessionStorage.setItem('sp.entered', '1'); }); await page.reload(); await expectAction('绑定账号'); assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); await open(); assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); await page.waitForSelector('.sp-binding__qr'); assert.equal(scans, 1, 'opening the scan tab automatically fetches a QR'); const firstQr = await page.$eval('.sp-binding__qr', (image) => image.src); await clickText('重新获取二维码'); await page.waitForFunction((previous) => { const image = document.querySelector('.sp-binding__qr'); return image && image.src !== previous; }, {}, firstQr); assert.equal(scans, 2, 'manual QR refresh remains available'); await page.waitForFunction(() => document.querySelector('.sp-binding__scan [role="status"]')?.textContent.includes('已扫码')); await expectName('B服博士'); await expectAction('开始'); assert.ok(polls >= 2); await clickText('官服博士官服 · UID 123'); await expectName('官服博士'); await clickText('刷新角色'); await expectName('官服博士'); await clickText('完成'); await open(); assert.equal(await callsign(), '官服博士'); await clickText('解绑账号'); await expectName(''); await expectAction('绑定账号'); await clickText('密码'); await page.type('.sp-binding input[type="tel"]', '13800000000'); await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD'); await clickText('登录并绑定'); await page.waitForSelector('.sp-binding__captcha'); await page.evaluate(() => { window.initGeetest4 = (_options, callback) => { let success; callback({ onSuccess(fn) { success = fn; }, onError() {}, onClose() {}, onReady(fn) { setTimeout(fn, 0); }, showCaptcha() { success(); }, getValidate() { return { captcha_id: 'FAKE', lot_number: 'TEST' }; }, destroy() {}, }); }; }); await clickText('开始人机验证'); await expectName('B服博士'); assert.equal(passwordProof.challenge, 'ORIGINAL'); assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false, 'binding never presses Start'); fs.mkdirSync('.cache/skland-preview', { recursive: true }); await new Promise((resolve) => setTimeout(resolve, 350)); await page.screenshot({ path: '.cache/skland-preview/bound.png' }); await clickText('解绑账号'); await clickText('验证码'); await clickText('发送验证码'); await page.waitForFunction(() => document.querySelector('.sp-binding__notice')?.textContent.includes('验证码已发送')); assert.equal(smsSends, 1); await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456'); await clickText('登录并绑定'); await expectName('B服博士'); await clickText('完成'); await new Promise((resolve) => setTimeout(resolve, 350)); await page.screenshot({ path: '.cache/skland-preview/entry-bound.png' }); await page.reload(); await expectName('B服博士'); const accountId = () => page.evaluate(async () => { const response = await fetch('/skland/session/status', { headers: { authorization: 'Bearer ' + window.__SP__.store.get().me.token } }); return (await response.json()).gamePlayerId; }); const previousId = await accountId(); await page.close(); page = await newPage(); await page.goto(server.url); await expectName('B服博士'); await expectAction('开始'); assert.equal(await accountId(), previousId, 'a fresh tab restores the JWT-owned role without logging in or entering'); assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); await open(); // Keep the existing target alive while checking the landscape layout; device emulation can reload the app and // would test browser boot behavior rather than this feature's responsive CSS. await page.setViewport({ width: 844, height: 390 }); await new Promise((resolve) => setTimeout(resolve, 350)); await page.screenshot({ path: '.cache/skland-preview/mobile.png' }); const fits = await page.$eval('.sp-binding', (element) => { const rect = element.getBoundingClientRect(); const overlay = element.parentElement?.getBoundingClientRect(); return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, height: rect.height, overlay: overlay && { top: overlay.top, bottom: overlay.bottom, height: overlay.height }, viewport: [innerWidth, innerHeight] }; }); assert.equal(fits.left >= 0 && fits.right <= fits.viewport[0] && fits.top >= 0 && fits.bottom <= fits.viewport[1], true, `the modal fits a landscape phone: ${JSON.stringify(fits)}`); await clickText('解绑账号'); await expectName(''); await expectAction('绑定账号'); await clickText('完成'); await page.click('[data-testid="binding-start"]'); await page.waitForSelector('.sp-binding'); assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); // A login without a named role still cannot start. const namedRoles = binding.list[0].bindingList; binding.list[0].bindingList = []; await clickText('验证码'); await page.type('.sp-binding input[type="tel"]', '13800000000'); await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456'); await clickText('登录并绑定'); await page.waitForSelector('.sp-binding__roles'); await expectAction('绑定账号'); await expectName(''); binding.list[0].bindingList = namedRoles; await clickText('刷新角色'); await expectName('B服博士'); await clickText('完成'); await page.click('[data-testid="binding-start"]'); await page.waitForSelector('.lobby-screen'); assert.equal(await page.evaluate(() => window.__SP__.store.get().me.name), 'B服博士'); await page.reload(); await page.waitForSelector('.lobby-screen'); // Removing the binding on the server invalidates the cached entry on the next reload. await page.evaluate(async () => { await fetch('/skland/session/unbind', { method: 'POST', headers: { authorization: `Bearer ${window.__SP__.store.get().me.token}`, 'content-type': 'application/json', }, body: '{}' }); }); await page.reload(); await expectAction('绑定账号'); assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); assert.deepEqual(errors, []); } finally { await browser?.close(); await server.close(); } });