char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
f154c1f
main
1import { Signal } from "@char/aftercare" ; 2import { assertEquals } from "@std/assert" ; 3import { until } from "@berry/util" ; 4import { fill , press , withDom } from "@berry/util/dom.ts" ; 5import { composer , go } from "./composer.tsx" ; 6import type { CommandInfo } from "./conversation.tsx" ; 7import { Scope } from "./ui.ts" ; 8 9Deno . test ( "composer drafts survive leaving a view and successful sends retire only the sent text" , async ( t ) => { 10await withDom ( async ( window ) => { 11const scopes :Scope [] = []; 12const errors :string [] = []; 13const commands = new Signal < readonly CommandInfo []>([]); 14let send :( text :string , followUp :boolean ) => Promise < void > = () => Promise . resolve (); 15let completeReferences :( prefix :string ) => Promise < readonly string []> = () => 16Promise . resolve ([]); 17const open = ( route :string , busy = new Signal ( false ), shellEnabled = new Signal ( false )) => { 18location . hash = `#/ ${ route } ` ; 19const scope = new Scope (); 20scopes . push ( scope ); 21const view = composer ( 22scope , 23route , 24busy , 25commands , 26( text , followUp ) => send ( text , followUp ), 27( message ) => errors . push ( message ), 28undefined , 29( prefix ) => completeReferences ( prefix ), 30shellEnabled , 31); 32document . body . replaceChildren ( view . element ); 33const input = view . element . querySelector ( "textarea" ) ! ; 34return { 35 ...view , 36 input, 37edit :( text :string ) => fill ( input , text ), 38key :( key :string ) => void press ( input , key ), 39leave () { 40scope . dispose (); 41view . element . remove (); 42}, 43}; 44}; 45try { 46await t . step ( 47"typed drafts are independent for conversations, branch points and forks" , 48async () => { 49const routes = [ "first" , "second" , "first@4" , "first@5" , "first/fork@4" ]; 50for ( const route of routes ) { 51const view = open ( route ); 52assertEquals ( view . input . value , "" ); 53view . edit ( ` ${ route } \nunfinished ` ); 54view . leave (); 55} 56for ( const route of routes ) { 57const view = open ( route ); 58assertEquals ( view . input . value , ` ${ route } \nunfinished ` ); 59view . leave (); 60} 61await window . happyDOM . waitUntilComplete (); 62}, 63); 64await t . step ( "a fresh frontend restores the draft from browser storage" , async () => { 65const view = open ( "reload" ); 66view . setText ( "survives reload" ); 67view . leave (); 68const fresh = await import ( 69new URL ( "./composer.tsx?reload" , import . meta. url ). href 70) as typeof import ( "./composer.tsx" ); 71const scope = new Scope (); 72scopes . push ( scope ); 73const restored = fresh . composer ( 74scope , 75"reload" , 76new Signal ( false ), 77commands , 78() => Promise . resolve (), 79() => {}, 80); 81document . body . replaceChildren ( restored . element ); 82assertEquals ( restored . element . querySelector ( "textarea" ) ! . value , "survives reload" ); 83await window . happyDOM . waitUntilComplete (); 84}); 85await t . step ( 86"tree prefills and later edits survive returning to the destination" , 87async () => { 88const source = open ( "source" ); 89go ({ tid :"prefilled" , at :8 , fork :true , text :"original message" }, source . setText ); 90source . leave (); 91const destination = open ( "prefilled/fork@8" ); 92assertEquals ( destination . input . value , "original message" ); 93destination . leave (); 94const returned = open ( "prefilled/fork@8" ); 95assertEquals ( returned . input . value , "original message" ); 96returned . edit ( "edited message" ); 97returned . leave (); 98assertEquals ( open ( "prefilled/fork@8" ). input . value , "edited message" ); 99await window . happyDOM . waitUntilComplete (); 100}, 101); 102await t . step ( 103"references complete at the caret, browse directories and save the draft" , 104async () => { 105const requested :string [] = []; 106completeReferences = ( prefix ) => { 107requested . push ( prefix ); 108return Promise . resolve ( prefix === "src/" ?[ "src/main.ts" , "src/mod.ts" ] :[ "src/" ]); 109}; 110const view = open ( "references" ); 111const suggestions = view . element . querySelector < HTMLElement >( ".suggestions" ) ! ; 112view . edit ( "read @sr then explain" ); 113view . input . setSelectionRange ( 8 , 8 ); 114view . input . click (); 115await until (() => ! suggestions . hidden && suggestions . children . length === 1 ); 116view . key ( "Tab" ); 117assertEquals ( view . input . value , "read @src/ then explain" ); 118await until (() => suggestions . children . length === 2 ); 119view . key ( "ArrowDown" ); 120view . key ( "Enter" ); 121assertEquals ( view . input . value , "read @src/mod.ts then explain" ); 122assertEquals ( requested , [ "sr" , "src/" ]); 123view . leave (); 124assertEquals ( open ( "references" ). input . value , "read @src/mod.ts then explain" ); 125await window . happyDOM . waitUntilComplete (); 126}, 127); 128await t . step ( "leaving a reference clears its suggestions immediately" , async () => { 129completeReferences = () => Promise . resolve ([ "src/main.ts" ]); 130commands . set ([{ name :"example" , extension :"test" , description :"example" }]); 131const view = open ( "leaving-reference" ); 132const suggestions = view . element . querySelector < HTMLElement >( ".suggestions" ) ! ; 133view . edit ( "@src" ); 134await until (() => ! suggestions . hidden ); 135view . edit ( "@src " ); 136assertEquals ( suggestions . hidden , true ); 137view . edit ( "/ex" ); 138assertEquals ( suggestions . querySelector ( ".command" )?. textContent , "/example" ); 139view . edit ( "@src" ); 140assertEquals ( suggestions . hidden , true ); 141await until (() => suggestions . textContent === "@src/main.ts" ); 142view . key ( "Escape" ); 143assertEquals ( suggestions . hidden , true ); 144view . leave (); 145await window . happyDOM . waitUntilComplete (); 146}); 147await t . step ( "shell entries do not complete references and use shell text" , async () => { 148const requested :string [] = []; 149completeReferences = ( prefix ) => { 150requested . push ( prefix ); 151return Promise . resolve ([ "src/main.ts" ]); 152}; 153const view = open ( "shell-references" , new Signal ( false ), new Signal ( true )); 154const suggestions = view . element . querySelector < HTMLElement >( ".suggestions" ) ! ; 155view . edit ( "!cat @sr" ); 156assertEquals ( view . input . classList . contains ( "shell" ), true ); 157view . edit ( "!!cat @sr" ); 158assertEquals ( view . input . classList . contains ( "shell" ), true ); 159// reference lookups are debounced; give a suppressed one time to fire 160await new Promise (( resolve ) => setTimeout ( resolve , 150 )); 161assertEquals ( requested , []); 162assertEquals ( suggestions . hidden , true ); 163view . edit ( "cat @sr" ); 164assertEquals ( view . input . classList . contains ( "shell" ), false ); 165await until (() => ! suggestions . hidden ); 166assertEquals ( requested , [ "sr" ]); 167view . edit ( "!cat @sr" ); 168assertEquals ( suggestions . hidden , true ); 169view . leave (); 170const returned = open ( "shell-references" , new Signal ( false ), new Signal ( true )); 171assertEquals ( returned . input . value , "!cat @sr" ); 172assertEquals ( returned . input . classList . contains ( "shell" ), true ); 173assertEquals ( returned . element . querySelector < HTMLElement >( ".suggestions" ) ! . hidden , true ); 174returned . leave (); 175await window . happyDOM . waitUntilComplete (); 176}); 177await t . step ( "a leading bang without shell still completes references" , async () => { 178completeReferences = () => Promise . resolve ([ "src/main.ts" ]); 179const view = open ( "bang-message" ); 180view . edit ( "!cat @sr" ); 181assertEquals ( view . input . classList . contains ( "shell" ), false ); 182await until (() => ! view . element . querySelector < HTMLElement >( ".suggestions" ) ! . hidden ); 183view . leave (); 184await window . happyDOM . waitUntilComplete (); 185}); 186await t . step ( "outdated reference results cannot revive dismissed suggestions" , async () => { 187const pending :(( paths :string []) => void )[] = []; 188const asked :string [] = []; 189completeReferences = ( prefix ) => { 190asked . push ( prefix ); 191return new Promise (( resolve ) => pending . push ( resolve )); 192}; 193const view = open ( "stale-references" ); 194view . edit ( "@old" ); 195await until (() => asked . length === 1 ); 196view . edit ( "@new" ); 197await until (() => asked . length === 2 ); 198pending [ 0 ]([ "old.ts" ]); 199await Promise . resolve (); 200assertEquals ( view . element . querySelector ( ".suggestions" ) ! . children . length , 0 ); 201view . key ( "Escape" ); 202pending [ 1 ]([ "new.ts" ]); 203await Promise . resolve (); 204view . key ( "Tab" ); 205assertEquals ( view . input . value , "@new" ); 206view . edit ( "@gone" ); 207await until (() => asked . length === 3 ); 208view . leave (); 209pending [ 2 ]([ "gone.ts" ]); 210await Promise . resolve (); 211assertEquals ( view . element . querySelector ( ".suggestions" ) ! . children . length , 0 ); 212await window . happyDOM . waitUntilComplete (); 213}); 214await t . step ( "failed sends retain text and successful retries clear it" , async () => { 215send = () => Promise . reject ( new Error ( "offline" )); 216const view = open ( "retry" ); 217view . edit ( " send me\nplease " ); 218view . key ( "Enter" ); 219await window . happyDOM . waitUntilComplete (); 220assertEquals ( errors , [ "offline" ]); 221view . leave (); 222const returned = open ( "retry" ); 223assertEquals ( returned . input . value , " send me\nplease " ); 224const sent :string [] = []; 225send = ( text ) => { 226sent . push ( text ); 227return Promise . resolve (); 228}; 229returned . key ( "Enter" ); 230await window . happyDOM . waitUntilComplete (); 231assertEquals ( sent , [ "send me\nplease" ]); 232assertEquals ( returned . input . value , "" ); 233returned . leave (); 234assertEquals ( open ( "retry" ). input . value , "" ); 235await window . happyDOM . waitUntilComplete (); 236}); 237await t . step ( 238"a pending send cannot erase text typed after sending or duplicate the send" , 239async () => { 240let finish ! :() => void ; 241let sends = 0 ; 242send = () => { 243sends ++ ; 244return new Promise < void >(( resolve ) => finish = resolve ); 245}; 246const view = open ( "pending" ); 247view . edit ( "sent text" ); 248view . key ( "Enter" ); 249view . key ( "Enter" ); 250view . edit ( "next message" ); 251finish (); 252await window . happyDOM . waitUntilComplete (); 253assertEquals ( sends , 1 ); 254assertEquals ( view . input . value , "next message" ); 255view . leave (); 256assertEquals ( open ( "pending" ). input . value , "next message" ); 257await window . happyDOM . waitUntilComplete (); 258}, 259); 260await t . step ( 261"on a touch screen, enter starts a new line and buttons send, steer and follow up" , 262async () => { 263Object . defineProperty ( window . navigator , "maxTouchPoints" , { 264configurable :true , 265value :5 , 266}); 267const sent :[ string , boolean ][] = []; 268send = ( text , followUp ) => { 269sent . push ([ text , followUp ]); 270return Promise . resolve (); 271}; 272const busy = new Signal ( false ); 273const view = open ( "touch" , busy ); 274const press = ( label :string ) => 275([ ...view . element . querySelectorAll ( "button" )] as HTMLButtonElement []) 276. find (( button ) => 277! button . closest ( "[hidden]" ) && button . textContent ?. trim () === label 278) ! 279. click (); 280view . edit ( "first line" ); 281view . key ( "Enter" ); 282await window . happyDOM . waitUntilComplete (); 283assertEquals ( sent , []); 284assertEquals ( view . input . value , "first line" ); 285press ( "send" ); 286await window . happyDOM . waitUntilComplete (); 287busy . set ( true ); 288view . edit ( "later" ); 289const more = view . element . querySelector < HTMLButtonElement >( 290'[aria-label="Other send options"]' , 291) ! ; 292const menu = document . getElementById ( more . getAttribute ( "aria-controls" ) ! ) ! ; 293assertEquals ( more . getAttribute ( "aria-expanded" ), "false" ); 294view . input . focus (); 295more . click (); 296assertEquals ( more . getAttribute ( "aria-expanded" ), "true" ); 297assertEquals ( menu . hidden , false ); 298press ( "follow up" ); 299await window . happyDOM . waitUntilComplete (); 300assertEquals ( more . getAttribute ( "aria-expanded" ), "false" ); 301assertEquals ( menu . hidden , true ); 302assertEquals ( document . activeElement , view . input ); 303view . edit ( "now" ); 304press ( "steer" ); 305await window . happyDOM . waitUntilComplete (); 306assertEquals ( sent , [[ "first line" , false ], [ "later" , true ], [ "now" , false ]]); 307view . leave (); 308Reflect . deleteProperty ( window . navigator , "maxTouchPoints" ); 309}, 310); 311} finally { 312for ( const scope of scopes ) scope . dispose (); 313} 314}); 315});