import json import subprocess from pathlib import Path SAVED_SEARCHES = Path(__file__).parents[1] / "frontend" / "saved-searches.js" def run_node(script): return json.loads( subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout ) def test_saved_search_controller_loads_saves_opens_renames_and_deletes(): script = f""" const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))}); const requests=[]; const opened=[]; const states=[]; let remote={{revision:0,views:[]}}; let sequence=0; const controller=createSavedSearches({{ fetchJson:async (_url,options={{}})=>{{ requests.push(options.method||'GET'); if(!options.method)return remote; const body=JSON.parse(options.body);remote={{revision:remote.revision+1,views:body.views}};return remote; }}, createId:()=> 'view-'+(++sequence), onOpen:view=>opened.push(view), onState:state=>states.push(state), }}); (async()=>{{ await controller.load(); await controller.save('Release queue',{{query:'mobile',kind:'issue',state:'open',repository:'stackchain/dashboard'}}); controller.open('view-1'); await controller.rename('view-1','Morning release queue'); await controller.remove('view-1'); process.stdout.write(JSON.stringify({{requests,opened,snapshot:controller.snapshot(),statuses:states.map(x=>x.status)}})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ assert run_node(script) == { "requests": ["GET", "PUT", "PUT", "PUT"], "opened": [{ "id": "view-1", "name": "Release queue", "query": "mobile", "kind": "issue", "state": "open", "repository": "stackchain/dashboard", }], "snapshot": {"revision": 3, "views": []}, "statuses": ["loading", "ready", "saving", "ready", "saving", "ready", "saving", "ready"], } def test_saved_search_controller_adopts_conflict_and_keeps_search_usable_on_failure(): script = f""" const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))}); const states=[];let mode='conflict'; const server={{revision:4,views:[{{id:'remote',name:'Remote',query:'review',kind:'pull',state:'open',repository:''}}]}}; const controller=createSavedSearches({{ fetchJson:async (_url,options={{}})=>{{ if(!options.method)return {{revision:3,views:[]}}; if(mode==='conflict'){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:server}}}};throw error;}} throw new Error('offline'); }},createId:()=> 'local',onOpen:()=>{{}},onState:state=>states.push([state.status,state.message||'']), }}); (async()=>{{ await controller.load(); try{{await controller.save('Local',{{query:'mobile',kind:'all',state:'all'}})}}catch(_error){{}} mode='offline'; try{{await controller.remove('remote')}}catch(_error){{}} process.stdout.write(JSON.stringify({{snapshot:controller.snapshot(),states}})); }})(); """ result = run_node(script) assert result["snapshot"] == { "revision": 4, "views": [{ "id": "remote", "name": "Remote", "query": "review", "kind": "pull", "state": "open", "repository": "", }], } assert result["states"][-1] == ["error", "Saved searches could not sync. Search still works."] assert ["conflict", "Saved searches changed on another device."] in result["states"] def test_mobile_search_renders_synced_saved_view_controls_and_runtime_wiring(): from tests.dashboard_bundle import dashboard_bundle_text html = dashboard_bundle_text() css = (SAVED_SEARCHES.parent / "dashboard.css").read_text() dashboard = (SAVED_SEARCHES.parent / "dashboard.js").read_text() assert '
' in html assert 'id="saved-search-name"' in html assert 'id="save-current-search"' in html assert 'id="saved-search-list"' in html assert 'id="saved-search-status"' in html assert "createSavedSearches.mount(" in dashboard saved_searches = SAVED_SEARCHES.read_text() assert "search.setQuery('')" in saved_searches assert "applyScope(view)" in saved_searches assert "search.setQuery(view.query)" in saved_searches assert ").load();" in dashboard assert "static/saved-searches.js" in (SAVED_SEARCHES.parent.parent / "src" / "frontend_bundle.py").read_text() assert ".saved-search-action { min-height:44px;" in css