| … | … | @@ -69,6 +69,7 @@ const view = { |
| 69 | 69 | // Temporarily regroup the board so the selected card's neighbourhood is laid |
| 70 | 70 | // out together: { id, depth }, depth in hops. Null is the ordinary board. |
| 71 | 71 | gather: null, |
| 72 | hovered: null, // the card under the pointer, so a key can act on it | |
| 72 | 73 | linkEls: [], |
| 73 | 74 | cardEls: null, |
| 74 | 75 | laid: null, |
| … | … | @@ -520,8 +521,14 @@ function drawCard (world, card, pos, closure) { |
| 520 | 521 | // Edges are dim until you point at something: at real scale they are the |
| 521 | 522 | // noise, not the signal - 105 crossing curves at the system level, 316 inside |
| 522 | 523 | // one 58-file module. |
| 523 | g.addEventListener('mouseenter', () => { if (!view.selected) focusCard(card) }) | |
| 524 | g.addEventListener('mouseleave', () => { if (!view.selected) unfocus() }) | |
| 524 | g.addEventListener('mouseenter', () => { | |
| 525 | view.hovered = card.id | |
| 526 | if (!view.selected) focusCard(card) | |
| 527 | }) | |
| 528 | g.addEventListener('mouseleave', () => { | |
| 529 | if (view.hovered === card.id) view.hovered = null | |
| 530 | if (!view.selected) unfocus() | |
| 531 | }) | |
| 525 | 532 | g.addEventListener('click', (e) => { |
| 526 | 533 | e.stopPropagation() |
| 527 | 534 | select(view.selected === card.id ? null : card.id) |
| … | … | @@ -779,8 +786,22 @@ function wire () { |
| 779 | 786 | // while the hint bar went on promising them. |
| 780 | 787 | if (!view.tree && !view.model) return |
| 781 | 788 | |
| 789 | // This view has a path input. Without this guard, typing a path containing g, | |
| 790 | // b, r, e or c fired every one of these shortcuts mid-word. | |
| 791 | const t = e.target | |
| 792 | if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return | |
| 793 | if (e.metaKey || e.ctrlKey || e.altKey) return | |
| 794 | ||
| 782 | 795 | if (e.key === 'g' || e.key === 'G') { |
| 783 | gather(view.gather ? null : view.selected, view.gather ? 0 : 1) | |
| 796 | // Hover counts, not just selection. On this board the obvious click target | |
| 797 | // is a card's TITLE, and that opens the card rather than selecting it — so | |
| 798 | // requiring a selection made G look broken. | |
| 799 | const target = view.gather ? null : (view.selected || view.hovered) | |
| 800 | if (!view.gather && !target) { | |
| 801 | banner('point at a node, or click one, then press G to gather it') | |
| 802 | return | |
| 803 | } | |
| 804 | gather(target, view.gather ? 0 : 1) | |
| 784 | 805 | } else if (e.key === ']' || e.key === '}') { |
| 785 | 806 | if (view.gather) gather(view.gather.id, view.gather.depth + 1) |
| 786 | 807 | } else if (e.key === '[' || e.key === '{') { |
| … | … | @@ -128,6 +128,8 @@ var NV = { |
| 128 | 128 | // { id, depth } in hops, null for the ordinary board. |
| 129 | 129 | gather: null, |
| 130 | 130 | gathered: null, |
| 131 | hover: null, // the node under the pointer, so a key can act on it | |
| 132 | note: null, // a one-line message in the legend | |
| 131 | 133 | // A LIST of cached routings, not one slot: gathering and releasing swaps |
| 132 | 134 | // between two geometries over and over, and a single slot makes every swap a |
| 133 | 135 | // fresh A* pass over the whole board. |
| … | … | @@ -854,8 +856,14 @@ function nvDrawNode (parent, n) { |
| 854 | 856 | |
| 855 | 857 | // Hover traces this node's own wires; click keeps its whole dependency depth |
| 856 | 858 | // on screen until you click it again or hit Escape. |
| 857 | g.addEventListener('mouseenter', function () { if (!NV.selected) nvFocus(n) }) | |
| 858 | g.addEventListener('mouseleave', function () { if (!NV.selected) nvUnfocus() }) | |
| 859 | g.addEventListener('mouseenter', function () { | |
| 860 | NV.hover = n.id | |
| 861 | if (!NV.selected) nvFocus(n) | |
| 862 | }) | |
| 863 | g.addEventListener('mouseleave', function () { | |
| 864 | if (NV.hover === n.id) NV.hover = null | |
| 865 | if (!NV.selected) nvUnfocus() | |
| 866 | }) | |
| 859 | 867 | g.addEventListener('click', function (e) { |
| 860 | 868 | e.stopPropagation() |
| 861 | 869 | NV.selected = NV.selected === n.id ? null : n.id |
| … | … | @@ -1048,11 +1056,13 @@ function nvLegend () { |
| 1048 | 1056 | '<span style="color:#f85149">○</span> unresolved' + |
| 1049 | 1057 | '<br>left sockets demand · right sockets supply' + |
| 1050 | 1058 | '<br>hover traces a node · click for its full dependency depth' + |
| 1051 | '<br><b>G</b> gathers the selection · <b>[ ]</b> its depth' + | |
| 1059 | '<br><b>G</b> gathers what you point at · <b>[ ]</b> its depth · ' + | |
| 1060 | '<b>F</b> frames · <b>R</b> routing · <b>O</b> only connected · <b>Esc</b>' + | |
| 1052 | 1061 | (NV.gather && NV.gathered |
| 1053 | 1062 | ? '<br><span style="color:#ffa657">gathered ' + (NV.gathered.size - 1) + |
| 1054 | 1063 | ' within ' + NV.gather.depth + (NV.gather.depth === 1 ? ' hop' : ' hops') + '</span>' |
| 1055 | 1064 | : '') + |
| 1065 | (NV.note ? '<br><span style="color:#f0883e">' + escapeHtml(NV.note) + '</span>' : '') + | |
| 1056 | 1066 | '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' + |
| 1057 | 1067 | '<input type="checkbox" id="nv-only-connected"' + (NV.onlyConnected ? ' checked' : '') + |
| 1058 | 1068 | ' style="vertical-align:-1px;accent-color:#58a6ff"> collapse to only connected</label>' + |
| … | … | @@ -1191,12 +1201,38 @@ function nvInit () { |
| 1191 | 1201 | }) |
| 1192 | 1202 | |
| 1193 | 1203 | document.addEventListener('keydown', function (e) { |
| 1204 | // The page has a search box and a path field. Without this guard every one of | |
| 1205 | // these letters fires while you are typing. | |
| 1206 | var t = e.target | |
| 1207 | if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return | |
| 1208 | if (e.metaKey || e.ctrlKey || e.altKey) return | |
| 1209 | // These are the node view's keys; they must not fire while another tab is up. | |
| 1210 | if (typeof currentTab !== 'undefined' && currentTab !== 'nodes') return | |
| 1211 | ||
| 1212 | NV.note = null | |
| 1194 | 1213 | if (e.key === 'g' || e.key === 'G') { |
| 1195 | nvGather(NV.gather ? null : NV.selected, NV.gather ? 0 : 1) | |
| 1214 | // Hover counts, not just selection. A node's HEAD and TITLE open it — that | |
| 1215 | // is the obvious thing to click — so only the body selects, and requiring a | |
| 1216 | // selection made G look broken. | |
| 1217 | var target = NV.gather ? null : (NV.selected || NV.hover) | |
| 1218 | if (!NV.gather && !target) { | |
| 1219 | NV.note = 'point at a node, or click its body, then press G' | |
| 1220 | nvLegend() | |
| 1221 | return | |
| 1222 | } | |
| 1223 | nvGather(target, NV.gather ? 0 : 1) | |
| 1196 | 1224 | } else if (e.key === ']' || e.key === '}') { |
| 1197 | 1225 | if (NV.gather) nvGather(NV.gather.id, NV.gather.depth + 1) |
| 1198 | 1226 | } else if (e.key === '[' || e.key === '{') { |
| 1199 | 1227 | if (NV.gather) nvGather(NV.gather.id, NV.gather.depth - 1) |
| 1228 | } else if (e.key === 'f' || e.key === 'F') { | |
| 1229 | NV.showFrames = !NV.showFrames | |
| 1230 | nvRender() | |
| 1231 | } else if (e.key === 'r' || e.key === 'R') { | |
| 1232 | NV.circuit = !NV.circuit | |
| 1233 | nvRender() | |
| 1234 | } else if (e.key === 'o' || e.key === 'O') { | |
| 1235 | window.nvToggleConnected(!NV.onlyConnected) | |
| 1200 | 1236 | } else if (e.key === 'Escape') { |
| 1201 | 1237 | if (NV.gather) { nvGather(null, 0); return } |
| 1202 | 1238 | if (NV.selected) { NV.selected = null; nvRender() } |
| … | … | @@ -0,0 +1,33 @@ |
| 1 | # Manual probes | |
| 2 | ||
| 3 | These need a browser and a running server, so they are not part of `npm test`. | |
| 4 | ||
| 5 | ## keyprobe-nodes.mjs / keyprobe-floor.mjs | |
| 6 | ||
| 7 | The only instrument here that sees a REAL key event. Everything else in the suite | |
| 8 | tests the pure modules, and the keyboard handling lives in emitted browser code | |
| 9 | where a handler can look perfectly correct and still never fire — which is | |
| 10 | exactly what happened: `G` did nothing on the Nodes tab because the obvious click | |
| 11 | target (a node's title) opens the node instead of selecting it, so the handler | |
| 12 | ran with nothing to act on and returned in silence. | |
| 13 | ||
| 14 | They drive Chromium over the DevTools protocol: launch headless with | |
| 15 | `--remote-debugging-port`, dispatch `Input.dispatchMouseEvent` to hover and | |
| 16 | `Input.dispatchKeyEvent` to type, then read the result back out of the DOM. | |
| 17 | ||
| 18 | ```bash | |
| 19 | node index.js serve .. 9078 --depth 1 --no-watch & | |
| 20 | node test/manual/keyprobe-nodes.mjs "http://localhost:9078/?tab=nodes" | |
| 21 | node test/manual/keyprobe-floor.mjs "http://localhost:9078/floor" | |
| 22 | ``` | |
| 23 | ||
| 24 | Two things to know before believing a run: | |
| 25 | ||
| 26 | - **Aim at a node that is actually on screen.** Most of a real board is scrolled | |
| 27 | off, and `elementFromPoint` on empty space reports nothing rather than failing. | |
| 28 | Both probes pick the first node whose centre really hits its own body; an | |
| 29 | earlier version aimed at the first node in the DOM and "reproduced" a bug that | |
| 30 | was my probe missing the target. | |
| 31 | - **`view` is module-scoped in level.js**, so `Runtime.evaluate` cannot read it. | |
| 32 | The floor probe asserts on the DOM — the gather box, the stats line, the URL — | |
| 33 | which is what a user sees anyway. |
| … | … | @@ -0,0 +1,118 @@ |
| 1 | // Drive the real page with real key events over the DevTools protocol. | |
| 2 | // Asserting that a handler LOOKS right is not evidence that a key works. | |
| 3 | import { spawn } from 'child_process' | |
| 4 | ||
| 5 | const URL_ = process.argv[2] | |
| 6 | const PORT = 9335 | |
| 7 | const dir = '/tmp/claude-1000/cdp-profile-' + PORT | |
| 8 | ||
| 9 | const chrome = spawn('chromium', [ | |
| 10 | '--headless=new', '--no-sandbox', '--disable-gpu', '--window-size=1600,1000', | |
| 11 | '--remote-debugging-port=' + PORT, '--user-data-dir=' + dir, URL_ | |
| 12 | ], { stdio: 'ignore' }) | |
| 13 | ||
| 14 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)) | |
| 15 | ||
| 16 | async function target () { | |
| 17 | for (let i = 0; i < 60; i++) { | |
| 18 | try { | |
| 19 | const res = await fetch('http://127.0.0.1:' + PORT + '/json/list') | |
| 20 | const list = await res.json() | |
| 21 | const page = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl) | |
| 22 | if (page) return page.webSocketDebuggerUrl | |
| 23 | } catch {} | |
| 24 | await new Promise(r => setTimeout(r, 250)) | |
| 25 | } | |
| 26 | throw new Error('no devtools target') | |
| 27 | } | |
| 28 | ||
| 29 | const ws = new WebSocket(await target()) | |
| 30 | await new Promise(r => { ws.onopen = r }) | |
| 31 | let id = 0 | |
| 32 | const pending = new Map() | |
| 33 | ws.onmessage = (m) => { | |
| 34 | const msg = JSON.parse(m.data) | |
| 35 | if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id) } | |
| 36 | } | |
| 37 | function send (method, params = {}) { | |
| 38 | const myId = ++id | |
| 39 | ws.send(JSON.stringify({ id: myId, method, params })) | |
| 40 | return new Promise(r => pending.set(myId, r)) | |
| 41 | } | |
| 42 | async function evaluate (expr) { | |
| 43 | const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) | |
| 44 | if (r.result && r.result.exceptionDetails) return { error: r.result.exceptionDetails.text } | |
| 45 | return r.result && r.result.result ? r.result.result.value : undefined | |
| 46 | } | |
| 47 | const VK = { g: 71, f: 70, r: 82, o: 79, '[': 219, ']': 221 } | |
| 48 | async function key (k) { | |
| 49 | const code = k === '[' ? 'BracketLeft' : k === ']' ? 'BracketRight' : 'Key' + k.toUpperCase() | |
| 50 | const base = { key: k, code, windowsVirtualKeyCode: VK[k], nativeVirtualKeyCode: VK[k] } | |
| 51 | await send('Input.dispatchKeyEvent', { type: 'keyDown', text: k, ...base }) | |
| 52 | await send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }) | |
| 53 | await new Promise(r => setTimeout(r, 450)) | |
| 54 | } | |
| 55 | async function moveTo (x, y) { | |
| 56 | await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, buttons: 0 }) | |
| 57 | await new Promise(r => setTimeout(r, 200)) | |
| 58 | } | |
| 59 | ||
| 60 | await send('Runtime.enable') | |
| 61 | await new Promise(r => setTimeout(r, 5000)) | |
| 62 | ||
| 63 | const out = [] | |
| 64 | const say = (label, value) => { out.push(label + ': ' + JSON.stringify(value)); console.log(label + ': ' + JSON.stringify(value)) } | |
| 65 | ||
| 66 | say('level', await evaluate('document.getElementById("levelname").textContent')) | |
| 67 | say('cards', await evaluate('view.cards.length')) | |
| 68 | say('containers drawn', await evaluate('document.querySelectorAll(".zone").length')) | |
| 69 | ||
| 70 | const rect = await evaluate(`(() => { | |
| 71 | for (const g of document.querySelectorAll('#world > g')) { | |
| 72 | const r = g.getBoundingClientRect(); | |
| 73 | const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2); | |
| 74 | const el = document.elementFromPoint(x, y); | |
| 75 | if (el && String(el.getAttribute('class') || '').indexOf('n-') === 0) return { x, y }; | |
| 76 | } | |
| 77 | return null; | |
| 78 | })()`) | |
| 79 | say('a card point', rect) | |
| 80 | if (rect) await moveTo(rect.x, rect.y) | |
| 81 | ||
| 82 | // level.js is an ES module, so `view` is not reachable from here. Everything | |
| 83 | // below is read off the DOM, which is what the user sees anyway. | |
| 84 | const stats = () => evaluate('document.getElementById("stats").textContent') | |
| 85 | const curves = () => evaluate( | |
| 86 | '[...document.querySelectorAll("path.link")].filter(p => p.getAttribute("d").indexOf(" C ") >= 0).length') | |
| 87 | ||
| 88 | await key('g') | |
| 89 | say('after G -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length')) | |
| 90 | say('after G -> url', await evaluate('location.search')) | |
| 91 | say('after G -> stats', await stats()) | |
| 92 | await key(']') | |
| 93 | say('after ] -> url', await evaluate('location.search')) | |
| 94 | say('after ] -> stats', await stats()) | |
| 95 | await key('[') | |
| 96 | await key('[') | |
| 97 | say('after [[ -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length')) | |
| 98 | say('after [[ -> url', await evaluate('location.search')) | |
| 99 | ||
| 100 | // B and R were dead on this rung: the handler began `if (!view.model) return` | |
| 101 | // and view.model is null at system level. | |
| 102 | await key('b') | |
| 103 | say('after B -> stats mentions bundled', await stats()) | |
| 104 | await key('b') | |
| 105 | say('curved links before R', await curves()) | |
| 106 | await key('r') | |
| 107 | say('curved links after R', await curves()) | |
| 108 | await key('r') | |
| 109 | ||
| 110 | await evaluate('document.getElementById("path").focus()') | |
| 111 | say('focused', await evaluate('document.activeElement.id')) | |
| 112 | await key('g') | |
| 113 | say('G while typing -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length')) | |
| 114 | ||
| 115 | console.log('---') | |
| 116 | ws.close() | |
| 117 | chrome.kill('SIGKILL') | |
| 118 | process.exit(0) |
| … | … | @@ -0,0 +1,115 @@ |
| 1 | // Drive the real page with real key events over the DevTools protocol. | |
| 2 | // Asserting that a handler LOOKS right is not evidence that a key works. | |
| 3 | import { spawn } from 'child_process' | |
| 4 | ||
| 5 | const URL_ = process.argv[2] | |
| 6 | const PORT = 9333 | |
| 7 | const dir = '/tmp/claude-1000/cdp-profile-' + PORT | |
| 8 | ||
| 9 | const chrome = spawn('chromium', [ | |
| 10 | '--headless=new', '--no-sandbox', '--disable-gpu', '--window-size=1600,1000', | |
| 11 | '--remote-debugging-port=' + PORT, '--user-data-dir=' + dir, URL_ | |
| 12 | ], { stdio: 'ignore' }) | |
| 13 | ||
| 14 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)) | |
| 15 | ||
| 16 | async function target () { | |
| 17 | for (let i = 0; i < 60; i++) { | |
| 18 | try { | |
| 19 | const res = await fetch('http://127.0.0.1:' + PORT + '/json/list') | |
| 20 | const list = await res.json() | |
| 21 | const page = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl) | |
| 22 | if (page) return page.webSocketDebuggerUrl | |
| 23 | } catch {} | |
| 24 | await new Promise(r => setTimeout(r, 250)) | |
| 25 | } | |
| 26 | throw new Error('no devtools target') | |
| 27 | } | |
| 28 | ||
| 29 | const ws = new WebSocket(await target()) | |
| 30 | await new Promise(r => { ws.onopen = r }) | |
| 31 | let id = 0 | |
| 32 | const pending = new Map() | |
| 33 | ws.onmessage = (m) => { | |
| 34 | const msg = JSON.parse(m.data) | |
| 35 | if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id) } | |
| 36 | } | |
| 37 | function send (method, params = {}) { | |
| 38 | const myId = ++id | |
| 39 | ws.send(JSON.stringify({ id: myId, method, params })) | |
| 40 | return new Promise(r => pending.set(myId, r)) | |
| 41 | } | |
| 42 | async function evaluate (expr) { | |
| 43 | const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) | |
| 44 | if (r.result && r.result.exceptionDetails) return { error: r.result.exceptionDetails.text } | |
| 45 | return r.result && r.result.result ? r.result.result.value : undefined | |
| 46 | } | |
| 47 | const VK = { g: 71, f: 70, r: 82, o: 79, '[': 219, ']': 221 } | |
| 48 | async function key (k) { | |
| 49 | const code = k === '[' ? 'BracketLeft' : k === ']' ? 'BracketRight' : 'Key' + k.toUpperCase() | |
| 50 | const base = { key: k, code, windowsVirtualKeyCode: VK[k], nativeVirtualKeyCode: VK[k] } | |
| 51 | await send('Input.dispatchKeyEvent', { type: 'keyDown', text: k, ...base }) | |
| 52 | await send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }) | |
| 53 | await new Promise(r => setTimeout(r, 450)) | |
| 54 | } | |
| 55 | async function moveTo (x, y) { | |
| 56 | await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, buttons: 0 }) | |
| 57 | await new Promise(r => setTimeout(r, 200)) | |
| 58 | } | |
| 59 | ||
| 60 | await send('Runtime.enable') | |
| 61 | await new Promise(r => setTimeout(r, 5000)) | |
| 62 | ||
| 63 | const out = [] | |
| 64 | const say = (label, value) => { out.push(label + ': ' + JSON.stringify(value)); console.log(label + ': ' + JSON.stringify(value)) } | |
| 65 | ||
| 66 | say('tab', await evaluate('typeof currentTab !== "undefined" ? currentTab : "n/a"')) | |
| 67 | say('nodes drawn', await evaluate('document.querySelectorAll(".nv-node").length')) | |
| 68 | ||
| 69 | // hover the first node's BODY (not its head, which opens it) | |
| 70 | // The first node whose CENTRE actually hits its own body: most of the board is | |
| 71 | // scrolled off-screen, and pointing at empty space proves nothing. | |
| 72 | const rect = await evaluate(`(() => { | |
| 73 | for (const n of document.querySelectorAll('.nv-node')) { | |
| 74 | const r = n.getBoundingClientRect(); | |
| 75 | const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2); | |
| 76 | const el = document.elementFromPoint(x, y); | |
| 77 | if (el && el.getAttribute('class') === 'nv-node-body') return { x, y }; | |
| 78 | } | |
| 79 | return null; | |
| 80 | })()`) | |
| 81 | say('first node body point', rect) | |
| 82 | if (rect) await moveTo(rect.x, rect.y) | |
| 83 | say('NV.hover after move', await evaluate('NV.hover')) | |
| 84 | ||
| 85 | await key('g') | |
| 86 | say('after G -> gather', await evaluate('NV.gather && NV.gather.depth')) | |
| 87 | say('after G -> gather boxes', await evaluate('document.querySelectorAll(".nv-frame-gather").length')) | |
| 88 | say('after G -> gathered count', await evaluate('NV.gathered ? NV.gathered.size : null')) | |
| 89 | ||
| 90 | await key(']') | |
| 91 | say('after ] -> depth', await evaluate('NV.gather && NV.gather.depth')) | |
| 92 | say('after ] -> gathered count', await evaluate('NV.gathered ? NV.gathered.size : null')) | |
| 93 | ||
| 94 | await key('[') | |
| 95 | say('after [ -> depth', await evaluate('NV.gather && NV.gather.depth')) | |
| 96 | await key('[') | |
| 97 | say('after [[ -> gather released', await evaluate('NV.gather === null')) | |
| 98 | ||
| 99 | await key('f') | |
| 100 | say('after F -> showFrames', await evaluate('NV.showFrames')) | |
| 101 | await key('f') | |
| 102 | await key('r') | |
| 103 | say('after R -> circuit', await evaluate('NV.circuit')) | |
| 104 | await key('r') | |
| 105 | ||
| 106 | // the typing guard | |
| 107 | await evaluate(`(() => { const i = document.querySelector('input[type=text], #search, input'); if (i) { i.focus(); return i.id || i.type } return 'none' })()`) | |
| 108 | say('focused input', await evaluate('document.activeElement.tagName')) | |
| 109 | await key('g') | |
| 110 | say('G while typing -> gather stays null', await evaluate('NV.gather === null')) | |
| 111 | ||
| 112 | console.log('---') | |
| 113 | ws.close() | |
| 114 | chrome.kill('SIGKILL') | |
| 115 | process.exit(0) |
| … | … | @@ -88,3 +88,19 @@ test('render: embedded tree JSON cannot break out of the script tag', async (t) |
| 88 | 88 | t.fail('data containing </script> truncated the script: ' + err.message) |
| 89 | 89 | } |
| 90 | 90 | }) |
| 91 | ||
| 92 | test('render: the node view keys do not fire while you are typing', (t) => { | |
| 93 | // The page has a search box and a path field. Without a guard on the event | |
| 94 | // target, every single-letter shortcut fires mid-word — typing "graph" into | |
| 95 | // search would gather, toggle frames and toggle routing on the way past. | |
| 96 | const src = readFileSync(resolve(HERE, '../lib/render-nodes.js'), 'utf-8') | |
| 97 | const handler = src.slice(src.indexOf("addEventListener('keydown'")) | |
| 98 | const body = handler.slice(0, handler.indexOf('\n })')) | |
| 99 | ||
| 100 | t.ok(body.includes('tagName'), 'the handler inspects the event target') | |
| 101 | t.ok(/INPUT/.test(body) && /TEXTAREA/.test(body), 'and bails out for text fields') | |
| 102 | t.ok(body.includes('isContentEditable'), 'contentEditable too') | |
| 103 | ||
| 104 | // And it must not act on another tab's keystrokes. | |
| 105 | t.ok(body.includes('currentTab'), 'the node view only takes keys while it is up') | |
| 106 | }) |