"""Pin ArrowUp recall on the chat composer (static/js/composerArrowUpRecall.js). Driven through `node --input-type=module` so we exercise the real JS without a full Vitest/Jest setup (same approach as test_reply_recipients_js.py). Skips when `node` is not installed rather than failing. Locks in: empty composer recalls user messages from the active conversation, repeated ArrowUp walks older prompts in that same chat; non-empty composer is untouched unless it contains the recalled prompt; multiline caret navigation is not hijacked; Shift/Alt/Ctrl/Meta+ArrowUp are ignored; IME composition does not trigger recall; messages are read from #chat-history (dataset.raw), not session sidebar metadata. """ import json import shutil import subprocess from pathlib import Path import pytest _REPO = Path(__file__).resolve().parent.parent _HELPER = _REPO / "static" / "js" / "composerArrowUpRecall.js" _HELPER_URL = _HELPER.as_uri() _HAS_NODE = shutil.which("node") is not None _HARNESS = r""" import { wireArrowUpRecall } from 'HELPER_PATH'; function makeComposer(initial = '') { const listeners = []; const composer = { value: initial, selectionStart: initial.length, selectionEnd: initial.length, _arrowUpRecallWired: false, addEventListener(type, fn) { if (type === 'keydown') listeners.push(fn); }, dispatchKey(opts = {}) { let prevented = false; let stopped = false; let immediateStopped = false; const e = { key: opts.key ?? 'ArrowUp', shiftKey: !!opts.shiftKey, altKey: !!opts.altKey, ctrlKey: !!opts.ctrlKey, metaKey: !!opts.metaKey, isComposing: !!opts.isComposing, preventDefault() { prevented = true; }, stopPropagation() { stopped = true; }, stopImmediatePropagation() { immediateStopped = true; }, }; for (const fn of listeners) fn(e); return { prevented, stopped, immediateStopped }; }, }; return composer; } function runCase(body) { const composer = makeComposer(body.initial ?? ''); if (body.caret != null) { composer.selectionStart = body.caret; composer.selectionEnd = body.caretEnd ?? body.caret; } const last = body.history ?? body.last ?? 'previous message'; let resized = false; wireArrowUpRecall(composer, () => last, { autoResize: () => { resized = true; }, }); const events = body.events ?? [body.event ?? {}]; const handled = events.map(ev => composer.dispatchKey(ev)); return { value: composer.value, selectionStart: composer.selectionStart, selectionEnd: composer.selectionEnd, prevented: handled.map(v => v.prevented), stopped: handled.map(v => v.stopped), immediateStopped: handled.map(v => v.immediateStopped), resized, }; } const cases = CASES_JSON; const results = cases.map(runCase); console.log(JSON.stringify(results)); """.replace("HELPER_PATH", _HELPER_URL) def _run(cases: list) -> list: js = _HARNESS.replace("CASES_JSON", json.dumps(cases)) proc = subprocess.run( ["node", "--input-type=module"], input=js, capture_output=True, text=True, encoding="utf-8", cwd=str(_REPO), timeout=30, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout.strip()) @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_empty_composer_recalls_last_user_message(): out = _run([{"initial": "", "last": "hello again"}])[0] assert out["value"] == "hello again" assert out["selectionStart"] == len("hello again") assert out["selectionEnd"] == len("hello again") assert out["prevented"] == [True] assert out["stopped"] == [True] assert out["immediateStopped"] == [True] assert out["resized"] is True @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_repeated_arrow_up_cycles_current_chat_prompts_newest_first(): out = _run([{ "initial": "", "history": ["third prompt", "second prompt", "first prompt"], "events": [{}, {}, {}, {}], }])[0] assert out["value"] == "first prompt" assert out["selectionStart"] == len("first prompt") assert out["prevented"] == [True, True, True, True] assert out["stopped"] == [True, True, True, True] assert out["immediateStopped"] == [True, True, True, True] assert out["resized"] is True @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_non_empty_composer_does_not_recall(): out = _run([{"initial": "draft in progress", "last": "ignored"}])[0] assert out["value"] == "draft in progress" assert out["prevented"] == [False] assert out["resized"] is False @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_whitespace_only_composer_is_not_empty(): out = _run([{"initial": " ", "last": "ignored"}])[0] assert out["value"] == " " assert out["prevented"] == [False] @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_multiline_caret_navigation_preserved(): # Caret on line 2 — ArrowUp must not recall or preventDefault. text = "line one\nline two" out = _run([{"initial": text, "caret": len(text), "last": "ignored"}])[0] assert out["value"] == text assert out["selectionStart"] == len(text) assert out["prevented"] == [False] @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_modified_arrow_up_ignored(): cases = [ {"initial": "", "event": {"shiftKey": True}}, {"initial": "", "event": {"altKey": True}}, {"initial": "", "event": {"ctrlKey": True}}, {"initial": "", "event": {"metaKey": True}}, ] for out in _run(cases): assert out["value"] == "" assert out["prevented"] == [False] @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_ime_composition_does_not_trigger_recall(): out = _run([{"initial": "", "event": {"isComposing": True}, "last": "ignored"}])[0] assert out["value"] == "" assert out["prevented"] == [False] @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_no_recall_when_last_message_missing(): out = _run([{"initial": "", "last": ""}])[0] assert out["value"] == "" assert out["prevented"] == [False] assert out["resized"] is False @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_wire_is_idempotent(): js = f""" import {{ wireArrowUpRecall }} from '{_HELPER_URL}'; const composer = {{ _arrowUpRecallWired: false, addEventListener() {{}} }}; const ok1 = wireArrowUpRecall(composer, () => 'x'); const ok2 = wireArrowUpRecall(composer, () => 'y'); console.log(JSON.stringify({{ ok1, ok2, wired: composer._arrowUpRecallWired }})); """ proc = subprocess.run( ["node", "--input-type=module"], input=js, capture_output=True, text=True, encoding="utf-8", cwd=str(_REPO), timeout=30, ) assert proc.returncode == 0, proc.stderr assert json.loads(proc.stdout.strip()) == {"ok1": True, "ok2": True, "wired": True} @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_get_last_user_message_from_chat_history(): js = f""" import {{ getLastUserMessageFromChatHistory, getUserMessagesFromChatHistory, }} from '{_HELPER_URL}'; const chatBox = {{ id: 'chat-history', querySelectorAll(sel) {{ if (sel !== '.msg-user') return []; return [ {{ dataset: {{ raw: 'first' }}, querySelector: () => null }}, {{ dataset: {{ raw: 'last raw' }}, querySelector: () => null }}, ]; }}, }}; const doc = {{ getElementById(id) {{ return id === 'chat-history' ? chatBox : null; }}, }}; console.log(JSON.stringify({{ fromChat: getLastUserMessageFromChatHistory(doc), fromBox: getLastUserMessageFromChatHistory(chatBox), allFromChat: getUserMessagesFromChatHistory(doc), empty: getLastUserMessageFromChatHistory({{ getElementById: () => null }}), noUsers: getLastUserMessageFromChatHistory({{ getElementById: () => ({{ querySelectorAll: () => [] }}), }}), }})); """ proc = subprocess.run( ["node", "--input-type=module"], input=js, capture_output=True, text=True, encoding="utf-8", cwd=str(_REPO), timeout=30, ) assert proc.returncode == 0, proc.stderr assert json.loads(proc.stdout.strip()) == { "fromChat": "last raw", "fromBox": "last raw", "allFromChat": ["last raw", "first"], "empty": "", "noUsers": "", } @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_integration_recalls_from_chat_history_dom(): js = f""" import {{ wireArrowUpRecall, getUserMessagesFromChatHistory, }} from '{_HELPER_URL}'; const chatBox = {{ id: 'chat-history', querySelectorAll(sel) {{ if (sel !== '.msg-user') return []; return [{{ dataset: {{ raw: 'stored prompt' }}, querySelector: () => null }}]; }}, }}; const doc = {{ getElementById: (id) => (id === 'chat-history' ? chatBox : null) }}; const listeners = []; const composer = {{ value: '', selectionStart: 0, selectionEnd: 0, _arrowUpRecallWired: false, addEventListener(type, fn) {{ if (type === 'keydown') listeners.push(fn); }}, }}; wireArrowUpRecall(composer, () => getUserMessagesFromChatHistory(doc)); let prevented = false; listeners[0]({{ key: 'ArrowUp', shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, isComposing: false, preventDefault() {{ prevented = true; }}, }}); console.log(JSON.stringify({{ value: composer.value, prevented }})); """ proc = subprocess.run( ["node", "--input-type=module"], input=js, capture_output=True, text=True, encoding="utf-8", cwd=str(_REPO), timeout=30, ) assert proc.returncode == 0, proc.stderr assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}