Files
ark-stronghold-protocol/test/skland/entry.browser.test.js
T
herryyun 7c25c89992
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
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

212 lines
12 KiB
JavaScript

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