import json import subprocess from pathlib import Path FRONTEND = Path(__file__).parents[1] / "frontend" VOICE_CAPTURE = FRONTEND / "voice-conversation-capture.js" VOICE_STORE = FRONTEND / "voice-transcript-store.js" def run_node(script: str): completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert completed.returncode == 0, completed.stderr return json.loads(completed.stdout) def test_dictated_reply_waits_for_review_then_appends_to_the_current_conversation(): script = f""" const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))}); class Element {{ constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; this.events=[]; }} addEventListener(name, callback) {{ this.listeners[name]=callback; }} click() {{ this.listeners.click?.(); }} dispatchEvent(event) {{ this.events.push(event.type); }} }} let recognition; class Recognition {{ constructor() {{ recognition=this; this.aborts=0; }} start() {{}} stop() {{}} abort() {{ this.aborts += 1; }} }} const elements=Object.fromEntries( ['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()]) ); elements.review.hidden=true; elements.draft.value='Typed opening'; const saved=[]; const cleared=[]; const store={{ async load(login, target) {{ return ''; }}, async save(login, transcript, target) {{ saved.push({{login, transcript, target}}); }}, async clear(login, target) {{ cleared.push({{login, target}}); }}, }}; (async()=>{{ const controller=createVoiceConversationCapture({{ Recognition,elements,transcriptStore:store,getLogin:()=> 'timmy',createEvent:name=>({{type:name}}) }}); await controller.open('issue:stackchain/stackchain-dashboard#951'); elements.start.click(); const first=Object.assign([{{transcript:'Please test on mobile.'}}],{{isFinal:true}}); recognition.onresult({{resultIndex:0,results:[first]}}); const before={{draft:elements.draft.value,reviewHidden:elements.review.hidden,transcript:elements.transcript.value}}; elements.append.click(); await Promise.resolve(); process.stdout.write(JSON.stringify({{ before,draft:elements.draft.value,events:elements.draft.events,saved,cleared, reviewHidden:elements.review.hidden,status:elements.status.textContent }})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ assert run_node(script) == { "before": { "draft": "Typed opening", "reviewHidden": False, "transcript": "Please test on mobile.", }, "draft": "Typed opening\n\nPlease test on mobile.", "events": ["input"], "saved": [ { "login": "timmy", "transcript": "Please test on mobile.", "target": "issue:stackchain/stackchain-dashboard#951", } ], "cleared": [ {"login": "timmy", "target": "issue:stackchain/stackchain-dashboard#951"} ], "reviewHidden": True, "status": "Transcript added. Review the reply before sending.", } def test_today_progress_dictation_uses_its_composer_boundary_and_copy(): script = f""" const {{mountVoiceConversation}} = require({json.dumps(str(VOICE_CAPTURE))}); const requested=[]; const nodes=new Map(); const qs=selector=>{{requested.push(selector);if(!nodes.has(selector))nodes.set(selector,{{selector}});return nodes.get(selector);}}; const created=[]; const result=mountVoiceConversation({{ kind:'today-progress',draftSelector:'#today-progress-body',qs,Recognition:function Recognition(){{}}, transcriptStore:{{name:'store'}},getLogin:()=> 'timmy', createCapture:options=>{{created.push(options);return {{supported:true}};}}, }}); process.stdout.write(JSON.stringify({{result,requested,elements:Object.fromEntries(Object.entries(created[0].elements).map(([key,value])=>[key,value.selector]))}})); """ assert run_node(script) == { "result": {"supported": True}, "requested": [ "#voice-today-progress", "#start-voice-today-progress", "#stop-voice-today-progress", "#voice-today-progress-review", "#voice-today-progress-transcript", "#append-voice-today-progress", "#replace-with-voice-today-progress", "#discard-voice-today-progress", "#voice-today-progress-status", "#today-progress-body", ], "elements": { "root": "#voice-today-progress", "start": "#start-voice-today-progress", "stop": "#stop-voice-today-progress", "review": "#voice-today-progress-review", "transcript": "#voice-today-progress-transcript", "append": "#append-voice-today-progress", "replace": "#replace-with-voice-today-progress", "discard": "#discard-voice-today-progress", "status": "#voice-today-progress-status", "draft": "#today-progress-body", }, } script = f""" const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))}); class Element {{ constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}};this.events=[]; }} addEventListener(name,callback) {{ this.listeners[name]=callback; }} click() {{ this.listeners.click?.(); }} dispatchEvent(event) {{ this.events.push(event.type); }} }} let recognition; class Recognition {{ constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}} }} const elements=Object.fromEntries( ['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()]) ); elements.draft.value='Typed'; elements.draft.maxLength=2000; elements.root.dataset={{draftLabel:'progress update'}}; const saved=[]; const store={{load:async()=>'',save:async(_login,text)=>saved.push(text),clear:async()=>{{}}}}; (async()=>{{ const controller=createVoiceConversationCapture({{ Recognition,elements,transcriptStore:store,getLogin:()=> 'timmy',createEvent:name=>({{type:name}}) }}); await controller.open('issue:stackchain/dashboard:1058:'); elements.start.click(); recognition.onresult({{results:[Object.assign([{{transcript:'x'.repeat(2500)}}],{{isFinal:true}})]}}); const reviewed={{length:elements.transcript.value.length,status:elements.status.textContent,savedLength:saved[0].length}}; elements.replace.click(); process.stdout.write(JSON.stringify({{reviewed,draftLength:elements.draft.value.length,status:elements.status.textContent}})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ assert run_node(script) == { "reviewed": { "length": 2000, "savedLength": 2000, "status": "Transcript ready. Review it before using it.", }, "draftLength": 2000, "status": "Transcript added. Review the progress update before sending.", } def test_unsupported_today_dictation_keeps_progress_entry_available_with_truthful_copy(): script = f""" const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))}); class Element {{ constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}}; }} addEventListener(name,callback) {{ this.listeners[name]=callback; }} dispatchEvent() {{}} }} const elements=Object.fromEntries( ['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()]) ); const controller=createVoiceConversationCapture({{Recognition:null,elements,draftLabel:'progress update'}}); process.stdout.write(JSON.stringify({{ supported:controller.supported,voiceHidden:elements.root.hidden,draftHidden:elements.draft.hidden, status:elements.status.textContent }})); """ assert run_node(script) == { "supported": False, "voiceHidden": True, "draftHidden": False, "status": "Voice capture is unavailable; type the progress update instead.", } def test_private_store_isolates_conversation_transcripts_by_account_and_target(): script = f""" const createVoiceTranscriptStore=require({json.dumps(str(VOICE_STORE))}); const records=new Map(); const transaction=async work=>work({{ get:async key=>records.get(key), put:async value=>records.set(value.login,{{...value}}), delete:async key=>records.delete(key), }}); (async()=>{{ const store=createVoiceTranscriptStore({{transaction}}); await store.save('timmy','Issue reply','issue:org/repo#1'); await store.save('timmy','Pull reply','pull:org/repo#2'); await store.save('alexander','Other account','issue:org/repo#1'); const before={{ issue:await store.load('TIMMY','issue:org/repo#1'), pull:await store.load('timmy','pull:org/repo#2'), other:await store.load('alexander','issue:org/repo#1'), }}; await store.clear('timmy','issue:org/repo#1'); process.stdout.write(JSON.stringify({{before,cleared:await store.load('timmy','issue:org/repo#1')}})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ assert run_node(script) == { "before": {"issue": "Issue reply", "pull": "Pull reply", "other": "Other account"}, "cleared": "", } def test_switching_conversations_aborts_listening_and_recovers_only_the_new_target(): script = f""" const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))}); class Element {{ constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}}; }} addEventListener(name,callback) {{ this.listeners[name]=callback; }} click() {{ this.listeners.click?.(); }} dispatchEvent() {{}} }} const instances=[]; class Recognition {{ constructor() {{ this.aborts=0; instances.push(this); }} start() {{}} stop() {{}} abort() {{ this.aborts+=1; }} }} const elements=Object.fromEntries( ['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()]) ); const loads=[]; const saved=[]; const store={{ async load(login,target) {{ loads.push(target); return target.endsWith('#2') ? 'Recovered pull reply.' : ''; }}, async save(login,text,target) {{ saved.push({{text,target}}); }}, async clear() {{}}, }}; (async()=>{{ const controller=createVoiceConversationCapture({{Recognition,elements,transcriptStore:store,getLogin:()=> 'timmy'}}); await controller.open('issue:org/repo#1'); elements.start.click(); await controller.open('pull:org/repo#2'); instances[0].onresult({{results:[Object.assign([{{transcript:'Stale issue words.'}}],{{isFinal:true}})]}}); instances[0].onerror({{error:'not-allowed'}}); instances[0].onend(); await Promise.resolve(); process.stdout.write(JSON.stringify({{ loads,saved,aborts:instances[0].aborts,transcript:elements.transcript.value, reviewHidden:elements.review.hidden,status:elements.status.textContent, startHidden:elements.start.hidden,stopHidden:elements.stop.hidden }})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ assert run_node(script) == { "loads": ["issue:org/repo#1", "pull:org/repo#2"], "saved": [], "aborts": 1, "transcript": "Recovered pull reply.", "reviewHidden": False, "status": "Recovered transcript. Review it before using or discarding it.", "startHidden": False, "stopHidden": True, } def test_all_mobile_conversation_composers_ship_accessible_voice_review_controls(): html = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text() dashboard = (FRONTEND / "dashboard.js").read_text() bundle = (Path(__file__).parents[1] / "src" / "frontend_bundle.py").read_text() for kind, draft in ( ("issue-comment", "issue-comment"), ("pull-comment", "pull-comment"), ("update-reply", "update-reply"), ): assert f'id="voice-{kind}"' in html assert f'id="start-voice-{kind}"' in html assert f'id="voice-{kind}-transcript"' in html assert f'for="voice-{kind}-transcript"' in html assert f'id="append-voice-{kind}"' in html assert f'id="replace-with-voice-{kind}"' in html assert f'id="discard-voice-{kind}"' in html assert "['issue-comment', '#issue-comment']" in dashboard assert "['pull-comment', '#pull-comment']" in dashboard assert "['update-reply', '#update-reply']" in dashboard assert ".voice-conversation-controls button" in css assert "min-height:44px" in css assert '"static/voice-conversation-capture.js"' in bundle assert "issueVoiceReply.open(conversationVoiceTarget('issue', item))" in dashboard assert "pullVoiceReply.open(conversationVoiceTarget('pull', item))" in dashboard assert "updateVoiceReply.open('update:' + item.notification_id)" in dashboard assert "issueVoiceReply.cancel();" in dashboard assert "pullVoiceReply.cancel();" in dashboard assert "updateVoiceReply.cancel();" in dashboard def test_mobile_search_reply_ships_target_scoped_voice_review_and_cancels_on_close(): html = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text() dashboard = (FRONTEND / "dashboard.js").read_text() for control in ( "voice-search-reply", "start-voice-search-reply", "stop-voice-search-reply", "voice-search-reply-review", "voice-search-reply-transcript", "append-voice-search-reply", "replace-with-voice-search-reply", "discard-voice-search-reply", "voice-search-reply-status", ): assert f'id="{control}"' in html assert 'for="voice-search-reply-transcript"' in html assert "['search-reply', '#search-preview-reply']" in dashboard assert "searchVoiceReply.open(searchConversationVoiceTarget(state.item))" in dashboard assert "searchVoiceReply.cancel();" in dashboard assert ".search-preview-reply .voice-conversation-controls button" in css assert "min-height:44px" in css