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 };86 const inputRuntime = {87 send: (event) => { sent.push(event); return true; },88 clear: () => { calls.clear += 1; },89 releaseHeld: () => { calls.releaseHeld += 1; },90 drain: async (timeoutMs) => { calls.drains.push(timeoutMs); },91 };92 const pointerLock = {93 target,94 eventTarget,95 getOwner: () => owner,96 request: pointerRequest ?? (() => {97 owner = target;98 eventTarget.dispatch('pointerlockchange');99 }),100 exit: () => {101 calls.exits += 1;102 owner = null;103 eventTarget.dispatch('pointerlockchange');104 },105 };106 const warnings = [];107 const errors = [];108 const runtime = createControlRuntime({109 getConnection: () => ({ connected, disconnecting, capabilities }),110 inputRuntime,111 invokeCursorGrab,112 pointerLock,113 publishController: () => { calls.publish += 1; },114 resetControllerEscape: () => { calls.resetEscape += 1; },115 onChange: () => { calls.change += 1; },116 onReleaseFailure: (error) => calls.releaseFailures.push(error),117 pointerLockTimeoutMs,118 scheduleTimeout,119 logger: {120 warn: (...args) => warnings.push(args),121 error: (...args) => errors.push(args),122 },123 });124 return {125 runtime,126 calls,127 capabilities,128 sent,129 warnings,130 errors,131 eventTarget,132 target,133 pointerLock,134 get owner() { return owner; },135 set owner(value) { owner = value; },136 set connected(value) { connected = value; },137 set disconnecting(value) { disconnecting = value; },138 };139}140141test('explicit native false bypasses browser lock and controller activation stays gated until A release', async () => {142 let pointerRequests = 0;143 const harness = newHarness({144 invokeCursorGrab: async () => false,145 pointerRequest: () => { pointerRequests += 1; },146 });147148 await harness.runtime.toggle({ controllerInitiated: true });149150 assert.equal(harness.runtime.active, true);151 assert.equal(harness.runtime.browserPointerLockRequired, false);152 assert.equal(pointerRequests, 0);153 assert.equal(harness.calls.publish, 1);154 assert.equal(harness.sent.length, 1);155 assert.equal(harness.sent[0].t, 'gp');156 assert.equal(harness.runtime.activationGateActive, true);157 assert.equal(harness.runtime.acceptsControllerInput({ a: true }), false);158 assert.equal(harness.runtime.acceptsControllerInput({ a: false }), true);159 assert.equal(harness.runtime.activationGateActive, false);160});161162test('absolute-pointer control never enters the gated relative-capture path', async () => {163 let nativeGrabCalls = 0;164 let pointerLockRequests = 0;165 const harness = newHarness({166 invokeCursorGrab: async () => {167 nativeGrabCalls += 1;168 return true;169 },170 pointerRequest: () => {171 pointerLockRequests += 1;172 },173 });174 harness.capabilities.relativePointer = false;175 harness.capabilities.absolutePointer = true;176177 await harness.runtime.toggle();178179 assert.equal(harness.runtime.active, true);180 assert.equal(nativeGrabCalls, 0);181 assert.equal(pointerLockRequests, 0);182 assert.equal(harness.calls.publish, 1);183});184185test('ordinary toggle exit queues neutral and releases held input without resetting controller escape', async () => {186 const harness = newHarness({ invokeCursorGrab: async () => false });187 await harness.runtime.toggle();188 await harness.runtime.toggle();189 await eventually(() => assert.equal(harness.sent.length, 1));190191 assert.equal(harness.runtime.active, false);192 assert.equal(harness.sent[0].t, 'gp');193 assert.equal(harness.calls.releaseHeld, 1);194 assert.equal(harness.calls.resetEscape, 0);195 assert.equal(harness.calls.change, 2);196});197198test('browser acquisition reasserts native grab after ownership and publishes non-controller state', async () => {199 const grabs = [];200 const harness = newHarness({201 invokeCursorGrab: async (grab) => { grabs.push(grab); return true; },202 });203204 await harness.runtime.toggle();205206 assert.deepEqual(grabs, [true, true]);207 assert.equal(harness.owner, harness.target);208 assert.equal(harness.runtime.active, true);209 assert.equal(harness.calls.publish, 1);210 assert.equal(harness.sent.length, 0);211 assert.equal(harness.calls.change, 1);212});213214test('native cursor commands remain serialized', async () => {215 const first = deferred();216 const invocations = [];217 const harness = newHarness({218 invokeCursorGrab: (grab) => {219 invocations.push(grab);220 return invocations.length === 1 ? first.promise : Promise.resolve(true);221 },222 });223224 const one = harness.runtime.reassertNativeGrab();225 const two = harness.runtime.reassertNativeGrab();226 await Promise.resolve();227 assert.deepEqual(invocations, [true]);228 first.resolve(true);229 await Promise.all([one, two]);230 assert.deepEqual(invocations, [true, true]);231});232233test('browser ownership timeout exits the transition and resets controller escape', async () => {234 const harness = newHarness({235 invokeCursorGrab: async () => true,236 pointerRequest: () => {},237 pointerLockTimeoutMs: 5,238 });239240 await harness.runtime.toggle();241242 assert.equal(harness.runtime.active, false);243 assert.equal(harness.runtime.transitioning, false);244 assert.equal(harness.calls.resetEscape, 1);245 assert.equal(harness.calls.releaseHeld, 1);246 assert.equal(harness.calls.change, 1);247 assert.match(harness.warnings[0][1].message, /timed out/);248});249250test('native-only post-acquisition rejection retains the existing transition-state quirk', async () => {251 const nativeGrab = deferred();252 const harness = newHarness({ invokeCursorGrab: async () => nativeGrab.promise });253 const entering = harness.runtime.toggle();254 await Promise.resolve();255 harness.disconnecting = true;256 nativeGrab.resolve(false);257 await entering;258259 assert.equal(harness.runtime.active, false);260 assert.equal(harness.runtime.transitioning, true);261 assert.equal(harness.calls.change, 0);262 assert.equal(harness.calls.releaseHeld, 0);263});264265test('losing a browser-owned lock exits active control exactly once', async () => {266 const harness = newHarness({ invokeCursorGrab: async () => true });267 await harness.runtime.toggle();268 harness.owner = null;269270 assert.equal(harness.runtime.handleBrowserPointerLockChange(), true);271 assert.equal(harness.runtime.active, false);272 assert.equal(harness.calls.resetEscape, 1);273 assert.equal(harness.calls.releaseHeld, 1);274 assert.equal(harness.runtime.handleBrowserPointerLockChange(), false);275});276277test('a native-only session ignores browser ownership changes', async () => {278 const harness = newHarness({ invokeCursorGrab: async () => false });279 await harness.runtime.toggle();280281 assert.equal(harness.runtime.handleBrowserPointerLockChange(), false);282 assert.equal(harness.runtime.active, true);283 assert.equal(harness.calls.resetEscape, 0);284});285286test('late acquisition cannot reactivate a cancelled transition or leave browser ownership behind', async () => {287 const firstGrab = deferred();288 let grabCalls = 0;289 const harness = newHarness({290 invokeCursorGrab: async () => {291 grabCalls += 1;292 if (grabCalls === 1) return firstGrab.promise;293 return true;294 },295 });296297 const entering = harness.runtime.toggle();298 await Promise.resolve();299 harness.runtime.exit();300 firstGrab.resolve(true);301 await entering;302 await eventually(() => assert.equal(harness.owner, null));303304 assert.equal(harness.runtime.active, false);305 assert.equal(harness.runtime.transitioning, false);306 assert.ok(harness.calls.exits >= 1);307 assert.equal(harness.calls.publish, 0);308});309310test('native cursor release uses exactly three bounded retries before reporting failure', async () => {311 let acquisition = true;312 let releaseAttempts = 0;313 const delays = [];314 const harness = newHarness({315 invokeCursorGrab: async (grab) => {316 if (grab) return acquisition ? false : true;317 releaseAttempts += 1;318 throw new Error(`release-${releaseAttempts}`);319 },320 scheduleTimeout: (callback, delay) => {321 delays.push(delay);322 return setTimeout(callback, 0);323 },324 });325 await harness.runtime.toggle();326 acquisition = false;327328 harness.runtime.exit();329 await eventually(() => assert.equal(harness.calls.releaseFailures.length, 1));330331 assert.equal(releaseAttempts, 3);332 assert.deepEqual(delays, [16, 50]);333 assert.deepEqual(NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS, [0, 16, 50]);334 assert.equal(harness.errors[0][0], 'native cursor release failed after bounded retries:');335});336337test('disconnect exits control and awaits the requested finite input drain', async () => {338 const harness = newHarness({ invokeCursorGrab: async () => false });339 await harness.runtime.toggle();340 await harness.runtime.prepareDisconnect(250);341342 assert.equal(harness.runtime.active, false);343 assert.equal(harness.calls.releaseHeld, 1);344 assert.deepEqual(harness.calls.drains, [250]);345 assert.equal(harness.calls.change, 2);346});347348test('accepted and disconnected resets preserve their distinct UI notification behavior', () => {349 const harness = newHarness();350351 harness.runtime.resetAcceptedConnection();352 assert.equal(harness.calls.clear, 1);353 assert.equal(harness.calls.change, 0);354355 harness.runtime.resetDisconnected();356 assert.equal(harness.calls.clear, 2);357 assert.equal(harness.calls.change, 1);358});359360test('runtime constants pin existing browser timeout and disconnect retry policy', () => {361 assert.equal(BROWSER_POINTER_LOCK_TIMEOUT_MS, 500);362 assert.deepEqual(NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS, [0, 16, 50]);363});364365test('Portal delegates control state while retaining DOM presentation and registration', () => {366 assert.match(main, /controlRuntime = createControlRuntime\(\{/);367 assert.match(368 main,369 /document\.addEventListener\('pointerlockchange', handleBrowserPointerLockChange\)/,370 );371 assert.match(main, /const controlling = controlRuntime\.setInactiveIfUnavailable\(available\)/);372 assert.match(main, /controlRuntime\.requestBrowserPointerLock\(\)/);373 assert.match(main, /controlRuntime\.reassertNativeGrab\(\)/);374 assert.doesNotMatch(main, /let controlMode =/);375 assert.doesNotMatch(main, /let nativeCursorCommand =/);376 assert.doesNotMatch(main, /function releaseNativeCursorGrab\(/);377});