| 1 | // room-window.test.mjs — when the call window's controls show |
| 2 | // (2026-09-28), gated without a browser. |
| 3 | // |
| 4 | // node --test test/browser/room-window.test.mjs |
| 5 | // |
| 6 | // The same trick as room-layout.test.mjs: the rule is lifted out of the |
| 7 | // shipped call-window.js by name, and its constants are held equal to |
| 8 | // the Mac's ControlsVisibility (CallChrome.swift), so the web and the |
| 9 | // Mac cannot drift apart in silence. So is Grid's fit (call-layout.js's |
| 10 | // gridColumns, the Mac's GridFit.columns) and where a dropped self view |
| 11 | // settles (pipCorner, PiPLayout.corner). |
| 12 | import { test } from 'node:test'; |
| 13 | import assert from 'node:assert/strict'; |
| 14 | import { readFileSync } from 'node:fs'; |
| 15 | import { fileURLToPath } from 'node:url'; |
| 16 | |
| 17 | const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); |
| 18 | const windowJS = read('../../internal/calls/static/call-window.js'); |
| 19 | const roomJS = read('../../internal/calls/static/room.js'); |
| 20 | const layoutJS = read('../../internal/calls/static/call-layout.js'); |
| 21 | const swift = read('../../native/apple/Sources/CallsKit/CallChrome.swift'); |
| 22 | const css = read('../../internal/calls/static/calls.css'); |
| 23 | |
| 24 | function lift(source, name) { |
| 25 | const start = source.indexOf('function ' + name + '('); |
| 26 | assert.notEqual(start, -1, name + ' is not there any more — this test is stale, not passing'); |
| 27 | let depth = 0; |
| 28 | for (let i = source.indexOf('{', start); i < source.length; i++) { |
| 29 | if (source[i] === '{') depth += 1; |
| 30 | else if (source[i] === '}') { |
| 31 | depth -= 1; |
| 32 | if (depth === 0) return source.slice(start, i + 1); |
| 33 | } |
| 34 | } |
| 35 | assert.fail('unbalanced braces reading ' + name); |
| 36 | } |
| 37 | function jsConst(name) { |
| 38 | const m = windowJS.match(new RegExp('const ' + name + ' = ([0-9.]+);')); |
| 39 | assert.ok(m, name + ' is not a numeric const in call-window.js any more'); |
| 40 | return Number(m[1]); |
| 41 | } |
| 42 | function swiftConst(name) { |
| 43 | const m = swift.match(new RegExp('static let ' + name + ': TimeInterval = ([0-9.]+)')); |
| 44 | assert.ok(m, name + ' is not in CallChrome.swift any more'); |
| 45 | return Number(m[1]); |
| 46 | } |
| 47 | |
| 48 | const CONSTS = ['CONTROLS_POINTER_DELAY', 'CONTROLS_TOUCH_DELAY', 'CONTROLS_EXIT_DELAY'] |
| 49 | .map((n) => 'const ' + n + ' = ' + jsConst(n) + ';').join('\n'); |
| 50 | // eslint-disable-next-line no-new-func |
| 51 | const m = new Function(CONSTS + '\n' + ['controlsDelay', 'controlsStart', 'controlsHeld', 'controlsStep'] |
| 52 | .map((n) => lift(windowJS, n)).join('\n') + '; return { controlsStart, controlsStep, controlsHeld };')(); |
| 53 | // eslint-disable-next-line no-new-func |
| 54 | const layout = new Function(['gridColumns', 'gridTileWidth', 'gridFit', 'pipCorner'] |
| 55 | .map((n) => lift(layoutJS, n)).join('\n') + '; return { gridColumns, gridFit, pipCorner };')(); |
| 56 | |
| 57 | const run = (s, events) => events.reduce((st, [t, ev]) => m.controlsStep(st, ev, t), s); |
| 58 | |
| 59 | test('the delays are the Mac\'s', () => { |
| 60 | assert.equal(jsConst('CONTROLS_POINTER_DELAY'), swiftConst('pointerDelay')); |
| 61 | assert.equal(jsConst('CONTROLS_TOUCH_DELAY'), swiftConst('touchDelay')); |
| 62 | assert.equal(jsConst('CONTROLS_EXIT_DELAY'), swiftConst('pointerExitDelay')); |
| 63 | }); |
| 64 | |
| 65 | test('the fades are the Mac\'s: 0.35 s going, 0.2 s coming back', () => { |
| 66 | const out = css.match(/--calls-fade-out: ([0-9.]+)s;/); |
| 67 | const back = css.match(/--calls-fade-in: ([0-9.]+)s;/); |
| 68 | assert.ok(out && back, 'calls.css has no fade durations'); |
| 69 | assert.equal(Number(out[1]), swiftConst('fadeOutDuration')); |
| 70 | assert.equal(Number(back[1]), swiftConst('fadeInDuration')); |
| 71 | }); |
| 72 | |
| 73 | test('they start visible and go 3 s later', () => { |
| 74 | const s = m.controlsStart('pointer', 100); |
| 75 | assert.equal(s.visible, true); |
| 76 | assert.equal(s.hideAt, 103); |
| 77 | assert.equal(m.controlsStep(s, { type: 'tick' }, 102.9).visible, true); |
| 78 | assert.equal(m.controlsStep(s, { type: 'tick' }, 103).visible, false); |
| 79 | }); |
| 80 | |
| 81 | test('moving the pointer shows them and starts the wait again', () => { |
| 82 | const s = run(m.controlsStart('pointer', 0), [[4, { type: 'tick' }], [5, { type: 'move' }]]); |
| 83 | assert.equal(s.visible, true); |
| 84 | assert.equal(s.hideAt, 8); |
| 85 | assert.equal(s.pointerInside, true); |
| 86 | assert.equal(run(s, [[7, { type: 'move' }], [9, { type: 'tick' }]]).visible, true); |
| 87 | assert.equal(run(s, [[7, { type: 'move' }], [10, { type: 'tick' }]]).visible, false); |
| 88 | }); |
| 89 | |
| 90 | test('leaving the window hides them 1.5 s later, never later than they were due', () => { |
| 91 | let s = run(m.controlsStart('pointer', 0), [[0.5, { type: 'move' }], [1, { type: 'exit' }]]); |
| 92 | assert.equal(s.hideAt, 2.5); |
| 93 | assert.equal(s.pointerInside, false); |
| 94 | s = run(m.controlsStart('pointer', 0), [[0, { type: 'move' }], [2.5, { type: 'exit' }]]); |
| 95 | assert.equal(s.hideAt, 3, 'due at 3 already, so leaving at 2.5 does not put it off to 4'); |
| 96 | }); |
| 97 | |
| 98 | test('a hold keeps them up; letting go starts the usual wait', () => { |
| 99 | let s = run(m.controlsStart('pointer', 0), [[1, { type: 'hold', hold: 'menuOpen', on: true }]]); |
| 100 | assert.equal(s.hideAt, null); |
| 101 | assert.equal(run(s, [[60, { type: 'tick' }]]).visible, true); |
| 102 | s = run(s, [[60, { type: 'hold', hold: 'menuOpen', on: false }]]); |
| 103 | assert.equal(s.hideAt, 63); |
| 104 | // A hold shows hidden controls. |
| 105 | s = run(m.controlsStart('pointer', 0), [[5, { type: 'tick' }]]); |
| 106 | assert.equal(s.visible, false); |
| 107 | assert.equal(run(s, [[6, { type: 'hold', hold: 'keyboardFocus', on: true }]]).visible, true); |
| 108 | }); |
| 109 | |
| 110 | test('leaving the window lets go of the pointer on the bar, but not of other holds', () => { |
| 111 | let s = run(m.controlsStart('pointer', 0), [[0, { type: 'move' }], [0, { type: 'hold', hold: 'pointerOverControls', on: true }], [1, { type: 'exit' }]]); |
| 112 | assert.equal(m.controlsHeld(s), false); |
| 113 | assert.equal(s.hideAt, 2.5); |
| 114 | s = run(m.controlsStart('pointer', 0), [[0, { type: 'hold', hold: 'menuOpen', on: true }], [1, { type: 'exit' }]]); |
| 115 | assert.equal(s.hideAt, null); |
| 116 | }); |
| 117 | |
| 118 | test('a tap toggles, unless they are held', () => { |
| 119 | let s = m.controlsStart('touch', 0); |
| 120 | s = m.controlsStep(s, { type: 'tap' }, 1); |
| 121 | assert.equal(s.visible, false); |
| 122 | s = m.controlsStep(s, { type: 'tap' }, 2); |
| 123 | assert.equal(s.visible, true); |
| 124 | assert.equal(s.hideAt, 5); |
| 125 | s = run(s, [[2, { type: 'hold', hold: 'menuOpen', on: true }], [3, { type: 'tap' }]]); |
| 126 | assert.equal(s.visible, true, 'a held tap only starts the wait again'); |
| 127 | }); |
| 128 | |
| 129 | test('activity (Tab, a click) shows them', () => { |
| 130 | const s = run(m.controlsStart('pointer', 0), [[4, { type: 'tick' }], [5, { type: 'activity' }]]); |
| 131 | assert.equal(s.visible, true); |
| 132 | assert.equal(s.hideAt, 8); |
| 133 | }); |
| 134 | |
| 135 | test('Grid fits as the Mac\'s GridFit does', () => { |
| 136 | // Two 16:9 tiles in a laptop's window: one above the other is larger. |
| 137 | assert.equal(layout.gridColumns(2, 1264, 784, 16 / 9, 4), 1); |
| 138 | // In a wide, short one, side by side. |
| 139 | assert.equal(layout.gridColumns(2, 1600, 500, 16 / 9, 4), 2); |
| 140 | assert.equal(layout.gridColumns(4, 1400, 860, 16 / 9, 4), 2); |
| 141 | assert.equal(layout.gridColumns(1, 1400, 860, 16 / 9, 4), 1); |
| 142 | const small = layout.gridFit(8, 400, 300, 72); |
| 143 | assert.equal(small.tileW, 200, 'no tile under 200 wide: it scrolls instead'); |
| 144 | const fit = layout.gridFit(4, 1400, 860, 72); |
| 145 | assert.ok(fit.tileW * fit.columns + (fit.columns - 1) * 4 <= 1392); |
| 146 | assert.ok(fit.tileH * fit.rows + (fit.rows - 1) * 4 <= 852); |
| 147 | assert.equal(fit.lastRow, 2); |
| 148 | }); |
| 149 | |
| 150 | test('a dropped self view settles where it was headed', () => { |
| 151 | assert.equal(layout.pipCorner({ x: 100, y: 100 }, null, 1000, 600), 'top-left'); |
| 152 | assert.equal(layout.pipCorner({ x: 900, y: 500 }, null, 1000, 600), 'bottom-right'); |
| 153 | assert.equal(layout.pipCorner({ x: 450, y: 500 }, { x: 700, y: 100 }, 1000, 600), 'top-right', 'a flick carries it'); |
| 154 | }); |
| 155 | |
| 156 | test('every state the title pill carried is a notice now (2026-10-02)', () => { |
| 157 | // The pill said Connecting, Waiting to be let in, The room is full, |
| 158 | // Reconnecting and Call ended over the title. It is gone; the status |
| 159 | // notice (#status, room.js's statusLine) says each of them, and the |
| 160 | // call being over is stop()'s sentence with its way out. |
| 161 | // eslint-disable-next-line no-new-func |
| 162 | const statusLine = new Function(lift(roomJS, 'statusLine') + '; return statusLine;')(); |
| 163 | assert.deepEqual(statusLine({ admitted: false }, false, 0, true), ['Connecting to the call.', false]); |
| 164 | assert.deepEqual(statusLine({ admitted: false, peer: 'p', joined: true }, false, 0, false), |
| 165 | ['Waiting for someone to let you in.', false]); |
| 166 | assert.deepEqual(statusLine({ admitted: false, peer: 'p', joined: true }, false, 0, true), |
| 167 | ['Connecting to the call.', false], 'a member is never waiting'); |
| 168 | assert.deepEqual(statusLine({ full: true }, false, 0, true), ['The room is full.', false]); |
| 169 | assert.deepEqual(statusLine({ admitted: true }, true, 1, true), ['Reconnecting…', false]); |
| 170 | assert.deepEqual(statusLine({ admitted: true }, false, 0, true), ['You are the only one here.', true]); |
| 171 | assert.deepEqual(statusLine({ admitted: true }, false, 2, true), ['', false], 'in the meeting: nothing to say'); |
| 172 | assert.ok(/This meeting has ended/.test(roomJS), 'the call being over is said'); |
| 173 | assert.ok(!/callState|call-state/.test(windowJS), 'and no pill is drawn'); |
| 174 | }); |
| 175 | |
| 176 | // 2026-10-03, Till on an iPhone: "kept cutting in and out". Safari there |
| 177 | // stops the camera, the microphone and the sound with the tab in the |
| 178 | // background or the screen locked, so the room says so, once a session. |
| 179 | test('appleTouch: iPhone and iPad, in any browser there, and nothing else', () => { |
| 180 | const appleTouch = new Function(lift(windowJS, 'appleTouch') + '; return appleTouch;')(); |
| 181 | const iphone = 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1'; |
| 182 | const crios = 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0 Mobile/15E148 Safari/604.1'; |
| 183 | const mac = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15'; |
| 184 | assert.equal(appleTouch({ platform: 'iPhone', userAgent: iphone, maxTouchPoints: 5 }), true); |
| 185 | assert.equal(appleTouch({ platform: 'iPhone', userAgent: crios, maxTouchPoints: 5 }), true, 'Chrome on iPhone is Safari underneath'); |
| 186 | assert.equal(appleTouch({ platform: 'MacIntel', userAgent: mac, maxTouchPoints: 5 }), true, 'an iPad asking for the desktop site'); |
| 187 | assert.equal(appleTouch({ platform: 'MacIntel', userAgent: mac, maxTouchPoints: 0 }), false, 'a Mac'); |
| 188 | assert.equal(appleTouch({ platform: 'Linux armv8l', userAgent: 'Mozilla/5.0 (Linux; Android 15) Chrome/140 Mobile', maxTouchPoints: 5 }), false); |
| 189 | assert.equal(appleTouch(undefined), false); |
| 190 | }); |
| 191 | |