Files
ark-stronghold-protocol/test/skland/panel.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

116 lines
7.7 KiB
JavaScript

// test/skland/panel.test.js — public/skland.html, the standalone Skland binding panel (scan / SMS / password).
//
// The panel is one self-contained HTML file with an inline module script (no build step, no browser test), so this
// test reads it as TEXT and pins only the contract facts that would silently rot: the endpoints it calls, that the QR
// encodes the scanUrl deep link (NOT the scan session id), that captcha challenges are handled, that a 404 reads as
// "the module is off", and that the 2 s polling loop always gets cleared.
// String checks like these are deliberately few and each one names the failure it prevents.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const HTML_PATH = path.join(ROOT, 'public', 'skland.html');
describe('the Skland binding panel (public/skland.html)', () => {
assert.ok(existsSync(HTML_PATH), 'the panel is a real file the static server can serve');
const html = readFileSync(HTML_PATH, 'utf8');
test('is one whole document: a doctype, an inline module script and a closed html tag', () => {
// A stray `</script>` or an unclosed tag is exactly the kind of damage a text-only test can catch.
assert.match(html, /^<!doctype html>/i);
assert.equal((html.match(/<script\b/g) || []).length, (html.match(/<\/script>/g) || []).length, 'every script opens and closes once');
assert.match(html, /<script(?![^>]*\bsrc=)[^>]*type="module"/i, 'the panel logic is an inline module (no build step)');
assert.match(html, /<\/body>\s*<\/html>\s*$/, 'the document ends where it should');
});
test('every endpoint the panel needs is referenced', () => {
// Each is a distinct login/status step of server/skland/http.js; dropping one breaks that path silently.
for (const endpoint of [
'/skland/status', '/skland/scan/start', '/skland/scan/poll', '/skland/phone/send',
'/skland/phone/login', '/skland/password/login', '/skland/unbind', '/skland/roles',
]) {
assert.ok(html.includes(endpoint), `the panel calls ${endpoint}`);
}
assert.match(html, /\/skland\/roles\?player=.*refresh=1/, 'a bound refresh re-queries the roles');
});
test('the QR code encodes scanUrl — never the scanId', () => {
// The upstream deep link is `hypergryph://scan_login?…` (server/skland/service.js startScan). Encoding the opaque
// scanId would render a QR that no phone can act on, and nothing else in the flow would fail loudly.
assert.ok(html.includes('/vendor/qrcode.js'), 'the vendored library is loaded as a classic script');
const addData = html.match(/qr\.addData\(\s*([^)]*)\)/);
assert.ok(addData, 'the generator is fed one payload');
assert.match(addData[1], /scanUrl/, 'the payload is the scanUrl deep link');
assert.doesNotMatch(addData[1], /scanId/, 'never the scan session id');
assert.match(html, /renderQr\(\s*r\.scanUrl\s*\)/, 'the value comes from the scan/start answer');
assert.match(html, /typeof qrcode !== 'function'/, 'a missing vendor file is detected…');
assert.match(html, /createSvgTag|createImgTag/, '…and the present library renders a tag');
assert.match(html, /textContent\s*=\s*String\(scanUrl\)|value\s*=\s*String\(scanUrl\)/, '…with the deep link as selectable text');
});
test('the QR frame step is named exactly as the server sends it', () => {
// service.js pollScan answers state ∈ none|waiting|confirmed|expired; 'none' and 'expired' mean start over.
for (const state of ["'waiting'", "'confirmed'", "'expired'", "'none'"]) {
assert.ok(html.includes(`state === ${state}`), `the poll handles state ${state}`);
}
});
test('a 404 (or the 405 a POST gets) on /skland/* reads as "the module is off", not as a broken page', () => {
// The module is opt-in (SP_SKLAND=1). With it off /skland/* is not routed at all: a GET falls through to the
// static server's 404 page, but a POST hits routes.js's GET/HEAD gate first and gets a 405 HTML page. Both are
// the bilingual error page, while an enabled module always answers JSON (errors included) — so the panel keys on
// the HTML content type as well, and only a plain 404 check would show a confusing "method not allowed".
assert.match(html, /res\.status === 404/);
assert.match(html, /text\/html/, 'an HTML answer is recognised as the wrong server…');
assert.match(html, /该功能未启用\s*·\s*the module is off/);
assert.ok(html.includes('id="module-off"'), 'the notice has a place to render');
assert.match(html, /setModuleOff\(\)/, 'the notice is raised, not just logged');
});
test('needCaptcha is handled', () => {
// needCaptcha: send → geetest challenge, then resend with the solved fields (service.js sendPhoneCode).
assert.ok(html.includes('needCaptcha'));
assert.match(html, /needCaptcha[\s\S]{0,200}renderCaptcha\(/, 'the challenge data is displayed');
assert.ok(html.includes('/js/skland/captcha.js'), 'the shared interactive widget supplies the proof');
assert.ok(html.includes('pw-captcha-box'), 'password login has its own challenge control');
});
test('a poll answer is never read as `undefined`: ok:false is honoured and `state` is always named', () => {
// The panel once showed "未知状态 · Unknown state: undefined" because the server answered a failed poll with a
// bare { ok: false } and pollOnce only switched on `state`. Both halves are pinned here.
assert.match(html, /r\.ok === false/, 'pollOnce checks ok:false before it reads state');
assert.match(html, /r\.state === 'error'/, "the server's terminal error state is handled");
assert.match(html, /未知状态[\s\S]{0,160}brief\(JSON\.stringify\(r\)\)/, 'the unknown branch shows the real body');
});
test('the poll runs about every 2 s and its timer is always cleared', () => {
// A leaked interval keeps asking the server after the tab is hidden or the section is left (service.js deletes the
// scan session on the first non-waiting answer, so a leaked poll can only ever report state 'none' — noise).
const ms = html.match(/POLL_MS\s*=\s*(\d+)/);
assert.ok(ms, 'the interval is a named constant');
assert.equal(Number(ms[1]), 2000, 'the documented ~2 s');
assert.match(html, /setInterval\([\s\S]{0,80}?POLL_MS\s*\)/, 'the timer uses that constant');
assert.match(html, /clearInterval\(pollTimer\)/, 'the single cleanup path clears the interval');
assert.match(html, /document\.hidden\)\s*return/, 'the poll never starts on a hidden tab');
assert.match(html, /document\.hidden\)\s*pollStop\(\)/, 'a tab that goes hidden stops the poll');
assert.ok(html.includes("addEventListener('visibilitychange'"), 'visibility changes are watched');
assert.ok(html.includes("addEventListener('pagehide'"), 'leaving the page stops the poll too');
});
test('the identity shown is the ?player= / localStorage one, labelled as a placeholder', () => {
assert.ok(html.includes('URLSearchParams'), 'the player id is read from the URL');
assert.ok(html.includes('sp.skland.player'), 'and kept in localStorage under a clear key');
assert.match(html, /placeholder/i, 'the page says the id is a placeholder until the session provides one');
});
test('no external network calls and no inline secrets', () => {
// The page must work on an offline LAN box: only same-origin /vendor and /skland URLs are allowed. No tokens,
// passwords or phone numbers are baked in either.
assert.doesNotMatch(html, /https?:\/\//, 'no absolute http(s) URL anywhere');
assert.doesNotMatch(html, /\b(?:token|secret|password)\s*[:=]\s*['"][A-Za-z0-9]{8,}['"]/i, 'no hardcoded credential');
});
});