fix(web): drop sequence aria-keyshortcuts and use fake timers in keyboard tests
- commit
- eb30609584c558aca1ed12cb36b8b8581d931d5e
- author
- brice · · 2026-10-22 07:58 UTC
- committer
- brice · · 2026-10-22 08:51 UTC
- parent
- a8c4b10
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +87 -97
M internal/adapters/web/jstest/keyboard.test.mjs +38 -4
| @@ -4,17 +4,25 @@ import { readFileSync } from 'node:fs'; | |||
| 4 | 4 | import { test } from 'node:test'; | |
| 5 | 5 | import { runInNewContext } from 'node:vm'; | |
| 6 | 6 | const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | |
| 7 | - | function keyboard({ off = false, typing = false, dialog = false } = {}) { | |
| 7 | + | function keyboard({ owner = true, off = false, typing = false, dialog = false } = {}) { | |
| 8 | 8 | let handler; | |
| 9 | 9 | const fired = []; | |
| 10 | + | const timers = new Map(); | |
| 11 | + | let timerID = 0; | |
| 10 | 12 | runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), { | |
| 11 | 13 | addEventListener: (_, fn) => { handler = fn; }, | |
| 12 | 14 | window: { ermiteKeys: { blocks: () => off } }, | |
| 13 | 15 | ermite: { fire: name => fired.push(name) }, | |
| 14 | - | $: selector => selector === '[data-inbox]' || (dialog && selector === 'dialog[open]'), | |
| 15 | - | closeMenus: () => false, clearTimeout() {}, setTimeout() {}, | |
| 16 | + | $: selector => (owner && selector === '[data-inbox]') || (dialog && selector === 'dialog[open]'), | |
| 17 | + | closeMenus: () => false, | |
| 18 | + | clearTimeout: id => timers.delete(id), | |
| 19 | + | setTimeout: fn => { timers.set(++timerID, fn); return timerID; }, | |
| 16 | 20 | }); | |
| 17 | - | return { fired, press(key, shiftKey = false) { | |
| 21 | + | return { fired, expireTimers() { | |
| 22 | + | const callbacks = [...timers.values()]; | |
| 23 | + | timers.clear(); | |
| 24 | + | for (const callback of callbacks) callback(); | |
| 25 | + | }, press(key, shiftKey = false) { | |
| 18 | 26 | handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} }); | |
| 19 | 27 | } }; | |
| 20 | 28 | } | |
| @@ -40,3 +48,29 @@ test('uppercase actions respect preference, fields and dialogs', () => { | |||
| 40 | 48 | assert.deepEqual(k.fired, []); | |
| 41 | 49 | } | |
| 42 | 50 | }); | |
| 51 | + | ||
| 52 | + | test('Shift+I opens inbox while issues need the g i sequence', () => { | |
| 53 | + | const k = keyboard(); | |
| 54 | + | k.press('I', true); k.press('i'); | |
| 55 | + | assert.deepEqual(k.fired, ['inbox']); | |
| 56 | + | k.press('g'); k.press('i'); | |
| 57 | + | assert.deepEqual(k.fired, ['inbox', 'i']); | |
| 58 | + | }); | |
| 59 | + | test('inbox shortcut respects preference, fields, dialogs, and owner target', () => { | |
| 60 | + | for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) { | |
| 61 | + | const k = keyboard(options); k.press('I', true); | |
| 62 | + | assert.deepEqual(k.fired, [], JSON.stringify(options)); | |
| 63 | + | } | |
| 64 | + | }); | |
| 65 | + | test('shifted punctuation still opens help and the command palette', () => { | |
| 66 | + | const k = keyboard(); | |
| 67 | + | k.press('?', true); k.press(':', true); | |
| 68 | + | assert.deepEqual(k.fired, ['help', 'cmd']); | |
| 69 | + | }); | |
| 70 | + | test('g prefix expires without scheduling real timers', () => { | |
| 71 | + | const k = keyboard(); | |
| 72 | + | k.press('g'); k.expireTimers(); k.press('s'); | |
| 73 | + | assert.deepEqual(k.fired, []); | |
| 74 | + | k.press('g'); k.press('s'); | |
| 75 | + | assert.deepEqual(k.fired, ['s']); | |
| 76 | + | }); | |
| @@ -4,17 +4,25 @@ import { readFileSync } from 'node:fs'; | |||||
| 4 | import { test } from 'node:test'; | 4 | import { test } from 'node:test'; | ||
| 5 | import { runInNewContext } from 'node:vm'; | 5 | import { runInNewContext } from 'node:vm'; | ||
| 6 | const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | 6 | const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | ||
| 7 | - | function keyboard({ off = false, typing = false, dialog = false } = {}) { | 7 | + | function keyboard({ owner = true, off = false, typing = false, dialog = false } = {}) { |
| 8 | let handler; | 8 | let handler; | ||
| 9 | const fired = []; | 9 | const fired = []; | ||
| 10 | + | const timers = new Map(); | |||
| 11 | + | let timerID = 0; | |||
| 10 | runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), { | 12 | runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), { | ||
| 11 | addEventListener: (_, fn) => { handler = fn; }, | 13 | addEventListener: (_, fn) => { handler = fn; }, | ||
| 12 | window: { ermiteKeys: { blocks: () => off } }, | 14 | window: { ermiteKeys: { blocks: () => off } }, | ||
| 13 | ermite: { fire: name => fired.push(name) }, | 15 | ermite: { fire: name => fired.push(name) }, | ||
| 14 | - | $: selector => selector === '[data-inbox]' || (dialog && selector === 'dialog[open]'), | 16 | + | $: selector => (owner && selector === '[data-inbox]') || (dialog && selector === 'dialog[open]'), |
| 15 | - | closeMenus: () => false, clearTimeout() {}, setTimeout() {}, | 17 | + | closeMenus: () => false, |
| 18 | + | clearTimeout: id => timers.delete(id), | |||
| 19 | + | setTimeout: fn => { timers.set(++timerID, fn); return timerID; }, | |||
| 16 | }); | 20 | }); | ||
| 17 | - | return { fired, press(key, shiftKey = false) { | 21 | + | return { fired, expireTimers() { |
| 22 | + | const callbacks = [...timers.values()]; | |||
| 23 | + | timers.clear(); | |||
| 24 | + | for (const callback of callbacks) callback(); | |||
| 25 | + | }, press(key, shiftKey = false) { | |||
| 18 | handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} }); | 26 | handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} }); | ||
| 19 | } }; | 27 | } }; | ||
| 20 | } | 28 | } | ||
| @@ -40,3 +48,29 @@ test('uppercase actions respect preference, fields and dialogs', () => { | |||||
| 40 | assert.deepEqual(k.fired, []); | 48 | assert.deepEqual(k.fired, []); | ||
| 41 | } | 49 | } | ||
| 42 | }); | 50 | }); | ||
| 51 | + | ||||
| 52 | + | test('Shift+I opens inbox while issues need the g i sequence', () => { | |||
| 53 | + | const k = keyboard(); | |||
| 54 | + | k.press('I', true); k.press('i'); | |||
| 55 | + | assert.deepEqual(k.fired, ['inbox']); | |||
| 56 | + | k.press('g'); k.press('i'); | |||
| 57 | + | assert.deepEqual(k.fired, ['inbox', 'i']); | |||
| 58 | + | }); | |||
| 59 | + | test('inbox shortcut respects preference, fields, dialogs, and owner target', () => { | |||
| 60 | + | for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) { | |||
| 61 | + | const k = keyboard(options); k.press('I', true); | |||
| 62 | + | assert.deepEqual(k.fired, [], JSON.stringify(options)); | |||
| 63 | + | } | |||
| 64 | + | }); | |||
| 65 | + | test('shifted punctuation still opens help and the command palette', () => { | |||
| 66 | + | const k = keyboard(); | |||
| 67 | + | k.press('?', true); k.press(':', true); | |||
| 68 | + | assert.deepEqual(k.fired, ['help', 'cmd']); | |||
| 69 | + | }); | |||
| 70 | + | test('g prefix expires without scheduling real timers', () => { | |||
| 71 | + | const k = keyboard(); | |||
| 72 | + | k.press('g'); k.expireTimers(); k.press('s'); | |||
| 73 | + | assert.deepEqual(k.fired, []); | |||
| 74 | + | k.press('g'); k.press('s'); | |||
| 75 | + | assert.deepEqual(k.fired, ['s']); | |||
| 76 | + | }); | |||
D internal/adapters/web/jstest/keys.test.mjs +0 -31
| @@ -1,31 +0,0 @@ | |||
| 1 | - | // Exercise the production keydown handler without loading the rest of the page. | |
| 2 | - | import assert from 'node:assert/strict'; | |
| 3 | - | import { readFileSync } from 'node:fs'; | |
| 4 | - | import { test } from 'node:test'; | |
| 5 | - | const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | |
| 6 | - | const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]')); | |
| 7 | - | ||
| 8 | - | function page({ owner = true, off = false, typing = false, dialog = false } = {}) { | |
| 9 | - | let handler; | |
| 10 | - | const fired = []; | |
| 11 | - | const window = { ermiteKeys: { blocks: () => off } }; | |
| 12 | - | const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null; | |
| 13 | - | new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; }); | |
| 14 | - | return (key) => { | |
| 15 | - | const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } }; | |
| 16 | - | handler(e); | |
| 17 | - | return fired; | |
| 18 | - | }; | |
| 19 | - | } | |
| 20 | - | test('Shift+I opens inbox while issues need the g i sequence', () => { | |
| 21 | - | const press = page(); | |
| 22 | - | assert.deepEqual(press('I'), ['inbox']); | |
| 23 | - | assert.deepEqual(press('i'), ['inbox']); | |
| 24 | - | press('g'); | |
| 25 | - | assert.deepEqual(press('i'), ['inbox', 'i']); | |
| 26 | - | }); | |
| 27 | - | test('inbox shortcut respects preference, fields, dialogs, and owner target', () => { | |
| 28 | - | for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) { | |
| 29 | - | assert.deepEqual(page(options)('I'), [], JSON.stringify(options)); | |
| 30 | - | } | |
| 31 | - | }); | |
| @@ -1,31 +0,0 @@ | |||||
| 1 | - | // Exercise the production keydown handler without loading the rest of the page. | |||
| 2 | - | import assert from 'node:assert/strict'; | |||
| 3 | - | import { readFileSync } from 'node:fs'; | |||
| 4 | - | import { test } from 'node:test'; | |||
| 5 | - | const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | |||
| 6 | - | const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]')); | |||
| 7 | - | ||||
| 8 | - | function page({ owner = true, off = false, typing = false, dialog = false } = {}) { | |||
| 9 | - | let handler; | |||
| 10 | - | const fired = []; | |||
| 11 | - | const window = { ermiteKeys: { blocks: () => off } }; | |||
| 12 | - | const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null; | |||
| 13 | - | new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; }); | |||
| 14 | - | return (key) => { | |||
| 15 | - | const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } }; | |||
| 16 | - | handler(e); | |||
| 17 | - | return fired; | |||
| 18 | - | }; | |||
| 19 | - | } | |||
| 20 | - | test('Shift+I opens inbox while issues need the g i sequence', () => { | |||
| 21 | - | const press = page(); | |||
| 22 | - | assert.deepEqual(press('I'), ['inbox']); | |||
| 23 | - | assert.deepEqual(press('i'), ['inbox']); | |||
| 24 | - | press('g'); | |||
| 25 | - | assert.deepEqual(press('i'), ['inbox', 'i']); | |||
| 26 | - | }); | |||
| 27 | - | test('inbox shortcut respects preference, fields, dialogs, and owner target', () => { | |||
| 28 | - | for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) { | |||
| 29 | - | assert.deepEqual(page(options)('I'), [], JSON.stringify(options)); | |||
| 30 | - | } | |||
| 31 | - | }); | |||
M internal/adapters/web/keys_test.go +1 -1
| @@ -17,7 +17,7 @@ func TestSectionShortcutSequences(t *testing.T) { | |||
| 17 | 17 | bindings := renderToString(t, Keys()) | |
| 18 | 18 | for _, section := range sections { | |
| 19 | 19 | assert.Contains(t, header, `data-key="g `+section.Key+`"`) | |
| 20 | - | assert.Contains(t, header, `aria-keyshortcuts="g `+section.Key+`"`) | |
| 20 | + | assert.NotContains(t, header, `aria-keyshortcuts="g `+section.Key+`"`) | |
| 21 | 21 | assert.Contains(t, header, `<kbd aria-hidden="true">g `+section.Key+`</kbd>`) | |
| 22 | 22 | assert.Contains(t, bindings, "ermite.tab('"+section.Key+"')") | |
| 23 | 23 | } | |
| @@ -17,7 +17,7 @@ func TestSectionShortcutSequences(t *testing.T) { | |||||
| 17 | bindings := renderToString(t, Keys()) | 17 | bindings := renderToString(t, Keys()) | ||
| 18 | for _, section := range sections { | 18 | for _, section := range sections { | ||
| 19 | assert.Contains(t, header, `data-key="g `+section.Key+`"`) | 19 | assert.Contains(t, header, `data-key="g `+section.Key+`"`) | ||
| 20 | - | assert.Contains(t, header, `aria-keyshortcuts="g `+section.Key+`"`) | 20 | + | assert.NotContains(t, header, `aria-keyshortcuts="g `+section.Key+`"`) |
| 21 | assert.Contains(t, header, `<kbd aria-hidden="true">g `+section.Key+`</kbd>`) | 21 | assert.Contains(t, header, `<kbd aria-hidden="true">g `+section.Key+`</kbd>`) | ||
| 22 | assert.Contains(t, bindings, "ermite.tab('"+section.Key+"')") | 22 | assert.Contains(t, bindings, "ermite.tab('"+section.Key+"')") | ||
| 23 | } | 23 | } | ||
M internal/adapters/web/releases_test.go +2 -1
| @@ -120,7 +120,8 @@ func TestReleasePages(t *testing.T) { | |||
| 120 | 120 | assert.Contains(t, body, "2026-10-12") | |
| 121 | 121 | assert.Contains(t, body, `<td>1</td>`) | |
| 122 | 122 | assert.Contains(t, body, `?page=next%2B%2F%3D`) | |
| 123 | - | assert.Contains(t, body, `aria-keyshortcuts="g r"`) | |
| 123 | + | assert.Contains(t, body, `data-key="g r"`) | |
| 124 | + | assert.NotContains(t, body, `aria-keyshortcuts="g r"`) | |
| 124 | 125 | assert.Contains(t, body, "view=releases") | |
| 125 | 126 | } | |
| 126 | 127 | owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "") | |
| @@ -120,7 +120,8 @@ func TestReleasePages(t *testing.T) { | |||||
| 120 | assert.Contains(t, body, "2026-10-12") | 120 | assert.Contains(t, body, "2026-10-12") | ||
| 121 | assert.Contains(t, body, `<td>1</td>`) | 121 | assert.Contains(t, body, `<td>1</td>`) | ||
| 122 | assert.Contains(t, body, `?page=next%2B%2F%3D`) | 122 | assert.Contains(t, body, `?page=next%2B%2F%3D`) | ||
| 123 | - | assert.Contains(t, body, `aria-keyshortcuts="g r"`) | 123 | + | assert.Contains(t, body, `data-key="g r"`) |
| 124 | + | assert.NotContains(t, body, `aria-keyshortcuts="g r"`) | |||
| 124 | assert.Contains(t, body, "view=releases") | 125 | assert.Contains(t, body, "view=releases") | ||
| 125 | } | 126 | } | ||
| 126 | owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "") | 127 | owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "") | ||
M internal/adapters/web/shell.templ +0 -1
| @@ -173,7 +173,6 @@ templ Header(l Layout) { | |||
| 173 | 173 | <a | |
| 174 | 174 | href={ templ.SafeURL(s.Href) } | |
| 175 | 175 | data-key={ "g " + s.Key } | |
| 176 | - | aria-keyshortcuts={ "g " + s.Key } | |
| 177 | 176 | if s.Current { | |
| 178 | 177 | aria-current="page" | |
| 179 | 178 | } | |
| @@ -173,7 +173,6 @@ templ Header(l Layout) { | |||||
| 173 | <a | 173 | <a | ||
| 174 | href={ templ.SafeURL(s.Href) } | 174 | href={ templ.SafeURL(s.Href) } | ||
| 175 | data-key={ "g " + s.Key } | 175 | data-key={ "g " + s.Key } | ||
| 176 | - | aria-keyshortcuts={ "g " + s.Key } | |||
| 177 | if s.Current { | 176 | if s.Current { | ||
| 178 | aria-current="page" | 177 | aria-current="page" | ||
| 179 | } | 178 | } | ||