feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
"""
|
|
|
|
|
Sprint 20 Tests: Voice input (mic button) via Web Speech API.
|
|
|
|
|
|
|
|
|
|
These tests verify the static assets contain the correct HTML structure,
|
|
|
|
|
CSS rules, and JS logic for the mic feature — all of which runs purely in
|
|
|
|
|
the browser with no server-side component.
|
|
|
|
|
"""
|
|
|
|
|
import re
|
|
|
|
|
import urllib.request
|
|
|
|
|
import json
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
import pathlib
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
|
2026-04-14 19:04:48 +00:00
|
|
|
from tests._pytest_port import BASE
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_text(path):
|
|
|
|
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
|
|
|
|
return r.read().decode(), r.status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── index.html ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_button_present_in_html():
|
|
|
|
|
"""index.html must contain the mic button with id='btnMic'."""
|
|
|
|
|
html, status = get_text("/")
|
|
|
|
|
assert status == 200
|
|
|
|
|
assert 'id="btnMic"' in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_button_has_mic_btn_class():
|
|
|
|
|
"""btnMic must carry the mic-btn CSS class for styling hooks."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
assert 'class="icon-btn mic-btn"' in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_button_hidden_by_default():
|
|
|
|
|
"""btnMic starts hidden (display:none) — JS shows it only if supported."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
# The button element should have display:none in its style attribute
|
|
|
|
|
assert 'id="btnMic"' in html
|
|
|
|
|
btn_match = re.search(r'id="btnMic"[^>]*>', html)
|
|
|
|
|
assert btn_match, "btnMic element not found"
|
|
|
|
|
assert 'display:none' in btn_match.group(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_button_has_title():
|
|
|
|
|
"""btnMic should have a descriptive title for accessibility."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
btn_match = re.search(r'id="btnMic"[^>]*>', html)
|
|
|
|
|
assert btn_match
|
|
|
|
|
assert 'title=' in btn_match.group(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_status_div_present():
|
|
|
|
|
"""index.html must contain the #micStatus listening indicator."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
assert 'id="micStatus"' in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_status_hidden_by_default():
|
|
|
|
|
"""#micStatus starts hidden — only shown during active recording."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
status_match = re.search(r'id="micStatus"[^>]*>', html)
|
|
|
|
|
assert status_match, "#micStatus element not found"
|
|
|
|
|
assert 'display:none' in status_match.group(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_status_has_mic_dot():
|
|
|
|
|
"""#micStatus must contain a .mic-dot element for the pulse animation."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
# mic-dot should appear after micStatus
|
|
|
|
|
idx_status = html.find('id="micStatus"')
|
|
|
|
|
idx_dot = html.find('mic-dot', idx_status)
|
|
|
|
|
assert idx_status != -1 and idx_dot != -1
|
|
|
|
|
assert idx_dot > idx_status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_status_has_listening_text():
|
|
|
|
|
"""#micStatus should display a 'Listening' label."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
assert 'Listening' in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_button_svg_microphone_shape():
|
|
|
|
|
"""btnMic SVG must include the rect (mic body) and path (mic arc)."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
# Find mic button section
|
|
|
|
|
btn_start = html.find('id="btnMic"')
|
|
|
|
|
btn_end = html.find('</button>', btn_start) + len('</button>')
|
|
|
|
|
btn_html = html[btn_start:btn_end]
|
|
|
|
|
assert '<rect' in btn_html, "mic SVG missing rect (mic body)"
|
|
|
|
|
assert '<path' in btn_html, "mic SVG missing path (arc)"
|
|
|
|
|
assert '<line' in btn_html, "mic SVG missing line (stand)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_button_inside_composer_left():
|
|
|
|
|
"""btnMic must be inside .composer-left, next to the attach button."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
composer_left_start = html.find('class="composer-left"')
|
|
|
|
|
composer_left_end = html.find('</div>', composer_left_start)
|
|
|
|
|
section = html[composer_left_start:composer_left_end]
|
|
|
|
|
assert 'btnAttach' in section
|
|
|
|
|
assert 'btnMic' in section
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── style.css ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_btn_css_rule_exists():
|
|
|
|
|
"""style.css must define .mic-btn rule."""
|
|
|
|
|
css, status = get_text("/static/style.css")
|
|
|
|
|
assert status == 200
|
|
|
|
|
assert '.mic-btn' in css
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_btn_recording_state_css():
|
|
|
|
|
""".mic-btn.recording must be defined for active recording visual state."""
|
|
|
|
|
css, _ = get_text("/static/style.css")
|
|
|
|
|
assert '.mic-btn.recording' in css
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_recording_color_red():
|
|
|
|
|
""".mic-btn.recording must use the red accent color #e94560."""
|
|
|
|
|
css, _ = get_text("/static/style.css")
|
|
|
|
|
recording_idx = css.find('.mic-btn.recording')
|
|
|
|
|
# Find the rule block after the selector
|
|
|
|
|
brace_open = css.find('{', recording_idx)
|
|
|
|
|
brace_close = css.find('}', brace_open)
|
|
|
|
|
rule = css[brace_open:brace_close]
|
|
|
|
|
assert '#e94560' in rule or 'e94560' in rule
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_recording_has_animation():
|
|
|
|
|
""".mic-btn.recording must use an animation for the pulse effect."""
|
|
|
|
|
css, _ = get_text("/static/style.css")
|
|
|
|
|
recording_idx = css.find('.mic-btn.recording')
|
|
|
|
|
brace_open = css.find('{', recording_idx)
|
|
|
|
|
brace_close = css.find('}', brace_open)
|
|
|
|
|
rule = css[brace_open:brace_close]
|
|
|
|
|
assert 'animation' in rule
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_pulse_keyframes_defined():
|
|
|
|
|
"""@keyframes mic-pulse must be defined for the pulsing animation."""
|
|
|
|
|
css, _ = get_text("/static/style.css")
|
|
|
|
|
assert 'mic-pulse' in css
|
|
|
|
|
assert '@keyframes' in css
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_status_css_rule_exists():
|
|
|
|
|
"""style.css must define .mic-status rule."""
|
|
|
|
|
css, _ = get_text("/static/style.css")
|
|
|
|
|
assert '.mic-status' in css
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_dot_css_rule_exists():
|
|
|
|
|
"""style.css must define .mic-dot rule with animation."""
|
|
|
|
|
css, _ = get_text("/static/style.css")
|
|
|
|
|
assert '.mic-dot' in css
|
|
|
|
|
dot_idx = css.find('.mic-dot')
|
|
|
|
|
brace_open = css.find('{', dot_idx)
|
|
|
|
|
brace_close = css.find('}', brace_open)
|
|
|
|
|
rule = css[brace_open:brace_close]
|
|
|
|
|
assert 'animation' in rule
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mic_btn_has_transition():
|
|
|
|
|
""".mic-btn must define a transition for smooth state changes."""
|
|
|
|
|
css, _ = get_text("/static/style.css")
|
|
|
|
|
mic_btn_idx = css.find('.mic-btn{')
|
|
|
|
|
if mic_btn_idx == -1:
|
|
|
|
|
mic_btn_idx = css.find('.mic-btn ')
|
|
|
|
|
brace_open = css.find('{', mic_btn_idx)
|
|
|
|
|
brace_close = css.find('}', brace_open)
|
|
|
|
|
rule = css[brace_open:brace_close]
|
|
|
|
|
assert 'transition' in rule
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── boot.js ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_serves_ok():
|
|
|
|
|
"""boot.js must be served successfully."""
|
|
|
|
|
_, status = get_text("/static/boot.js")
|
|
|
|
|
assert status == 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_speech_recognition_check():
|
|
|
|
|
"""boot.js must check for SpeechRecognition (with webkit fallback)."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'SpeechRecognition' in js
|
|
|
|
|
assert 'webkitSpeechRecognition' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_recognition_config():
|
|
|
|
|
"""boot.js must configure recognition.continuous, interimResults, and lang."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'recognition.continuous' in js
|
|
|
|
|
assert 'recognition.interimResults' in js
|
|
|
|
|
assert 'recognition.lang' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_recognition_not_continuous():
|
|
|
|
|
"""recognition.continuous must be false (auto-stop after silence)."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'recognition.continuous=false' in js or 'recognition.continuous = false' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_recognition_interim_results():
|
|
|
|
|
"""recognition.interimResults must be true (live transcription preview)."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'recognition.interimResults=true' in js or 'recognition.interimResults = true' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_recognition_lang_en():
|
2026-04-08 14:55:03 +00:00
|
|
|
"""recognition.lang must be set (static en-US or dynamic via _locale._speech)."""
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
js, _ = get_text("/static/boot.js")
|
2026-04-08 14:55:03 +00:00
|
|
|
# Accept either the old static value or the new locale-driven assignment
|
|
|
|
|
assert (
|
|
|
|
|
"recognition.lang='en-US'" in js
|
|
|
|
|
or 'recognition.lang = "en-US"' in js
|
|
|
|
|
or "recognition.lang=" in js # dynamic: recognition.lang=(_locale._speech)||'en-US'
|
|
|
|
|
)
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_onresult_handler():
|
|
|
|
|
"""boot.js must define recognition.onresult to handle transcription."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'recognition.onresult' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_onend_handler():
|
|
|
|
|
"""boot.js must define recognition.onend to reset state when recording stops."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'recognition.onend' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_onerror_handler():
|
|
|
|
|
"""boot.js must define recognition.onerror for graceful error handling."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'recognition.onerror' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_not_allowed_error_message():
|
|
|
|
|
"""onerror must handle 'not-allowed' with a user-friendly message."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'not-allowed' in js
|
|
|
|
|
assert 'permission' in js.lower() or 'denied' in js.lower() or 'access' in js.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_no_speech_error_message():
|
|
|
|
|
"""onerror must handle 'no-speech' with a user-friendly message."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'no-speech' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_network_error_message():
|
|
|
|
|
"""onerror must handle 'network' error."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert "'network'" in js or '"network"' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_mic_active_flag():
|
|
|
|
|
"""boot.js must track recording state via _micActive flag."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert '_micActive' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_mic_recording_class_toggle():
|
|
|
|
|
"""boot.js must toggle 'recording' CSS class on the mic button."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert "'recording'" in js or '"recording"' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_mic_status_toggle():
|
|
|
|
|
"""boot.js must show/hide #micStatus during recording."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'micStatus' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_send_stops_mic():
|
|
|
|
|
"""btnSend onclick must stop mic before sending (send guard)."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
# The send button onclick should check _micActive and stop recording
|
|
|
|
|
send_onclick_idx = js.find("$('btnSend').onclick")
|
|
|
|
|
assert send_onclick_idx != -1
|
|
|
|
|
# Find the handler code — check that _micActive check appears near send assignment
|
|
|
|
|
handler_end = js.find(';', send_onclick_idx)
|
|
|
|
|
handler = js[send_onclick_idx:handler_end + 1]
|
|
|
|
|
assert '_micActive' in handler or 'stopMic' in handler.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_btn_mic_onclick():
|
|
|
|
|
"""boot.js must attach an onclick handler to btnMic."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'btn.onclick' in js or "btnMic.onclick" in js or "$('btnMic').onclick" in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_recognition_start():
|
|
|
|
|
"""boot.js must call recognition.start() to begin recording."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'recognition.start()' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_recognition_stop():
|
|
|
|
|
"""boot.js must call recognition.stop() to end recording."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'recognition.stop()' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_iife_guard():
|
|
|
|
|
"""Mic logic must be wrapped in an IIFE so it doesn't pollute global scope."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
# IIFE pattern: (function(){...})() or (() => {...})()
|
|
|
|
|
assert '(function(){' in js or '(function () {' in js
|
|
|
|
|
|
|
|
|
|
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
def test_boot_js_browser_unsupported_guard_uses_fallback_capabilities():
|
|
|
|
|
"""boot.js must keep the mic available when either speech recognition OR recorder capture exists."""
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
js, _ = get_text("/static/boot.js")
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
assert 'navigator.mediaDevices' in js
|
|
|
|
|
assert 'getUserMedia' in js
|
|
|
|
|
assert 'MediaRecorder' in js
|
|
|
|
|
assert '_canRecordAudio' in js or 'canRecordAudio' in js, \
|
|
|
|
|
"boot.js should compute a recorder fallback instead of bailing only on SpeechRecognition"
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
|
|
|
|
|
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
def test_boot_js_media_recorder_fallback_posts_to_transcribe_api():
|
|
|
|
|
"""Desktop fallback must send recorded audio to /api/transcribe for transcription."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert '/api/transcribe' in js
|
|
|
|
|
assert 'fetch(' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_routes_define_transcribe_endpoint():
|
|
|
|
|
"""Server routes must expose /api/transcribe for MediaRecorder fallback uploads."""
|
|
|
|
|
routes = pathlib.Path(__file__).parent.parent.joinpath("api/routes.py").read_text(encoding="utf-8")
|
|
|
|
|
assert '"/api/transcribe"' in routes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_shows_mic_button_when_any_voice_path_is_supported():
|
|
|
|
|
"""boot.js must reveal btnMic when speech recognition or recorder fallback is available."""
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert "btn.style.display=''" in js or 'btn.style.display = ""' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_show_toast_on_error():
|
|
|
|
|
"""boot.js must call showToast() for mic errors."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'showToast' in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_autoresize_called():
|
|
|
|
|
"""boot.js must call autoResize() after updating textarea from transcript."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert 'autoResize()' in js
|
|
|
|
|
|
|
|
|
|
|
fix: mic appends to existing textarea text instead of replacing it
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
2026-04-03 14:13:29 +00:00
|
|
|
# ── Append behaviour (fix: mic appends to existing text, not replace) ────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_prefix_variable_declared():
|
|
|
|
|
"""boot.js must declare _prefix variable to snapshot pre-existing textarea content."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert "_prefix" in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_prefix_captured_on_start():
|
|
|
|
|
"""_prefix must be set from ta.value when the user starts recording."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
# _prefix assignment must happen in the btn.onclick else branch (before recognition.start)
|
|
|
|
|
btn_onclick_idx = js.find("btn.onclick")
|
|
|
|
|
btn_onclick_end = js.find("};", btn_onclick_idx)
|
|
|
|
|
onclick_body = js[btn_onclick_idx:btn_onclick_end]
|
|
|
|
|
assert "_prefix=ta.value" in onclick_body or "_prefix = ta.value" in onclick_body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_onresult_prepends_prefix():
|
|
|
|
|
"""onresult must include _prefix when writing to textarea (append, not replace)."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
onresult_idx = js.find("recognition.onresult")
|
|
|
|
|
onresult_end = js.find("};", onresult_idx)
|
|
|
|
|
onresult_body = js[onresult_idx:onresult_end]
|
|
|
|
|
# ta.value must be set to _prefix + something, not just the transcript alone
|
|
|
|
|
assert "_prefix" in onresult_body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_onend_commits_with_prefix():
|
|
|
|
|
"""onend must commit _prefix + _finalText so appended text survives after recognition ends."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
onend_idx = js.find("recognition.onend")
|
|
|
|
|
onend_end = js.find("};", onend_idx)
|
|
|
|
|
onend_body = js[onend_idx:onend_end]
|
|
|
|
|
assert "_prefix" in onend_body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_prefix_reset_on_stop():
|
|
|
|
|
"""_prefix must be reset when recording stops so next session starts clean."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
# _setRecording(false) clears both _finalText and _prefix
|
|
|
|
|
set_rec_idx = js.find("function _setRecording")
|
|
|
|
|
set_rec_end = js.find("}", set_rec_idx) + 1
|
|
|
|
|
fn_body = js[set_rec_idx:set_rec_end]
|
|
|
|
|
assert "_prefix" in fn_body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_js_auto_space_between_prefix_and_transcript():
|
|
|
|
|
"""onend must insert a space between existing text and new transcript when needed."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
onend_idx = js.find("recognition.onend")
|
|
|
|
|
onend_end = js.find("};", onend_idx)
|
|
|
|
|
onend_body = js[onend_idx:onend_end]
|
|
|
|
|
# Should handle spacing — look for trimStart or endsWith(' ') check
|
|
|
|
|
has_spacing = ("trimStart" in onend_body or "endsWith(' ')" in onend_body
|
|
|
|
|
or "endsWith(\" \")" in onend_body or "endsWith('\\n')" in onend_body)
|
|
|
|
|
assert has_spacing, "onend should handle spacing between prefix and new transcript"
|
|
|
|
|
|
|
|
|
|
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
# ── Regression: existing behaviour unchanged ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_attach_button_still_wired():
|
|
|
|
|
"""btnAttach onclick must still be wired up (no regression)."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert "$('btnAttach').onclick" in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_file_input_onchange_still_wired():
|
|
|
|
|
"""fileInput onchange must still be wired up (no regression)."""
|
|
|
|
|
js, _ = get_text("/static/boot.js")
|
|
|
|
|
assert "$('fileInput').onchange" in js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_index_html_still_has_send_button():
|
|
|
|
|
"""btnSend must still be present in index.html (no regression)."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
assert 'id="btnSend"' in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_index_html_still_has_attach_button():
|
|
|
|
|
"""btnAttach must still be present in index.html (no regression)."""
|
|
|
|
|
html, _ = get_text("/")
|
|
|
|
|
assert 'id="btnAttach"' in html
|