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
121 lines
5.7 KiB
JavaScript
121 lines
5.7 KiB
JavaScript
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();
|
|
});
|