diff --git a/.github/workflows/conversation-lifecycle.yml b/.github/workflows/conversation-lifecycle.yml index e952d34f..a1898cdd 100644 --- a/.github/workflows/conversation-lifecycle.yml +++ b/.github/workflows/conversation-lifecycle.yml @@ -13,6 +13,7 @@ on: - 'server.py' - 'tests/browser_conversation_lifecycle.py' - 'tests/browser_historical_transcript_hydration.py' + - 'tests/browser_reconnect_scene_redraw.py' - 'requirements*.txt' - 'pyproject.toml' - '.github/workflows/conversation-lifecycle.yml' @@ -24,6 +25,7 @@ on: - 'server.py' - 'tests/browser_conversation_lifecycle.py' - 'tests/browser_historical_transcript_hydration.py' + - 'tests/browser_reconnect_scene_redraw.py' - 'requirements*.txt' - 'pyproject.toml' - '.github/workflows/conversation-lifecycle.yml' @@ -45,6 +47,9 @@ jobs: scenario: terminal-error - name: historical-transcript-hydration script: tests/browser_historical_transcript_hydration.py + - name: reconnect-scene-redraw + script: tests/browser_reconnect_scene_redraw.py + browsers: chromium steps: - uses: actions/checkout@v4 @@ -69,6 +74,7 @@ jobs: LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts HISTORICAL_HYDRATION_ARTIFACT_DIR: lifecycle-artifacts LIFECYCLE_SCENARIO: ${{ matrix.scenario }} + BROWSERS: ${{ matrix.browsers }} run: python ${{ matrix.script }} - name: Run settlement frame proof diff --git a/TESTING.md b/TESTING.md index 6b2f2862..d8fd0ff5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -137,11 +137,32 @@ HISTORICAL_HYDRATION_TEST_BITE=break-tool-link \ ``` The dedicated `Conversation lifecycle (informational)` workflow runs the current -proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`) and -stays non-blocking while the public +proof rows (`normal`, `terminal-error`, `historical-transcript-hydration`, and the +Chromium `reconnect-scene-redraw` matrix) and stays non-blocking while the public matrix expands to additional behavior rows. The maintainer's private QA harness -remains broader; later public slices will add session switching, reconnect/replay, -cancellation, compression, and recovery. +remains broader; later public slices will add cancellation, compression, and +recovery coverage. + +### Active-session reconnect redraw gate + +`tests/browser_reconnect_scene_redraw.py` is an opt-in deterministic browser +gate for reconnecting to a running session with a large Anchor activity scene. +It uses an isolated temporary server/home and fixture SSE events—no agent, +provider credentials, or production state. By default it checks Chromium and +WebKit, desktop and 390px viewports, and all three activity display modes: + +```bash +pip install playwright +python -m playwright install chromium webkit +python tests/browser_reconnect_scene_redraw.py +``` + +`TOOL_COUNT`, `BROWSERS`, and `MODES` narrow the matrix. `MEASURE_BASELINE=1` +records an unoptimized baseline without enforcing the redraw ceiling; +`METRICS_FILE` saves JSON metrics and `SCREENSHOT_DIR` saves generated-fixture +screenshots. This gate tests page reconstruction, journal-cursor resume, and +subsequent fixture SSE updates. It does not test a real provider/network or PWA +service-worker cache behavior. ### Streaming reader intent diff --git a/static/sessions.js b/static/sessions.js index b7a59359..60daf168 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2165,6 +2165,12 @@ async function loadSession(sid){ S.activeStreamId=activeStreamId; const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim(); const replayPersistedLiveToolCards=(opts)=>{ + // The journal-backed Anchor scene is authoritative through its resume + // cursor; newer rows arrive through the reattached SSE stream. Replaying + // the older INFLIGHT tool cache after a successful scene restore would + // redraw all N rows N times. Keep the #3707 replay only for legacy HTML + // restoration or a failed/unavailable scene render. + if(restoredAnchorScene) return; const liveToolCalls=Array.isArray(S.toolCalls) ? S.toolCalls : (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]); diff --git a/tests/browser_reconnect_scene_redraw.py b/tests/browser_reconnect_scene_redraw.py new file mode 100644 index 00000000..8bc5118e --- /dev/null +++ b/tests/browser_reconnect_scene_redraw.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Real page/loadSession/renderers with deterministic session and SSE fixtures. + +No agent, provider, production state, or credentials. Tests DOM reconstruction, +not a real provider/network reconnect. +""" +import json +import os +import tempfile +import time +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +from playwright.sync_api import sync_playwright + +from browser_conversation_lifecycle import _start_webui_server, _terminate_process + +ROOT = Path(os.environ.get('WEBUI_TEST_ROOT', Path(__file__).resolve().parent.parent)) + + +def fixture(count): + tools = [dict(name='terminal', tid=f'tool-{i}', args={'command': f'printf result-{i}'}, + preview=f'result-{i}', snippet=f'result-{i}', done=True) for i in range(count)] + rows = [dict(row_id=f'tool:{t["tid"]}', local_id=t['tid'], role='tool', kind='tool_call', + source_event_type='tool_complete', status='completed', order_index=i, + tool=t, payload=t) for i, t in enumerate(tools)] + return dict(stream_id='run-fixture', last_seq=1000, last_event_id='run-fixture:1000', + messages=[], tool_calls=tools, last_assistant_text='', last_reasoning_text='', + anchor_activity_scene=dict(version='activity_scene_v1', + identity=dict(session_id='fixture', stream_id='run-fixture', run_id='run-fixture'), + activity_rows=rows)) + + +INIT = """ +// A controlling service worker bypasses Playwright's page route fixtures in +// WebKit. This test targets reconnect rendering, not PWA cache behavior. +if(window===window.top && 'serviceWorker' in navigator){ + navigator.serviceWorker.register=()=>Promise.reject(new Error('Disabled in reconnect harness')); +} +window.fixtureSources=[]; +class FixtureEventSource { + static OPEN=1; static CONNECTING=0; static CLOSED=2; + constructor(url){this.url=String(url);this.readyState=1;this.listeners={};window.fixtureSources.push(this);} + addEventListener(name,fn){(this.listeners[name]||=[]).push(fn);} + removeEventListener(){} + close(){this.readyState=2;} + emit(name,data,id){for(const fn of this.listeners[name]||[])fn({data:JSON.stringify(data),lastEventId:id||''});} +} +window.EventSource=FixtureEventSource; +""" + + +def session_route(session, sid, workspace): + def handle(route): + query = parse_qs(urlsplit(route.request.url).query) + requested = query.get('session_id', [''])[0] + data = session if requested == sid else dict( + session_id='idle-fixture', messages=[], message_count=0, + tool_calls=[], workspace=workspace) + route.fulfill(json={'session': data}) + return handle + + +def main(): + tool_count = int(os.environ.get('TOOL_COUNT', '470')) + snapshot = fixture(tool_count) + stream_id = snapshot['stream_id'] + sid = 'fixture' + with tempfile.TemporaryDirectory(prefix='webui-reconnect-') as temp: + state = Path(temp) + env = {k:os.environ[k] for k in ('PATH','SYSTEMROOT','TMPDIR') if k in os.environ} + env.update(HOME=temp, HERMES_HOME=temp, HERMES_BASE_HOME=temp, + HERMES_WEBUI_STATE_DIR=str(state/'webui'), HERMES_CONFIG_PATH=str(state/'config.yaml'), + HERMES_WEBUI_HOST='127.0.0.1', HERMES_WEBUI_SKIP_ONBOARDING='1', + HERMES_WEBUI_AGENT_DIR=str(state/'no-agent')) + proc, log, _, base = _start_webui_server(ROOT, env, state) + results = [] + try: + with sync_playwright() as pw: + for engine in os.environ.get('BROWSERS','chromium,webkit').split(','): + browser = getattr(pw,engine).launch(headless=True) + try: + for mode in os.environ.get('MODES','compact_worklog,transparent_stream,hide_all_activity').split(','): + for width in [1280,390]: + context = browser.new_context(viewport={'width':width,'height':844},bypass_csp=True) + context.add_init_script(INIT) + page = context.new_page() + + errors=[] + page.on('pageerror',lambda e, errors=errors:errors.append(str(e))) + session = dict(session_id=sid,title='Reconnect performance fixture',model='', + workspace=temp,messages=[],message_count=0,tool_calls=[], + active_stream_id=stream_id,pending_user_message='Inspect the fixture', + pending_started_at=1,runtime_journal_snapshot=snapshot) + page.route('**/api/session?*',session_route(session,sid,temp)) + page.route('**/api/chat/stream/status?*',lambda r:r.fulfill(json={'active':True})) + page.goto(base,wait_until='load') + # WebKit's wait_for_function uses eval, blocked by + # the app CSP even with bypass_csp. Inspector + # evaluation still works; wait on actual boot state. + deadline=time.monotonic()+30 + while not page.evaluate("typeof loadSession==='function' && S._bootReady === true"): + assert time.monotonic(){ + window._chatActivityDisplayMode=mode;window._showThinking=true; + window._simplifiedToolCalling=true; + window.redrawCount=0; + const render=window._renderLiveAnchorActivitySceneForStream; + window._renderLiveAnchorActivitySceneForStream=function(...args){window.redrawCount++;return render(...args);}; + }""",mode) + measured=page.evaluate("""async sid=>{ + const t=performance.now();await loadSession(sid); + const loadMs=performance.now()-t; + const deadline=performance.now()+5000; + while(!fixtureSources.some(s=>s.url.includes('api/chat/stream?'))&&performance.now()setTimeout(r,10)); + } + return {ms:loadMs,redraws:window.redrawCount, + tools:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length, + busy:S.busy, + sources:fixtureSources.filter(s=>s.url.includes('api/chat/stream?')).map(s=>s.url)}; + }""",sid) + result=dict(engine=engine,mode=mode,width=width, + **{k:v for k,v in measured.items() if k!='sources'}) + print(json.dumps(result),flush=True) + results.append(result) + if not os.environ.get('MEASURE_BASELINE'): + assert measured['redraws']<=2,result + expected=0 if mode=='hide_all_activity' else tool_count + assert measured['tools']==expected,result + assert measured['busy'],result + assert measured['sources'], (result, errors) + cursor=parse_qs(urlsplit(measured['sources'][-1]).query) + assert cursor['after_seq']==[str(snapshot['last_seq'])], (result,errors) + if mode!='hide_all_activity': + # Subsequent real SSE handler updates still paint the existing owner. + updated=page.evaluate("""({sid,stream})=>{ + const source=fixtureSources.findLast(s=>s.url.includes('api/chat/stream?')&&s.readyState===1); + if(!source)throw new Error('missing chat stream'); + source.emit('tool',{name:'terminal',tid:'after-reconnect',args:{command:'printf later'},preview:'later'},stream+':1001'); + source.emit('tool_complete',{name:'terminal',tid:'after-reconnect',preview:'LATER RESULT',duration:1},stream+':1002'); + return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length, + result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')}; + }""",{'sid':sid,'stream':stream_id}) + assert updated=={'rows':expected+1,'result':True},updated + switched=page.evaluate("""async sid=>{ + await loadSession('idle-fixture'); + await loadSession(sid); + return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length, + result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')}; + }""",sid) + assert switched==updated,switched + artifact=os.environ.get('SCREENSHOT_DIR') + if artifact: + Path(artifact).mkdir(parents=True,exist_ok=True) + page.screenshot(path=str(Path(artifact)/f'{engine}-{mode}-{width}.png')) + assert not errors,errors + context.close() + finally: + browser.close() + output=os.environ.get('METRICS_FILE') + if output: + Path(output).write_text(json.dumps(results,indent=2)) + finally: + _terminate_process(proc) + log.close() + + +if __name__=='__main__': + main() diff --git a/tests/test_reconnect_scene_redraw.py b/tests/test_reconnect_scene_redraw.py new file mode 100644 index 00000000..1c1f03e3 --- /dev/null +++ b/tests/test_reconnect_scene_redraw.py @@ -0,0 +1,82 @@ +"""Execute the production reconnect replay seam, not a source-string assertion. + +Browser coverage in browser_reconnect_scene_redraw.py exercises loadSession and +real DOM rendering; these cheap Node checks pin fallback and redraw counts. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +NODE = shutil.which('node') +pytestmark = pytest.mark.skipif(NODE is None, reason='Node.js is required for the reconnect replay probe') + + +def source_slice(text, start_marker, end_marker, path): + start = text.find(start_marker) + assert start >= 0, f'{path}: missing start marker {start_marker!r}' + end = text.find(end_marker, start) + assert end >= 0, f'{path}: missing end marker {end_marker!r}' + return text[start:end] + + +def replay_probe(restored_scene, count, skip_unkeyed=True, has_rows=True): + sessions = (ROOT / 'static/sessions.js').read_text() + ui = (ROOT / 'static/ui.js').read_text() + replay = source_slice( + sessions, 'const liveToolReplayId=(tc)=>', ' let didReconnect=false;', + 'static/sessions.js', + ) + append = source_slice( + ui, 'function appendLiveToolCard(', '\nfunction _findLatestLiveAssistantByBurst', + 'static/ui.js', + ) + script = r""" +const vm=require('node:vm'); +const opts=JSON.parse(process.argv[1]); +const tools=Array.from({length:opts.count},(_,i)=>({name:'terminal',tid:'tool-'+i,done:true})); +tools.push({name:'read_file',done:true}); +let redraws=0, appended=[]; +const sandbox={ + S:{session:{session_id:'session'},activeStreamId:'run',toolCalls:tools}, + sid:'session',activeStreamId:'run',INFLIGHT:{},restoredAnchorScene:opts.restored_scene, + document:{getElementById:()=>({querySelector:()=>opts.has_rows?{}:null})}, + isFinalAnswerOnlyMode:()=>false, + isLiveAnchorActivitySceneOwner:()=>true, + _renderLiveAnchorActivitySceneForStream:()=>{redraws++;return true;}, +}; +vm.createContext(sandbox); +vm.runInContext(APPEND,sandbox); +// The actual append function proves the amplification when a scene owns DOM. +// For legacy fallback, observe arguments at the append boundary instead. +if(!opts.restored_scene) sandbox.appendLiveToolCard=(tc)=>appended.push(tc.tid||'unkeyed'); +vm.runInContext(REPLAY+'\nreplayPersistedLiveToolCards('+JSON.stringify({skipUnkeyedRestoredDuplicates:opts.skip_unkeyed})+');',sandbox); +console.log(JSON.stringify({redraws,appended})); +""".replace('APPEND', json.dumps(append)).replace('REPLAY', json.dumps(replay)) + result = subprocess.run( + [NODE or 'node', '-e', script, json.dumps({ + 'restored_scene': restored_scene, 'count': count, + 'skip_unkeyed': skip_unkeyed, 'has_rows': has_rows, + })], capture_output=True, text=True, check=True, + ) + return json.loads(result.stdout) + + +@pytest.mark.parametrize('count', [0, 1, 470]) +def test_restored_complete_scene_is_not_redrawn_per_saved_tool(count): + result = replay_probe(True, count) + assert result['redraws'] == 0, result + + +def test_non_anchor_restore_still_replays_keyed_tools(): + result = replay_probe(False, 3) + assert result['appended'] == ['tool-0', 'tool-1', 'tool-2'] + + +@pytest.mark.parametrize('skip_unkeyed,has_rows', [(False, True), (True, False)]) +def test_failed_scene_or_empty_html_keeps_unkeyed_fallback(skip_unkeyed, has_rows): + result = replay_probe(False, 3, skip_unkeyed, has_rows) + assert result['appended'] == ['tool-0', 'tool-1', 'tool-2', 'unkeyed'] diff --git a/tests/test_static_asset_resolver.py b/tests/test_static_asset_resolver.py index 455a6dc7..c2e51c17 100644 --- a/tests/test_static_asset_resolver.py +++ b/tests/test_static_asset_resolver.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import json from pathlib import Path from types import SimpleNamespace @@ -7,7 +8,7 @@ from urllib.parse import quote import api.config as api_config import api.routes as routes -from api.updates import WEBUI_VERSION + ROOT = Path(__file__).resolve().parent.parent @@ -81,8 +82,12 @@ def test_service_worker_and_favicon_follow_selected_static_root(tmp_path, monkey monkeypatch.setattr(api_config, "get_static_root", lambda: static_root) sw_handler = _get("/sw.js") + # The route resolves api.updates dynamically on each request. Other update + # tests deliberately evict and re-import that module, so a collection-time + # alias can become stale even though the checkout and runtime module agree. + webui_version = importlib.import_module("api.updates").WEBUI_VERSION expected = sw_path.read_text(encoding="utf-8").replace( - "__WEBUI_VERSION__", quote(WEBUI_VERSION, safe="") + "__WEBUI_VERSION__", quote(webui_version, safe="") ).encode("utf-8") assert sw_handler.status == 200 assert sw_handler.header("Service-Worker-Allowed") == "/"