cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/website-download-wiring
goq / portal / control-runtime.test.mjs
control-runtime.test.mjs 12.1K
1import assert from 'node:assert/strict';2import { readFile } from 'node:fs/promises';3import test from 'node:test';45import {6 BROWSER_POINTER_LOCK_TIMEOUT_MS,7 NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS,8 createControlRuntime,9} from './control-runtime.mjs';1011const main = await readFile(new URL('./main.js', import.meta.url), 'utf8');1213function deferred() {14 let resolve;15 let reject;16 const promise = new Promise((resolvePromise, rejectPromise) => {17 resolve = resolvePromise;18 reject = rejectPromise;19 });20 return { promise, resolve, reject };21}2223async function eventually(assertion, timeoutMs = 300) {24 const deadline = performance.now() + timeoutMs;25 let lastError;26 while (performance.now() < deadline) {27 try {28 assertion();29 return;30 } catch (error) {31 lastError = error;32 await new Promise((resolve) => setTimeout(resolve, 1));33 }34 }35 throw lastError;36}3738class FakeEventTarget {39 constructor() {40 this.listeners = new Map();41 }4243 addEventListener(type, listener) {44 const listeners = this.listeners.get(type) ?? new Set();45 listeners.add(listener);46 this.listeners.set(type, listeners);47 }4849 removeEventListener(type, listener) {50 this.listeners.get(type)?.delete(listener);51 }5253 dispatch(type) {54 for (const listener of [...(this.listeners.get(type) ?? [])]) listener();55 }56}5758function newHarness({59 invokeCursorGrab = async () => false,60 pointerRequest,61 pointerLockTimeoutMs,62 scheduleTimeout,63} = {}) {64 const target = {};65 const eventTarget = new FakeEventTarget();66 let owner = null;67 let connected = true;68 let disconnecting = false;69 const capabilities = {70 control: true,71 relativePointer: true,72 absolutePointer: false,73 gamepad: true,74 };75 const sent = [];76 const calls = {77 clear: 0,78 releaseHeld: 0,79 drains: [],80 publish: 0,81 resetEscape: 0,82 change: 0,83 releaseFailures: [],84 exits: 0,85 hostReleases: 0,86 exitOrder: [],87 };88 const inputRuntime = {89 send: (event) => { sent.push(event); return true; },90 clear: () => { calls.clear += 1; },91 releaseHeld: () => { calls.releaseHeld += 1; calls.exitOrder.push('neutral'); },92 drain: async (timeoutMs) => { calls.drains.push(timeoutMs); calls.exitOrder.push('drain'); },93 };94 const pointerLock = {95 target,96 eventTarget,97 getOwner: () => owner,98 request: pointerRequest ?? (() => {99 owner = target;100 eventTarget.dispatch('pointerlockchange');101 }),102 exit: () => {103 calls.exits += 1;104 owner = null;105 eventTarget.dispatch('pointerlockchange');106 },107 };108 const warnings = [];109 const errors = [];110 const runtime = createControlRuntime({111 getConnection: () => ({ connected, disconnecting, capabilities }),112 inputRuntime,113 invokeCursorGrab,114 pointerLock,115 publishController: () => { calls.publish += 1; },116 resetControllerEscape: () => { calls.resetEscape += 1; },117 onChange: () => { calls.change += 1; },118 onExit: async () => { calls.hostReleases += 1; calls.exitOrder.push('host-release'); },119 onReleaseFailure: (error) => calls.releaseFailures.push(error),120 pointerLockTimeoutMs,121 scheduleTimeout,122 logger: {123 warn: (...args) => warnings.push(args),124 error: (...args) => errors.push(args),125 },126 });127 return {128 runtime,129 calls,130 capabilities,131 sent,132 warnings,133 errors,134 eventTarget,135 target,136 pointerLock,137 get owner() { return owner; },138 set owner(value) { owner = value; },139 set connected(value) { connected = value; },140 set disconnecting(value) { disconnecting = value; },141 };142}143144test('explicit native false bypasses browser lock and controller activation stays gated until A release', async () => {145 let pointerRequests = 0;146 const harness = newHarness({147 invokeCursorGrab: async () => false,148 pointerRequest: () => { pointerRequests += 1; },149 });150151 await harness.runtime.toggle({ controllerInitiated: true });152153 assert.equal(harness.runtime.active, true);154 assert.equal(harness.runtime.browserPointerLockRequired, false);155 assert.equal(pointerRequests, 0);156 assert.equal(harness.calls.publish, 1);157 assert.equal(harness.sent.length, 1);158 assert.equal(harness.sent[0].t, 'gp');159 assert.equal(harness.runtime.activationGateActive, true);160 assert.equal(harness.runtime.acceptsControllerInput({ a: true }), false);161 assert.equal(harness.runtime.acceptsControllerInput({ a: false }), true);162 assert.equal(harness.runtime.activationGateActive, false);163});164165test('absolute-pointer control never enters the gated relative-capture path', async () => {166 let nativeGrabCalls = 0;167 let pointerLockRequests = 0;168 const harness = newHarness({169 invokeCursorGrab: async () => {170 nativeGrabCalls += 1;171 return true;172 },173 pointerRequest: () => {174 pointerLockRequests += 1;175 },176 });177 harness.capabilities.relativePointer = false;178 harness.capabilities.absolutePointer = true;179180 await harness.runtime.toggle();181182 assert.equal(harness.runtime.active, true);183 assert.equal(nativeGrabCalls, 0);184 assert.equal(pointerLockRequests, 0);185 assert.equal(harness.calls.publish, 1);186});187188test('ordinary toggle exit queues neutral and releases held input without resetting controller escape', async () => {189 const harness = newHarness({ invokeCursorGrab: async () => false });190 await harness.runtime.toggle();191 await harness.runtime.toggle();192 await eventually(() => assert.equal(harness.sent.length, 1));193194 assert.equal(harness.runtime.active, false);195 assert.equal(harness.sent[0].t, 'gp');196 assert.equal(harness.calls.releaseHeld, 1);197 assert.equal(harness.calls.resetEscape, 0);198 assert.equal(harness.calls.change, 2);199});200201test('browser acquisition reasserts native grab after ownership and publishes non-controller state', async () => {202 const grabs = [];203 const harness = newHarness({204 invokeCursorGrab: async (grab) => { grabs.push(grab); return true; },205 });206207 await harness.runtime.toggle();208209 assert.deepEqual(grabs, [true, true]);210 assert.equal(harness.owner, harness.target);211 assert.equal(harness.runtime.active, true);212 assert.equal(harness.calls.publish, 1);213 assert.equal(harness.sent.length, 0);214 assert.equal(harness.calls.change, 1);215});216217test('native cursor commands remain serialized', async () => {218 const first = deferred();219 const invocations = [];220 const harness = newHarness({221 invokeCursorGrab: (grab) => {222 invocations.push(grab);223 return invocations.length === 1 ? first.promise : Promise.resolve(true);224 },225 });226227 const one = harness.runtime.reassertNativeGrab();228 const two = harness.runtime.reassertNativeGrab();229 await Promise.resolve();230 assert.deepEqual(invocations, [true]);231 first.resolve(true);232 await Promise.all([one, two]);233 assert.deepEqual(invocations, [true, true]);234});235236test('browser ownership timeout exits the transition and resets controller escape', async () => {237 const harness = newHarness({238 invokeCursorGrab: async () => true,239 pointerRequest: () => {},240 pointerLockTimeoutMs: 5,241 });242243 await harness.runtime.toggle();244245 assert.equal(harness.runtime.active, false);246 assert.equal(harness.runtime.transitioning, false);247 assert.equal(harness.calls.resetEscape, 1);248 assert.equal(harness.calls.releaseHeld, 1);249 assert.equal(harness.calls.change, 1);250 assert.match(harness.warnings[0][1].message, /timed out/);251});252253test('native-only post-acquisition rejection retains the existing transition-state quirk', async () => {254 const nativeGrab = deferred();255 const harness = newHarness({ invokeCursorGrab: async () => nativeGrab.promise });256 const entering = harness.runtime.toggle();257 await Promise.resolve();258 harness.disconnecting = true;259 nativeGrab.resolve(false);260 await entering;261262 assert.equal(harness.runtime.active, false);263 assert.equal(harness.runtime.transitioning, true);264 assert.equal(harness.calls.change, 0);265 assert.equal(harness.calls.releaseHeld, 0);266});267268test('losing a browser-owned lock exits active control exactly once', async () => {269 const harness = newHarness({ invokeCursorGrab: async () => true });270 await harness.runtime.toggle();271 harness.owner = null;272273 assert.equal(harness.runtime.handleBrowserPointerLockChange(), true);274 assert.equal(harness.runtime.active, false);275 assert.equal(harness.calls.resetEscape, 1);276 assert.equal(harness.calls.releaseHeld, 1);277 assert.equal(harness.runtime.handleBrowserPointerLockChange(), false);278 await eventually(() => assert.equal(harness.calls.hostReleases, 1));279 assert.deepEqual(harness.calls.exitOrder.slice(0, 3), ['neutral', 'drain', 'host-release']);280});281282test('a native-only session ignores browser ownership changes', async () => {283 const harness = newHarness({ invokeCursorGrab: async () => false });284 await harness.runtime.toggle();285286 assert.equal(harness.runtime.handleBrowserPointerLockChange(), false);287 assert.equal(harness.runtime.active, true);288 assert.equal(harness.calls.resetEscape, 0);289});290291test('late acquisition cannot reactivate a cancelled transition or leave browser ownership behind', async () => {292 const firstGrab = deferred();293 let grabCalls = 0;294 const harness = newHarness({295 invokeCursorGrab: async () => {296 grabCalls += 1;297 if (grabCalls === 1) return firstGrab.promise;298 return true;299 },300 });301302 const entering = harness.runtime.toggle();303 await Promise.resolve();304 harness.runtime.exit();305 firstGrab.resolve(true);306 await entering;307 await eventually(() => assert.equal(harness.owner, null));308309 assert.equal(harness.runtime.active, false);310 assert.equal(harness.runtime.transitioning, false);311 assert.ok(harness.calls.exits >= 1);312 assert.equal(harness.calls.publish, 0);313});314315test('native cursor release uses exactly three bounded retries before reporting failure', async () => {316 let acquisition = true;317 let releaseAttempts = 0;318 const delays = [];319 const harness = newHarness({320 invokeCursorGrab: async (grab) => {321 if (grab) return acquisition ? false : true;322 releaseAttempts += 1;323 throw new Error(`release-${releaseAttempts}`);324 },325 scheduleTimeout: (callback, delay) => {326 delays.push(delay);327 return setTimeout(callback, 0);328 },329 });330 await harness.runtime.toggle();331 acquisition = false;332333 harness.runtime.exit();334 await eventually(() => assert.equal(harness.calls.releaseFailures.length, 1));335336 assert.equal(releaseAttempts, 3);337 assert.deepEqual(delays, [16, 50]);338 assert.deepEqual(NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS, [0, 16, 50]);339 assert.equal(harness.errors[0][0], 'native cursor release failed after bounded retries:');340});341342test('disconnect exits control and awaits the requested finite input drain', async () => {343 const harness = newHarness({ invokeCursorGrab: async () => false });344 await harness.runtime.toggle();345 await harness.runtime.prepareDisconnect(250);346347 assert.equal(harness.runtime.active, false);348 assert.equal(harness.calls.releaseHeld, 1);349 assert.deepEqual(harness.calls.drains, [250, 250]);350 assert.equal(harness.calls.change, 2);351});352353test('accepted and disconnected resets preserve their distinct UI notification behavior', () => {354 const harness = newHarness();355356 harness.runtime.resetAcceptedConnection();357 assert.equal(harness.calls.clear, 1);358 assert.equal(harness.calls.change, 0);359360 harness.runtime.resetDisconnected();361 assert.equal(harness.calls.clear, 2);362 assert.equal(harness.calls.change, 1);363});364365test('runtime constants pin existing browser timeout and disconnect retry policy', () => {366 assert.equal(BROWSER_POINTER_LOCK_TIMEOUT_MS, 500);367 assert.deepEqual(NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS, [0, 16, 50]);368});369370test('Portal delegates control state while retaining DOM presentation and registration', () => {371 assert.match(main, /controlRuntime = createControlRuntime\(\{/);372 assert.match(373 main,374 /document\.addEventListener\('pointerlockchange', handleBrowserPointerLockChange\)/,375 );376 assert.match(main, /const controlling = controlRuntime\.setInactiveIfUnavailable\(available\)/);377 assert.match(main, /controlRuntime\.requestBrowserPointerLock\(\)/);378 assert.match(main, /controlRuntime\.reassertNativeGrab\(\)/);379 assert.doesNotMatch(main, /let controlMode =/);380 assert.doesNotMatch(main, /let nativeCursorCommand =/);381 assert.doesNotMatch(main, /function releaseNativeCursorGrab\(/);382});