diff --git a/desktop/test/focused-cache.test.cjs b/desktop/test/focused-cache.test.cjs index eadfb9155f..dcdfc7e80d 100644 --- a/desktop/test/focused-cache.test.cjs +++ b/desktop/test/focused-cache.test.cjs @@ -70,15 +70,24 @@ test('cache restore never writes draft storage and a new local draft remains ind const {m,record}=await picture();const draft={text:'new unsent 日本語'};const view=new m.FocusedView({scope:()=>scope,cache:{get:async()=>record},hide(){},view(){}}); await view.restore();assert.equal(draft.text,'new unsent 日本語');assert.equal(Object.hasOwn(record,'drafts'),false);assert.equal(Object.hasOwn(record,'session'),false); }); -// This DOM shim follows the real normal live renderer: msg assistant live, -// body child, and NO data-id. A memo key is not a DOM data-id attribute. +// The web's own live row is a static pending bubble (View.pending.node, the +// product's static pending contract: docs/active-threads-load.md). This shim is +// the other stream row capture accepts: msg assistant live, body child, and NO +// data-id. A memo key is not a DOM data-id attribute. function normalLive(body) { return el('div',{class:'msg assistant live'},[el('div',{class:'body'},[text(body)])]); } test('normal live DOM shape matches the renderer rather than assuming a committed-message data-id',()=>{ const fs=require('node:fs'),path=require('node:path');const source=fs.readFileSync(path.resolve(__dirname,'../../src/web/view.bend'),'utf8'); const live=source.slice(source.indexOf('def View.live.n.loc('),source.indexOf('def View.live.n(',source.indexOf('def View.live.n.loc('))); - assert(live.includes('D.At{"class", "msg assistant live"}'));assert(live.includes('D.At{"class", "body"}'));assert(!live.includes('data-id')); + assert(live.includes('View.pending.node(lc, key)')); + const pending=source.slice(source.indexOf('def View.pending.node('),source.indexOf('def View.pending.rows(',source.indexOf('def View.pending.node('))); + assert(pending.includes('D.At{"class", "msg assistant pending"}'));assert(pending.includes('D.At{"class", "body"}'));assert(!pending.includes('data-id')); assert.equal(normalLive('x').hasAttribute('data-id'),false); }); +test('the static pending bubble the renderer draws stays out of a saved picture',async()=>{ + const {m,timeline}=await picture();timeline.children.push(el('div',{class:'msg assistant pending',role:'status'},[el('div',{class:'body'},[text('STATIC PENDING')])])); + const record=m.captureFocused({main:el('main'),timeline,title:el('span',{},[text('title')]),scope,epoch:'actual'}); + assert.equal(record.rows,1);assert(!JSON.stringify(record).includes('STATIC PENDING')); +}); test('a stream-only normal assistant row persists current partial Unicode as a readonly picture',async()=>{ const {m,timeline}=await picture();timeline.children=[normalLive('途中の返答 👩‍💻 é CURRENT')]; const record=m.captureFocused({main:el('main'),timeline,title:el('span',{},[text('Streaming thread')]),scope,epoch:'actual'}); diff --git a/desktop/test/host-thin-startup.test.cjs b/desktop/test/host-thin-startup.test.cjs index 585cd700a8..46ac6c4d2b 100644 --- a/desktop/test/host-thin-startup.test.cjs +++ b/desktop/test/host-thin-startup.test.cjs @@ -6,8 +6,10 @@ async function fixture(supported=false, legacyProbe=false, legacyPaint=false){ const {ViewerRpc}=await import('../../src/web/viewer-rpc.js'),{ReviewPosition}=await import('../../src/web/review-position.js'); const {ImprovementsRpc}=await import('../../src/web/improvements-rpc.js'); const {SubsFeedback}=await import('../../src/web/subs-feedback.js'); + const {SecretSaveHost}=await import('../../src/web/secret-save.js'); + const {phoneRequest}=await import('../../src/web/phone-shell.js'); const {EntryHistory}=await import('../../src/web/entry-history.js'); - const {ThinHost,presentationTarget,preservePresentationRoute,refusedFallback}=await import('../../src/web/thin-host.js'),{Backoff,Retry,probeHub,QUIET_MS}=await import('../../src/web/reconnect.js'),{ThinViews,viewCapability,VIEW_LIMITS}=await import('../../src/web/thin.js'),{discoverViews}=await import('../../src/web/thin-discovery.js'); + const {ThinHost,presentationTarget,preservePresentationRoute,refusedFallback}=await import('../../src/web/thin-host.js'),{Backoff,Retry,directReconnect,DIRECT_TRIES,probeHub,QUIET_MS}=await import('../../src/web/reconnect.js'),{ThinViews,viewCapability,VIEW_LIMITS}=await import('../../src/web/thin.js'),{discoverViews}=await import('../../src/web/thin-discovery.js'); let source=fs.readFileSync(path.resolve(__dirname,'../../src/web/host.js'),'utf8'); if(legacyProbe)source=source.replaceAll(' || attempt !== thinAttempt',''); if(legacyPaint)source=source.replace(' && !detail?.presenting','').replace('state.painted && !becameReady','state.paged && !becameReady'); @@ -31,15 +33,16 @@ async function fixture(supported=false, legacyProbe=false, legacyPaint=false){ presentationJson:x=>x,fromList:x=>x,presentationCommand:c=>calls.push(c),anchorOf:()=>null, History:{project:(_before,fresh)=>fresh,project_block:(_before,fresh)=>fresh},historyNode:null,timelineList:()=>null, App:{wire_out:t=>encoder.encode(t),wire_out_json:v=>encoder.encode(JSON.stringify(v)),viewer_input:(_u,_info,s)=>s,desk_reset:u=>u,presentation_node:n=>n,presentation_prepare:(s,input)=>({...input,rid:s.rid}),presentation_reply:s=>s,presentation_select:(u,id)=>({...u,sel:id}),presentation_handoff:(u,s)=>({...u,sel:s.sel}),unsync:u=>u,lang:()=> 'en'}, - Wire:{cbor:b=>JSON.parse(decoder.decode(b)),plain:x=>x,json:x=>x},document:{getElementById:()=>null,querySelector:()=>null,querySelectorAll:()=>[],documentElement:{classList:{toggle(){}},style:{setProperty(){}}}},pairingReady:Promise.resolve(true),pendingInvitation:null,pairingCause:'',pairingUI:{show(){calls.push('pairing-screen');}}, - fallbackIntent:null,wantFrontend:true,Backoff,Retry:FixtureRetry,probeHub,QUIET_MS,navigator:{onLine:true},thinContinuations:{clear(){},accept:()=>false,bind(){},project:x=>x},presentationTarget,preservePresentationRoute,checkDeploy:()=>{},outbox:new Map(),OUTBOX:'outbox',OUTBOX_DAY:86400000,DRAFT:'draft:', + Wire:{cbor:b=>JSON.parse(decoder.decode(b)),plain:x=>x,json:x=>x},document:{getElementById:()=>null,querySelector:()=>null,querySelectorAll:()=>[],documentElement:{classList:{toggle(){},contains:()=>false},style:{setProperty(){}}}},pairingReady:Promise.resolve(true),pendingInvitation:null,pairingCause:'',pairingUI:{show(){calls.push('pairing-screen');}}, + fallbackIntent:null,wantFrontend:true,Backoff,Retry:FixtureRetry,directReconnect,probeHub,QUIET_MS,navigator:{onLine:true},thinContinuations:{clear(){},accept:()=>false,bind(){},project:x=>x},presentationTarget,preservePresentationRoute,checkDeploy:()=>{},outbox:new Map(),OUTBOX:'outbox',OUTBOX_DAY:86400000,DRAFT:'draft:', fabLook(){},restoreTransition(){},transitionNode:null,transitionFor:'',shownFor:'',heldAt:0,transitionScope:()=>({}),transitionPosition:null,previewViews:{stop(){}},warmDestinations(){},transitionViews:{put(){},position:()=>null,warm:()=>Promise.resolve(false),destination:()=>null}, thinReading:null,resumedConversation:null,readingIdentity:()=> 'fixture-account',historyDirection:'', historyFillMore(){calls.push('history-fill-cleared');},historyPageState(){},historyClear(){},thinSearchAnswer(){},thinSearchFailed(){},showThinMessage(){},keepViewer(){},held:null, modelFeedback:{clear(){}},subsFeedback:new SubsFeedback(()=>{}),viewerFeedback:{choice(){}}, // The extracted socket adapter shares these services with the full host. // Record peripheral lifecycle calls; wire admission/ownership still uses real ThinHost/ThinViews. - RR:null,COARSE_POINTER:false,ViewerTools:{reset(){calls.push('viewer-tools-retired');}},viewerRpc:new ViewerRpc(()=>{throw Error('unexpected viewer RPC');}), + RR:null,COARSE_POINTER:false,PHONE:false,phoneRequest,ViewerTools:{reset(){calls.push('viewer-tools-retired');}},viewerRpc:new ViewerRpc(()=>{throw Error('unexpected viewer RPC');}), + secretSaves:new SecretSaveHost({build:()=>{throw Error('unexpected secret save');},scope:()=>'',binding:()=>'',send(){throw Error('unexpected secret send');},dispatch(){},note(){}}), improvementsRpc:new ImprovementsRpc(()=>{throw Error('unexpected Improvements RPC');}),voiceMicHeard(){calls.push('voice-envelope');}, reviewPosition:new ReviewPosition({getItem:k=>store.get(k)??null,setItem:(k,v)=>store.set(k,v)}), composerKeeper:{selecting(){},navigationFailed(){}},keepDesk:s=>s,deskCheck(){},settingsRecovery:null,settingsRecovering:null, @@ -55,8 +58,11 @@ async function fixture(supported=false, legacyProbe=false, legacyPaint=false){ for(const [start,end] of [['function keepThinReading(','// (the re-render harness'],['function keepSettingsRecovery(','async function recoverSettings('],['function restoreDraftAttachments(','function uploadScope(']]){ const from=source.indexOf(start),to=source.indexOf(end,from);assert(from>0&&to>from);vm.runInContext(source.slice(from,to),ctx); } + // Top-level names of host.js that the slice below reads, taken from host.js itself. + const phoneFrom=source.indexOf('const PHONE_TITLES ='),phoneTo=source.indexOf('\n',phoneFrom);assert(phoneFrom>0&&phoneTo>phoneFrom);vm.runInContext(source.slice(phoneFrom,phoneTo),ctx); + const watchFrom=source.indexOf('let wakeProbe = 0, plotHeard = 0;'),watchTo=source.indexOf('const onWake = why =>',watchFrom);assert(watchFrom>0&&watchTo>watchFrom);vm.runInContext(source.slice(watchFrom,watchTo),ctx); vm.runInContext(source.slice(a,b),ctx);ctx.showViewStatus=s=>status.push(s); - return{ctx,calls,status,requests,sockets,VIEW_LIMITS,boot,source,runBoot:async()=>{let started;const start=ctx.startThin;ctx.startThin=()=>started=start();vm.runInContext(boot,ctx);await Promise.resolve();await started;},tick:ms=>{clock+=ms;for(const[id,t]of[...timers])if(t.at<=clock&&timers.has(id)){timers.delete(id);t.fn();}}}; + return{ctx,calls,status,requests,sockets,VIEW_LIMITS,DIRECT_TRIES,boot,source,runBoot:async()=>{let started;const start=ctx.startThin;ctx.startThin=()=>started=start();vm.runInContext(boot,ctx);await Promise.resolve();await started;},tick:ms=>{clock+=ms;for(const[id,t]of[...timers])if(t.at<=clock&&timers.has(id)){timers.delete(id);t.fn();}}}; } test('actual startup begins capability discovery despite a permanently pending focused IDB lookup',async()=>{ const f=await fixture(false);await f.runBoot();assert(f.calls.includes('focused-read'));assert(f.calls.includes('/client/capabilities'));assert.equal(f.ctx.viewMode,'read-only'); @@ -114,6 +120,8 @@ test('late retry probe cannot replace a newer connection; previous host path rep const f=await fixture(true,legacy);await f.runBoot();f.sockets[0].open(); const original=f.ctx.window.fetch;let release; f.ctx.window.fetch=url=>url==='/hello'?new Promise(resolve=>{release=resolve;}):original(url); + // A page past its direct tries asks /hello first (directReconnect); that probe is what can come back late. + vm.runInContext('directFails='+f.DIRECT_TRIES,f.ctx); f.sockets[0].close();vm.runInContext('thinRetry.wake()',f.ctx);assert.equal(typeof release,'function'); await f.ctx.startThin(false,'retry');const newer=f.sockets[1];newer.open(); release({status:200});await new Promise(setImmediate); diff --git a/desktop/test/thin-search-ui.test.cjs b/desktop/test/thin-search-ui.test.cjs index 9fc930c332..bfb38fad99 100644 --- a/desktop/test/thin-search-ui.test.cjs +++ b/desktop/test/thin-search-ui.test.cjs @@ -1,5 +1,6 @@ 'use strict'; -const {test}=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'); +const {test,before}=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'); +let softFocus;before(async()=>{({softFocus}=await import('../../src/web/touch-focus.js'));}); const source=fs.readFileSync(path.resolve(__dirname,'../../src/web/host.js'),'utf8'); function trigger(mode,ready){ const a=source.indexOf('function dispatch('),b=source.indexOf(' if (viewMode !== "compatibility")',a),panels=[],status=[]; @@ -24,9 +25,12 @@ function panelFixture(){ } const panel=new Element('section'),calls=[],select=[];let closes=0; const ctx={resultPanel:()=>({panel,close:()=>{closes++;connected=false;}}), - document:{createElement:tag=>new Element(tag),createTextNode:text=>({tag:'#text',textContent:text}),getElementById:id=>id==='find-rows'?panel.children[2]:null}, - thin:{search:(...args)=>{calls.push(args);return true;}},dispatch:(...args)=>select.push(args),later:()=>{},setTimeout,clearTimeout,Date}; - const a=source.indexOf('let thinFind = null;'),b=source.indexOf('function showThinMessage(',a);vm.createContext(ctx);vm.runInContext(source.slice(a,b),ctx); + document:{documentElement:{classList:{contains:()=>false}},createElement:tag=>new Element(tag),createTextNode:text=>({tag:'#text',textContent:text}),getElementById:id=>id==='find-rows'?panel.children[2]:null}, + thin:{search:(...args)=>{calls.push(args);return true;}},dispatch:(...args)=>select.push(args),later:()=>{},softFocus,setTimeout,clearTimeout,Date}; + const a=source.indexOf('let thinFind = null;'),b=source.indexOf('function showThinMessage(',a);vm.createContext(ctx); + // A top-level name of host.js that the slice reads, taken from host.js itself. + const phoneFrom=source.indexOf('const PHONE_TITLES ='),phoneTo=source.indexOf('\n',phoneFrom);assert(phoneFrom>0&&phoneTo>phoneFrom);vm.runInContext(source.slice(phoneFrom,phoneTo),ctx); + vm.runInContext(source.slice(a,b),ctx); const parts=()=>({query:panel.children[0],note:panel.children[1],rows:panel.children[2]}); const key=(k,more={})=>parts().query.handlers.keydown({key:k,isComposing:false,preventDefault(){},...more}); return{ctx,panel,calls,select,parts,key,closes:()=>closes}; diff --git a/desktop/test/thin-view-hold.test.cjs b/desktop/test/thin-view-hold.test.cjs index 9257258c54..54896802e1 100644 --- a/desktop/test/thin-view-hold.test.cjs +++ b/desktop/test/thin-view-hold.test.cjs @@ -78,7 +78,7 @@ test('actual held/wait markers reach the dataset and unchanged renders do not re const data={},writes=[];const ctx={document:{documentElement:{dataset:new Proxy(data,{set:(o,k,v)=>{writes.push(k);o[k]=v;return true;}})}}, frontendNode:null,transitionNode:{},transitionFor:'thread:A',focusedNode:null,canonicalBoot:false,heldAt:5000,QUIET_MS:1200, performance:{now:()=>5000},currentSession:()=>({sel:'B'}),presentationTarget:s=>({kind:'thread',id:s.sel}),targetKey:t=>t.kind+':'+t.id}; - const a=source.indexOf('function htmlData('),b=source.indexOf('function render()',a),c=source.indexOf(' const heldFor =',b),d=source.indexOf(' retireAllowance(',c);assert(a>0&&b>a&&c>b&&d>c); + const a=source.indexOf('function htmlData('),b=source.indexOf('function render()',a),c=source.indexOf(' const heldFor =',b),d=source.indexOf(' trace("dom",',c);assert(a>0&&b>a&&c>b&&d>c); vm.createContext(ctx);vm.runInContext(source.slice(a,b),ctx);const mark=()=>vm.runInContext('{'+source.slice(c,d)+'}',ctx); mark();assert.deepEqual(data,{backplaneHeld:'true',backplaneWait:'false'});mark();assert.equal(writes.length,2); ctx.performance.now=()=>6200;mark();assert.equal(data.backplaneHeld,'quiet');