| 1 | // room-space.test.mjs — Space toggles mute in the room, gated without a |
| 2 | // browser. |
| 3 | // |
| 4 | // node --test test/browser/room-space.test.mjs |
| 5 | // |
| 6 | // Same trick as room-mute.test.mjs: the functions are lifted out of the |
| 7 | // shipped room.js by name, so a rename fails here loudly instead of |
| 8 | // passing against a copy. |
| 9 | // |
| 10 | // spaceKeyAction 'toggle', 'swallow' for a repeat, or '' when the key |
| 11 | // is not ours |
| 12 | // spaceBelongsTo whether Space already means something to the |
| 13 | // focused element (typing, activation, opening) |
| 14 | import { test } from 'node:test'; |
| 15 | import assert from 'node:assert/strict'; |
| 16 | import { readFileSync } from 'node:fs'; |
| 17 | import { fileURLToPath } from 'node:url'; |
| 18 | |
| 19 | const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| 20 | const src = readFileSync(roomJS, 'utf8'); |
| 21 | |
| 22 | function lift(source, name) { |
| 23 | const start = source.indexOf('function ' + name + '('); |
| 24 | assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); |
| 25 | let depth = 0; |
| 26 | for (let i = source.indexOf('{', start); i < source.length; i++) { |
| 27 | if (source[i] === '{') depth += 1; |
| 28 | else if (source[i] === '}') { |
| 29 | depth -= 1; |
| 30 | if (depth === 0) return source.slice(start, i + 1); |
| 31 | } |
| 32 | } |
| 33 | assert.fail('unbalanced braces reading ' + name + ' out of room.js'); |
| 34 | } |
| 35 | |
| 36 | // eslint-disable-next-line no-new-func |
| 37 | const spaceKeyAction = new Function( |
| 38 | lift(src, 'spaceBelongsTo') + '\n' + lift(src, 'spaceKeyAction') + '; return spaceKeyAction;', |
| 39 | )(); |
| 40 | |
| 41 | // A stand-in for a focused element: a tag, attributes, and whether it is |
| 42 | // editable, which is all the rules read. |
| 43 | function el(tagName, attrs = {}, isContentEditable = false) { |
| 44 | return { |
| 45 | tagName, |
| 46 | isContentEditable, |
| 47 | hasAttribute: (name) => Object.prototype.hasOwnProperty.call(attrs, name), |
| 48 | getAttribute: (name) => (Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null), |
| 49 | }; |
| 50 | } |
| 51 | |
| 52 | const space = (extra = {}) => ({ key: ' ', code: 'Space', repeat: false, ...extra }); |
| 53 | const body = el('BODY'); |
| 54 | |
| 55 | test('Space on the page toggles mute', () => { |
| 56 | assert.equal(spaceKeyAction(space(), body), 'toggle'); |
| 57 | assert.equal(spaceKeyAction(space(), el('DIV')), 'toggle'); |
| 58 | assert.equal(spaceKeyAction(space(), el('VIDEO')), 'toggle', 'a tile is not a control'); |
| 59 | assert.equal(spaceKeyAction(space(), null), 'toggle'); |
| 60 | // Either half of the key's identity is enough: some layouts report a |
| 61 | // different key for the physical Space bar, and some synthetic events |
| 62 | // carry no code. |
| 63 | assert.equal(spaceKeyAction({ key: ' ', repeat: false }, body), 'toggle'); |
| 64 | assert.equal(spaceKeyAction({ key: 'Unidentified', code: 'Space', repeat: false }, body), 'toggle'); |
| 65 | }); |
| 66 | |
| 67 | test('a held Space is handled once, and its repeats are swallowed', () => { |
| 68 | assert.equal(spaceKeyAction(space({ repeat: true }), body), 'swallow'); |
| 69 | }); |
| 70 | |
| 71 | test('other keys and modified Space are not ours', () => { |
| 72 | assert.equal(spaceKeyAction({ key: 'm', code: 'KeyM', repeat: false }, body), ''); |
| 73 | assert.equal(spaceKeyAction({ key: 'Enter', code: 'Enter', repeat: false }, body), ''); |
| 74 | for (const mod of ['ctrlKey', 'metaKey', 'altKey', 'shiftKey']) { |
| 75 | assert.equal(spaceKeyAction(space({ [mod]: true }), body), '', mod + '+Space is a shortcut of its own'); |
| 76 | } |
| 77 | assert.equal(spaceKeyAction(space({ isComposing: true }), body), '', 'an input method composing text owns it'); |
| 78 | assert.equal(spaceKeyAction(undefined, body), ''); |
| 79 | }); |
| 80 | |
| 81 | test('Space is typing in every text field, including the chat composer', () => { |
| 82 | for (const target of [ |
| 83 | el('INPUT', { type: 'text' }), |
| 84 | el('INPUT'), |
| 85 | el('TEXTAREA', { id: 'chat-text' }), |
| 86 | el('textarea'), |
| 87 | el('DIV', {}, true), // contenteditable, or inside one |
| 88 | el('DIV', { role: 'textbox' }), |
| 89 | el('INPUT', { type: 'search' }), |
| 90 | ]) { |
| 91 | assert.equal(spaceKeyAction(space(), target), '', JSON.stringify(target.tagName)); |
| 92 | assert.equal(spaceKeyAction(space({ repeat: true }), target), '', 'repeats in a field are typing too'); |
| 93 | } |
| 94 | }); |
| 95 | |
| 96 | test('Space is activation on buttons, links and other controls', () => { |
| 97 | for (const target of [ |
| 98 | el('BUTTON', { id: 'mute' }), |
| 99 | el('BUTTON', { id: 'leave' }), |
| 100 | el('INPUT', { type: 'checkbox' }), |
| 101 | el('INPUT', { type: 'button' }), |
| 102 | el('SELECT'), |
| 103 | el('SUMMARY'), |
| 104 | el('A', { href: '/m/abc' }), |
| 105 | el('VIDEO', { controls: '' }), |
| 106 | el('DIV', { role: 'button' }), |
| 107 | el('SPAN', { role: ' Switch ' }), |
| 108 | el('LI', { role: 'menuitem' }), |
| 109 | el('DIV', { role: 'slider' }), |
| 110 | ]) { |
| 111 | assert.equal(spaceKeyAction(space(), target), '', target.tagName + ' ' + JSON.stringify(target.getAttribute('role'))); |
| 112 | } |
| 113 | // An anchor with no href is not a link and does nothing with Space. |
| 114 | assert.equal(spaceKeyAction(space(), el('A')), 'toggle'); |
| 115 | // A role that is not a control is not claimed. |
| 116 | assert.equal(spaceKeyAction(space(), el('DIV', { role: 'region' })), 'toggle'); |
| 117 | }); |
| 118 | |