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
212 lines
12 KiB
JavaScript
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(); }
|
|
});
|