实现账号绑定、持久登录与跨设备对局恢复
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,211 @@
|
||||
// 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(); }
|
||||
});
|
||||
Reference in new issue
Block a user