实现账号绑定、持久登录与跨设备对局恢复
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,120 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createQrLogin } from '../../public/js/skland/scan.js';
|
||||
|
||||
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
|
||||
function fakeClock() {
|
||||
let time = 0, id = 0;
|
||||
const tasks = new Map();
|
||||
return {
|
||||
now: () => time,
|
||||
timers: {
|
||||
setTimeout: (fn, ms) => { tasks.set(++id, { fn, at: time + ms }); return id; },
|
||||
clearTimeout: (key) => tasks.delete(key),
|
||||
},
|
||||
count: () => tasks.size,
|
||||
async advance(ms) {
|
||||
const target = time + ms;
|
||||
for (;;) {
|
||||
const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next || next[1].at > target) break;
|
||||
time = next[1].at; tasks.delete(next[0]); next[1].fn(); await flush();
|
||||
}
|
||||
time = target; await flush();
|
||||
},
|
||||
};
|
||||
}
|
||||
function fixture(poll = () => ({ ok: true, state: 'waiting' }), onConfirmed) {
|
||||
const clock = fakeClock(), calls = [], images = [], pending = [], errors = [];
|
||||
let confirmed = 0, starts = 0;
|
||||
const scan = createQrLogin({ ...clock,
|
||||
request: async (path) => {
|
||||
calls.push({ path, at: clock.now() });
|
||||
return path === 'scan/start' ? { ok: true, scanUrl: `QR${++starts}` } : poll();
|
||||
},
|
||||
encode: async (url) => `image:${url}`, onQr: (value) => images.push(value),
|
||||
onPending: (value) => pending.push(value), onError: (value) => { if (value) errors.push(value); },
|
||||
onBusy: () => {}, onConfirmed: async () => { confirmed++; await onConfirmed?.(); },
|
||||
});
|
||||
return { scan, clock, calls, images, pending, errors, starts: () => starts, confirmed: () => confirmed };
|
||||
}
|
||||
|
||||
test('QR is fetched automatically, polled every 2 s and replaced every 10 s', async () => {
|
||||
const f = fixture(); f.scan.setActive(true); await f.clock.advance(0);
|
||||
assert.deepEqual(f.images, ['image:QR1']);
|
||||
await f.clock.advance(9999); assert.equal(f.starts(), 1);
|
||||
await f.clock.advance(1); assert.equal(f.starts(), 2);
|
||||
assert.deepEqual(f.calls.filter((c) => c.path === 'scan/poll').map((c) => c.at), [2000, 4000, 6000, 8000, 10000]);
|
||||
await f.clock.advance(10000); assert.equal(f.starts(), 3);
|
||||
f.scan.stop(); assert.equal(f.clock.count(), 0);
|
||||
});
|
||||
|
||||
test('a failed status fetch after confirmation retries completion without replacing the authenticated QR', async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture(() => ({ ok: true, state: 'confirmed' }), async () => {
|
||||
if (++attempts === 1) throw new Error('status offline');
|
||||
});
|
||||
f.scan.setActive(true); await f.clock.advance(2000);
|
||||
assert.equal(f.confirmed(), 1); assert.equal(f.errors[0].message, 'status offline');
|
||||
await f.clock.advance(10000);
|
||||
assert.equal(f.confirmed(), 2); assert.equal(f.starts(), 1); assert.equal(f.clock.count(), 0);
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('the first awaiting-confirmation event starts a 60 s window; repeated polls do not extend it', async () => {
|
||||
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
|
||||
f.scan.setActive(true); await f.clock.advance(2000);
|
||||
assert.equal(f.pending.at(-1), true);
|
||||
await f.clock.advance(59999); assert.equal(f.starts(), 1);
|
||||
await f.clock.advance(1); assert.equal(f.starts(), 2);
|
||||
assert.equal(f.calls.filter((c) => c.path === 'scan/start')[1].at, 62000);
|
||||
assert.equal(f.pending.at(-1), false, 'a new QR returns to the ordinary interval');
|
||||
f.scan.refresh(); await f.clock.advance(0); assert.equal(f.starts(), 3, 'manual refresh is immediate too');
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('a scan detected at the 10 s refresh boundary keeps the QR for confirmation', async () => {
|
||||
let f;
|
||||
f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: f.clock.now() >= 10000 }));
|
||||
f.scan.setActive(true); await f.clock.advance(10000);
|
||||
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
|
||||
await f.clock.advance(59999); assert.equal(f.starts(), 1);
|
||||
await f.clock.advance(1); assert.equal(f.starts(), 2);
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('pausing stops requests and resuming checks confirmation before replacing an overdue QR', async () => {
|
||||
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
|
||||
f.scan.setActive(true); await f.clock.advance(0);
|
||||
f.scan.setActive(false); assert.equal(f.clock.count(), 0);
|
||||
await f.clock.advance(50000); assert.equal(f.calls.length, 1);
|
||||
f.scan.setActive(true); await f.clock.advance(0);
|
||||
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
|
||||
f.scan.stop(); await f.clock.advance(120000); assert.equal(f.calls.length, 2);
|
||||
});
|
||||
|
||||
test('confirmation wins over a manual refresh queued during an in-flight poll', async () => {
|
||||
let resolvePoll;
|
||||
const f = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
|
||||
f.scan.setActive(true); await f.clock.advance(2000);
|
||||
f.scan.refresh(); assert.equal(f.starts(), 1, 'a refresh never overlaps its poll');
|
||||
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
|
||||
assert.equal(f.confirmed(), 1); assert.equal(f.clock.count(), 0);
|
||||
await f.clock.advance(120000); assert.equal(f.starts(), 1);
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('stopping ignores late replies, and a failed poll can recover with a fresh QR', async () => {
|
||||
let resolvePoll;
|
||||
const stopped = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
|
||||
stopped.scan.setActive(true); await stopped.clock.advance(2000); stopped.scan.stop();
|
||||
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
|
||||
assert.equal(stopped.confirmed(), 0); assert.equal(stopped.clock.count(), 0);
|
||||
|
||||
const retry = fixture(() => { throw new Error('offline'); });
|
||||
retry.scan.setActive(true); await retry.clock.advance(2000);
|
||||
assert.equal(retry.errors[0].message, 'offline');
|
||||
await retry.clock.advance(9999); assert.equal(retry.starts(), 1);
|
||||
await retry.clock.advance(1); assert.equal(retry.starts(), 2);
|
||||
retry.scan.stop();
|
||||
});
|
||||
Reference in new issue
Block a user