Files

203 lines
11 KiB
JavaScript

// Runs the shipped page's own claim and Gamepad-polling functions under Node,
// with small state mocks. No browser and no console.
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const page = fs.readFileSync('client/index.html', 'utf8');
const extract = (start, end) => {
const from = page.indexOf(start);
assert.notEqual(from, -1, `page no longer contains ${JSON.stringify(start)}`);
const to = page.indexOf(end, from);
assert.notEqual(to, -1, `page no longer contains ${JSON.stringify(end)}`);
return page.slice(from, to);
};
const sendClaimSource = extract(" let lastClaim = '';", ' const lastSent =');
const pollSource = extract(' function pollGamepads(t)', ' function setGamepadPad(');
function padStatus(fields) {
return Object.assign({ known: true, mine: false, open: false, clients: 0 }, fields);
}
(async () => {
// Echo timing remains the page's own measurement, with optional telemetry
// only for a recognized probe and an advertised supporting service.
const sentRtt = [], pingCtx = {
conn: { echoes: new Map([[7, 100]]), pending: new Map(), latency: true },
now: () => 116.6, pingMs: 0, send: message => sentRtt.push(message), showVersion() {},
};
vm.createContext(pingCtx);
vm.runInContext(extract(' function onMessage(data)', ' function applyStatus('), pingCtx);
vm.runInContext('onMessage(JSON.stringify({ method: "e", params: [7, 100] }))', pingCtx);
assert.equal(pingCtx.pingMs, 17);
assert.deepEqual(JSON.parse(JSON.stringify(sentRtt)), [{ method: 'latency', params: [7, 17] }]);
vm.runInContext('onMessage(JSON.stringify({ method: "e", params: [7, 100] }))', pingCtx);
assert.equal(sentRtt.length, 1, 'duplicate and unrecognized probes produce no report');
pingCtx.conn.latency = false; pingCtx.conn.echoes.set(8, 100);
vm.runInContext('onMessage(JSON.stringify({ method: "e", params: [8, 100] }))', pingCtx);
assert.equal(sentRtt.length, 1, 'older services get no new method');
const routed = [], routeCtx = { conn: { state: 'open',
input: { readyState: 'open', bufferedAmount: 0, send: data => routed.push(['input', JSON.parse(data)]) },
control: { readyState: 'open', bufferedAmount: 0, send: data => routed.push(['control', JSON.parse(data)]) } } };
vm.createContext(routeCtx);
vm.runInContext(extract(' function send(obj)', ' function call('), routeCtx);
vm.runInContext('send({ method: "latency", params: [7, 17] })', routeCtx);
assert.equal(routed[0][0], 'input');
console.log('PASS exact input-channel RTT telemetry, optional capability and duplicate suppression');
// A claim is answered as a whole. One controller taken on another device must
// not disconnect the gamepad that was playing as a different one.
const claims = [];
const ctx = {
conn: { state: 'open' },
keepAwake: { update() {} },
primaryPad: -1,
status: [padStatus({ open: true, clients: 1 }), padStatus({ mine: true, open: true, clients: 1 }), padStatus({}), padStatus({})],
claimedPads: () => [...ctx.gamepadSources.values()].map(s => s.pad).filter(p => p >= 0).sort(),
gamepadSources: new Map([[0, { pad: 0 }], [1, { pad: 1 }]]),
call(method, params) {
claims.push([method, params]);
if (method === 'claim' && claims.filter(c => c[0] === 'claim').length === 1)
return Promise.reject({ message: 'Controller is in use on another device' });
return Promise.resolve({ pads: [] });
},
applyStatus() {}, toast() {}, closePad() { ctx.primaryPad = -1; }, renderGamepads() {},
};
vm.createContext(ctx);
vm.runInContext(sendClaimSource, ctx);
vm.runInContext('sendClaim(true)', ctx);
for (let i = 0; i < 5; i++) await new Promise(setImmediate);
assert.equal(ctx.gamepadSources.get(0).pad, -1, 'the controller taken elsewhere is given up');
assert.equal(ctx.gamepadSources.get(1).pad, 1, 'the controller we own is kept');
assert.deepEqual(claims.filter(c => c[0] === 'claim').map(c => c[1]), [[0, 1], [1]],
'the remaining controller is claimed again');
console.log('PASS a refused claim gives up only the controllers owned elsewhere');
// Rapid selections serialize one claim and retain the latest desired set,
// including an empty selection. Creation-busy is not another owner.
const serialClaims = [], deferred = [];
const serial = {
conn: { state: 'open' }, keepAwake: { update() {} }, primaryPad: -1,
status: [padStatus({ open: true, clients: 0 }), padStatus({ mine: true, clients: 1 }), padStatus({}), padStatus({})],
gamepadSources: new Map([[0, { pad: 0 }], [1, { pad: 1 }]]),
claimedPads: () => [...new Set([...serial.gamepadSources.values()].map(s => s.pad).filter(p => p >= 0))].sort(),
call(method, params) {
if (method === 'status') return Promise.resolve({ pads: [] });
serialClaims.push(params); return new Promise((resolve, reject) => deferred.push({ resolve, reject }));
}, applyStatus() {}, toast() {}, closePad() {}, renderGamepads() {},
};
vm.createContext(serial); vm.runInContext(sendClaimSource, serial);
vm.runInContext('sendClaim(true)', serial);
serial.gamepadSources.get(0).pad = 2; vm.runInContext('sendClaim()', serial);
for (const s of serial.gamepadSources.values()) s.pad = -1;
vm.runInContext('sendClaim()', serial);
assert.equal(serialClaims.length, 1);
deferred.shift().resolve({ pads: [] });
for (let i = 0; i < 4; i++) await new Promise(setImmediate);
assert.deepEqual(serialClaims, [[0, 1], []]);
deferred.shift().resolve({ pads: [] });
for (let i = 0; i < 4; i++) await new Promise(setImmediate);
serial.gamepadSources.get(0).pad = 0; serial.gamepadSources.get(1).pad = 1;
vm.runInContext('sendClaim(true)', serial);
deferred.shift().reject({ code: 409, message: 'Creation busy' });
for (let i = 0; i < 4; i++) await new Promise(setImmediate);
assert.equal(serial.gamepadSources.get(0).pad, 0);
assert.equal(serial.gamepadSources.get(1).pad, 1);
assert.equal(serialClaims.length, 3, 'busy refusal cannot loop');
vm.runInContext('sendClaim(true); resetClaims(); sendClaim(true)', serial);
assert.equal(deferred.length, 2);
deferred.shift().reject({ message: 'old connection' });
deferred.shift().resolve({ pads: [] });
for (let i = 0; i < 4; i++) await new Promise(setImmediate);
assert.equal(serial.gamepadSources.get(1).pad, 1, 'old connection failure cannot drop new selections');
console.log('PASS serialized claims, latest empty selection and unowned-busy preservation');
// getGamepads() can start throwing after it has worked. Held buttons must not
// stay in the source: nothing would be left to release them.
const pads = {
gamepadsAvailable: true,
primaryPad: -1,
navigator: { getGamepads() { throw Error('SecurityError'); } },
gamepadSources: new Map([[0, { pad: 0, buttons: 0x4000, lx: 0, ly: 255, rx: 0, ry: 0, l2: 255, r2: 255, touches: [{}] }]]),
blankSource: pad => ({ pad, buttons: 0, lx: 128, ly: 128, rx: 128, ry: 128, l2: 0, r2: 0, touches: [] }),
renderGamepads() {}, sendClaim() {}, $: () => ({ textContent: '' }),
clearDevice(src) { Object.assign(src, pads.blankSource(src.pad)); },
mapper: { index: null, cancelCapture() {}, status() {} },
};
vm.createContext(pads);
vm.runInContext(pollSource, pads);
vm.runInContext('pollGamepads(100)', pads);
const src = pads.gamepadSources.get(0);
assert.deepEqual(
{ buttons: src.buttons, lx: src.lx, ly: src.ly, rx: src.rx, ry: src.ry, l2: src.l2, r2: src.r2, touches: src.touches },
{ buttons: 0, lx: 128, ly: 128, rx: 128, ry: 128, l2: 0, r2: 0, touches: [] });
assert.equal(src.pad, 0, 'the controller assignment is kept');
console.log('PASS failed Gamepad polling blanks every source');
// The full-screen button, in the three places it behaves differently.
const fsSource = extract(' // Full screen. Safari on iPhone', ' // Options menu');
async function fullScreen({ standalone, displayMode = false, enabled }) {
const button = { hidden: false, handlers: {}, addEventListener(type, fn) { this.handlers[type] = fn; } };
const ctx = {
button, toasts: [], requested: 0,
navigator: standalone === undefined ? {} : { standalone },
window: {},
matchMedia: () => ({ matches: displayMode }),
screen: {},
document: {
fullscreenEnabled: enabled,
documentElement: { requestFullscreen() { ctx.requested++; return Promise.resolve(); } },
},
$: () => button,
toast(message) { ctx.toasts.push(message); },
};
ctx.window.matchMedia = ctx.matchMedia;
vm.createContext(ctx);
vm.runInContext(fsSource, ctx);
if (!button.hidden) await button.handlers.click();
return ctx;
}
// Safari on iPhone: no page full screen, so the button explains the home screen.
let fs = await fullScreen({ standalone: false, enabled: false });
assert.equal(fs.button.hidden, false);
assert.equal(fs.requested, 0);
assert.match(fs.toasts[0], /Add to Home Screen/);
// Opened from the iPhone home screen: already without bars, nothing to offer.
fs = await fullScreen({ standalone: true, enabled: false });
assert.equal(fs.button.hidden, true);
// Installed elsewhere (display-mode standalone): same.
fs = await fullScreen({ displayMode: true, enabled: true });
assert.equal(fs.button.hidden, true);
// A browser that allows it: the button really goes full screen.
fs = await fullScreen({ enabled: true });
assert.equal(fs.button.hidden, false);
assert.equal(fs.requested, 1);
assert.deepEqual(fs.toasts, []);
// Neither possible nor iOS: no button that would do nothing.
fs = await fullScreen({ enabled: false });
assert.equal(fs.button.hidden, true);
console.log('PASS full screen: real where allowed, a home-screen hint on iPhone, gone when standalone');
// Which gamepads the page says it can rumble.
const rumbleCtx = {};
vm.createContext(rumbleCtx);
vm.runInContext(extract(' function canRumble(gp)', ' function renderGamepads()'), rumbleCtx);
const can = gp => vm.runInContext('canRumble(gp)', Object.assign(rumbleCtx, { gp }));
const play = () => Promise.resolve();
assert.equal(can({ vibrationActuator: { playEffect: play, effects: ['dual-rumble', 'trigger-rumble'] } }), true);
assert.equal(can({ vibrationActuator: { playEffect: play, type: 'dual-rumble' } }), true, 'older Chrome');
assert.equal(can({ vibrationActuator: null }), false, 'Chrome with a DualSense');
assert.equal(can({}), false, 'Firefox');
assert.equal(can({ vibrationActuator: { playEffect: play, effects: [] } }), false);
console.log('PASS gamepads that cannot rumble are named as such');
// The Invite panel draws the console's grid as runs of dark modules.
const qrCtx = {};
vm.createContext(qrCtx);
vm.runInContext(extract(' function qrPath(size, rows, quiet)', ' // The address this page reached'), qrCtx);
// 5 wide: row 0 = 11011, row 1 = 00100 -> hex d8, 20.
assert.equal(vm.runInContext("qrPath(5, ['d8', '20'], 4)", qrCtx),
'M4 4h2v1h-2zM7 4h2v1h-2zM6 5h1v1h-1z');
assert.equal(vm.runInContext("qrPath(3, ['0', '0', '0'], 4)", qrCtx), '');
console.log('PASS the invite QR is drawn from the console grid');
})();