char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
b4fc6cf
main
1import { assert , assertEquals , assertStringIncludes } from "@std/assert" ; 2import { Signal } from "@char/aftercare" ; 3import { until } from "@berry/util" ; 4import { fill , press } from "@berry/util/dom.ts" ; 5import type { ProjectIndex } from "@berry/api/projects.ts" ; 6import { type Page , withBerry } from "./testing.ts" ; 7 8const projects :ProjectIndex = { 9berry :{ 10name :"Berry" , 11defaultRoot :1 , 12roots :[ 13{ environment :"local" , cwd :"/work/berry" , platform :"linux" }, 14{ environment :"remote" , cwd :"/workspaces/berry" , platform :"linux" }, 15], 16}, 17other :{ 18name :"Other" , 19defaultRoot :0 , 20roots :[{ environment :"local" , cwd :"/work/other" , platform :"linux" }], 21}, 22}; 23 24const environment = { 25name :"environment" , 26description :null , 27status :"loaded" , 28error :null , 29running :true , 30builtin :true , 31alwaysOn :true , 32defaultEnabled :false , 33web :null , 34formerly :[], 35configuration :{ 36properties :{ 37environment :{ type :"string" , default :"local" }, 38cwd :{ type :"string" , default :"" }, 39}, 40}, 41}; 42 43const withPage = ( test :( page :Page ) => Promise < void >) => 44withBerry ( test , { globals :{ confirm :() => true } }); 45 46Deno . test ( "folder autocomplete keeps the input focused, selects by keyboard, and ignores stale machine responses" , async () => { 47await withPage ( async ({ scope, server}) => { 48const { folderInput} = await import ( "./folders.tsx" ); 49const machine = new Signal ( "local" ); 50const pending :(( paths :string []) => void )[] = []; 51const signals :( AbortSignal | undefined )[] = []; 52server . respond (( _ , signal ) => { 53signals . push ( signal ); 54return new Promise (( resolve ) => pending . push ( resolve )); 55}); 56const field = folderInput ({ 57 scope, 58environment :machine , 59base :() => "/base" , 60projects :new Signal ( projects ), 61}); 62document . body . append ( field . element ); 63field . input . focus (); 64fill ( field . input , "be" ); 65assertStringIncludes ( field . element . textContent ! , "Berry" ); 66await until (() => pending . length === 1 ); 67machine . set ( "remote" ); 68await until (() => pending . length === 2 ); 69assertEquals ( 70new URL ( server . requests [ 1 ]. path , "http://berry.test" ). searchParams . get ( "environment" ), 71"remote" , 72); 73assertEquals ( 74new URL ( server . requests [ 1 ]. path , "http://berry.test" ). searchParams . get ( "cwd" ), 75"/base" , 76); 77assertEquals ( signals [ 0 ]?. aborted , true ); 78pending [ 1 ]([ "beta/" , "berry workspace/" ]); 79await until (() => field . element . textContent ! . includes ( "beta/" )); 80pending [ 0 ]([ "wrong-machine/" ]); 81await new Promise (( resolve ) => setTimeout ( resolve , 0 )); 82assertEquals ( field . element . textContent ! . includes ( "wrong-machine" ), false ); 83press ( field . input , "ArrowUp" ); 84press ( field . input , "Enter" ); 85assertEquals ( field . input . value , "berry workspace/" ); 86assertEquals ( document . activeElement , field . input ); 87assertEquals ( field . input . getAttribute ( "aria-expanded" ), "false" ); 88fill ( field . input , "berry" ); 89press ( field . input , "Escape" ); 90assertEquals ( field . input . getAttribute ( "aria-expanded" ), "false" ); 91assertEquals ( field . input . value , "berry" ); 92}); 93}); 94 95Deno . test ( "environment configuration completes the selected machine and commits a machine switch with its reset folder" , async () => { 96await withPage ( async ({ scope, server}) => { 97const { configurationEditor} = await import ( "./configuration.tsx" ); 98const saves :Promise < unknown >[] = []; 99server . respond (({ method}) => 100method === "PATCH" ?{ environment :"remote" , cwd :"/remote-home" } :[ "next/" ] 101); 102const editor = configurationEditor ( 103scope , 104"aaaaaaaaaaaaa" , 105"environment" , 106{ 107properties :{ 108environment :{ type :"string" }, 109cwd :{ type :"string" }, 110}, 111}, 112( save ) => saves . push ( save ), 113{ environment :new Signal ([ "local" , "remote" ]) }, 114new Signal ( projects ), 115); 116const form = editor . element ; 117document . body . append ( form ); 118server . snapshot ( "configuration:aaaaaaaaaaaaa:environment" , { 119environment :"local" , 120cwd :"/work/berry" , 121}); 122const input = form . querySelector < HTMLInputElement >( "input" ) ! ; 123input . focus (); 124fill ( input , "n" ); 125await until (() => form . textContent ! . includes ( "next/" )); 126const query = new URL ( server . requests [ 0 ]. path , "http://berry.test" ). searchParams ; 127assertEquals ( query . get ( "cwd" ), "/work/berry" ); 128press ( input , "ArrowDown" ); 129press ( input , "Enter" ); 130assertEquals ( input . value , "next/" ); 131assertEquals ( saves . length , 0 ); 132server . snapshot ( "configuration:aaaaaaaaaaaaa:environment" , { 133environment :"local" , 134cwd :"next/" , 135}); 136input . blur (); 137await Promise . all ( saves ); 138const select = form . querySelector < HTMLSelectElement >( "select" ) ! ; 139select . focus (); 140select . value = "remote" ; 141select . dispatchEvent ( new Event ( "change" , { bubbles :true })); 142assertEquals ( input . value , "" ); 143select . blur (); 144assertEquals ( saves . length , 0 ); 145await editor . commit (); 146await Promise . all ( saves ); 147assertEquals ( server . requests . at ( - 1 )?. body , { environment :"remote" , cwd :"" }); 148assertEquals ( input . value , "/remote-home" ); 149}); 150}); 151 152Deno . test ( "a list configuration keeps answers no longer offered and stages its boxes until committed" , async () => { 153await withPage ( async ({ scope, server}) => { 154const { configurationEditor} = await import ( "./configuration.tsx" ); 155const saves :Promise < unknown >[] = []; 156server . respond (({ body}) => body ); 157const editor = configurationEditor ( 158scope , 159"aaaaaaaaaaaaa" , 160"subagent" , 161{ properties :{ models :{ type :"array" , items :{ type :"string" }, default :[] } } }, 162( save ) => saves . push ( save ), 163{ models :new Signal ([ "faux/a" , "faux/b" ]) }, 164); 165const form = editor . element ; 166document . body . append ( form ); 167server . snapshot ( "configuration:aaaaaaaaaaaaa:subagent" , { models :[ "faux/gone" ] }); 168const boxes = () => [ ...form . querySelectorAll < HTMLInputElement >( "input[type=checkbox]" )]; 169assertEquals ( boxes (). map (( box ) => [ box . value , box . checked ]), [ 170[ "faux/a" , false ], 171[ "faux/b" , false ], 172[ "faux/gone" , true ], 173]); 174const [ a , b ] = boxes (); 175a . focus (); 176a . click (); 177b . focus (); 178assertEquals ( saves . length , 0 ); 179b . blur (); 180assertEquals ( saves . length , 0 ); 181await editor . commit (); 182await Promise . all ( saves ); 183assertEquals ( server . requests . at ( - 1 )?. method , "PATCH" ); 184assertEquals ( server . requests . at ( - 1 )?. body , { models :[ "faux/a" , "faux/gone" ] }); 185}); 186}); 187 188Deno . test ( "project presets use their default workspace and preserve an explicitly chosen workspace on refresh" , async () => { 189await withPage ( async ({ scope}) => { 190const { projectPicker} = await import ( "./projects.tsx" ); 191const index = new Signal ( projects ); 192const picked :unknown [] = []; 193const picker = projectPicker ( scope , index , ( root ) => picked . push ( root )); 194document . body . append ( picker . element ); 195picker . pick ( "berry" ); 196assertEquals ( picked . at ( - 1 ), projects . berry . roots [ 1 ]); 197const workspace = document . querySelector < HTMLSelectElement >( 'select[aria-label="workspace"]' ) ! ; 198workspace . value = "0" ; 199workspace . dispatchEvent ( new Event ( "change" )); 200assertEquals ( picked . at ( - 1 ), projects . berry . roots [ 0 ]); 201index . set ({ ...projects , berry :{ ...projects . berry , name :"Renamed" } }); 202assertEquals ( workspace . value , "0" ); 203picker . pick ( "" ); 204assertEquals ( picked . at ( - 1 ), null ); 205}); 206}); 207 208Deno . test ( "sidebar groups independent workspaces, retains misc, and separates cross-project forks without losing ancestry" , async () => { 209await withPage ( async ({ scope, server}) => { 210const { app} = await import ( "./app.tsx" ); 211document . body . append ( app ( scope )); 212server . snapshot ( "settings" , { models :[], environments :{ remote :{ ssh :"remote" } } }); 213server . snapshot ( "extensions" , [ environment ]); 214const parent = "aaaaaaaaaaaaa" , 215same = "bbbbbbbbbbbbb" , 216cross = "ccccccccccccc" , 217misc = "ddddddddddddd" ; 218const info = { id :1 , created :1 }; 219server . snapshot ( "conversations" , { 220[ parent ] :info , 221[ same ] :{ ...info , id :2 , parent :{ tid :parent , at :1 } }, 222[ cross ] :{ ...info , id :3 , parent :{ tid :parent , at :1 } }, 223[ misc ] :{ ...info , id :4 }, 224}); 225const summary = { busy :false , preview :"hello" , unread :false , updated :1 }; 226server . snapshot ( "summaries" , { 227[ parent ] :{ ...summary , environment :"local" , cwd :"/work/berry/src" }, 228[ same ] :{ ...summary , environment :"remote" , cwd :"/workspaces/berry" }, 229[ cross ] :{ ...summary , environment :"local" , cwd :"/work/other" }, 230[ misc ] :{ ...summary , environment :"local" , cwd :"/misc" }, 231}); 232server . snapshot ( "projects" , projects ); 233const link = ( tid :string ) => 234document . querySelector < HTMLAnchorElement >( `.item > a:not(.parent-link)[href="#/ ${ tid } "]` ); 235assert ( link ( parent )); 236assert ( link ( cross )); 237assert ( link ( misc )); 238assertEquals ( link ( same ), null ); 239link ( parent ) ! . parentElement ! . querySelector < HTMLButtonElement >( ".chevron" ) ! . click (); 240assert ( link ( same )); 241assertEquals ( 242link ( cross ) ! . parentElement ! . querySelector < HTMLAnchorElement >( ".parent-link" ) 243?. getAttribute ( "href" ), 244`#/ ${ parent } ` , 245); 246const berryGroup = document . querySelector < HTMLElement >( '[data-project="berry"]' ) ! ; 247berryGroup . querySelector < HTMLButtonElement >( ".project-toggle" ) ! . click (); 248assertEquals ( link ( parent ), null ); 249assertEquals ( link ( same ), null ); 250assert ( link ( cross )); 251assert ( link ( misc )); 252server . snapshot ( "projects" , { other :projects . other }); 253assert ( link ( parent )); 254assert ( link ( same )); 255server . snapshot ( "projects" , projects ); 256document . querySelector < HTMLButtonElement >( '[aria-label="New conversation in Berry"]' ) ! . click (); 257await until (() => document . querySelector ( ".start-environment input" ) !== null ); 258assertEquals ( location . hash , "#/new/berry" ); 259assertEquals ( 260document . querySelector < HTMLSelectElement >( ".start-environment select" ) ! . value , 261"remote" , 262); 263assertEquals ( 264document . querySelector < HTMLInputElement >( ".start-environment input" ) ! . value , 265"/workspaces/berry" , 266); 267}); 268}); 269 270Deno . test ( "sidebar groups, no project included, keep their saved order and move by dragging or the editor's arrows" , async () => { 271await withPage ( async ({ scope, server}) => { 272const { app} = await import ( "./app.tsx" ); 273document . body . append ( app ( scope )); 274server . snapshot ( "projects" , projects ); 275server . snapshot ( "project-order" , { ids :[ "gone" , "other" ] }); 276server . snapshot ( "conversations" , { aaaaaaaaaaaaa :{ id :1 , created :1 } }); 277const groups = () => 278[ ...document . querySelectorAll < HTMLElement >( "[data-project]" )]. map (( node ) => 279node . dataset . project 280); 281assertEquals ( groups (), [ "other" , "berry" , "" ]); 282const drag = ( type :string , id :string ) => 283document . querySelector ( `[data-project=" ${ id } "] .project-toggle` ) ! 284. dispatchEvent ( new Event ( type , { bubbles :true , cancelable :true })); 285drag ( "dragstart" , "" ); 286drag ( "dragover" , "other" ); 287drag ( "drop" , "other" ); 288drag ( "dragend" , "" ); 289await until (() => groups ()[ 0 ] === "" ); 290assertEquals ( server . changes (). at ( - 1 )?. body , { ids :[ "" , "other" , "berry" ] }); 291 292document . querySelector < HTMLButtonElement >( '[aria-label="Edit no project"]' ) ! . click (); 293const up = document . querySelector < HTMLButtonElement >( '[aria-label="move up"]' ) ! ; 294const down = document . querySelector < HTMLButtonElement >( '[aria-label="move down"]' ) ! ; 295assert ( up . disabled ); 296down . click (); 297await until (() => groups ()[ 1 ] === "" ); 298assertEquals ( server . changes (). at ( - 1 )?. body , { ids :[ "other" , "" , "berry" ] }); 299assert ( ! up . disabled ); 300}); 301}); 302 303Deno . test ( "a folder typed for a new conversation shows the project and workspace it is in" , async () => { 304await withPage ( async ({ scope, server}) => { 305const { app} = await import ( "./app.tsx" ); 306location . hash = "#/new" ; 307document . body . append ( app ( scope )); 308server . snapshot ( "settings" , { models :[], environments :{ remote :{ ssh :"remote" } } }); 309server . snapshot ( "extensions" , [ environment ]); 310server . snapshot ( "projects" , projects ); 311await until (() => document . querySelector ( ".start-environment input" ) !== null ); 312const folder = document . querySelector < HTMLInputElement >( ".start-environment input" ) ! ; 313const project = document . querySelector < HTMLSelectElement >( 'select[aria-label="project"]' ) ! ; 314const workspace = document . querySelector < HTMLSelectElement >( 'select[aria-label="workspace"]' ) ! ; 315fill ( folder , "/work/berry/src" ); 316assertEquals ([ project . value , workspace . value ], [ "berry" , "0" ]); 317fill ( folder , "/elsewhere" ); 318assertEquals ( project . value , "" ); 319}); 320}); 321 322Deno . test ( "project editor saves multiple workspaces and a default without modifying conversations" , async () => { 323await withPage ( async ({ scope, server}) => { 324const { projectEditor} = await import ( "./projects.tsx" ); 325server . respond (() => ({ id :"saved" })); 326const editor = projectEditor ( scope , new Signal ([ "local" , "remote" ]), new Signal ( projects )); 327document . body . append ( editor . element ); 328editor . open ( "berry" ); 329fill ( editor . element . querySelector < HTMLInputElement >( "input[maxlength]" ) ! , "Renamed" ); 330editor . element . querySelector < HTMLButtonElement >( 'button[type="submit"]' ) ! . click (); 331await until (() => ! editor . element . open ); 332assertEquals ( server . requests . at ( - 1 ), { 333path :"/api/projects/berry" , 334method :"PUT" , 335body :{ 336name :"Renamed" , 337defaultRoot :1 , 338roots :projects . berry . roots . map (({ environment, cwd}) => ({ environment, cwd})), 339}, 340}); 341editor . open ( "berry" ); 342const remove = [ ...editor . element . querySelectorAll < HTMLButtonElement >( "button" )]. find (( 343button , 344) => button . textContent === "delete project" ) ! ; 345remove . click (); 346await until (() => ! editor . element . open ); 347assertEquals ( server . requests . at ( - 1 )?. method , "DELETE" ); 348assertEquals ( 349server . requests . filter (( request ) => request . path . includes ( "/conversations" )). length , 3500 , 351); 352editor . open (); 353fill ( editor . element . querySelector < HTMLInputElement >( "input[maxlength]" ) ! , "New project" ); 354fill ( editor . element . querySelector < HTMLInputElement >( "input[role=combobox]" ) ! , "/new/main" ); 355[ ...editor . element . querySelectorAll < HTMLButtonElement >( "button" )]. find (( button ) => 356button . textContent === "add workspace" 357) ! . click (); 358const second = editor . element . querySelectorAll < HTMLFieldSetElement >( ".project-root" )[ 1 ]; 359const machine = second . querySelector < HTMLSelectElement >( "select" ) ! ; 360machine . value = "remote" ; 361machine . dispatchEvent ( new Event ( "change" )); 362fill ( second . querySelector < HTMLInputElement >( "input[role=combobox]" ) ! , "/new/feature" ); 363second . querySelector < HTMLInputElement >( 'input[type="radio"]' ) ! . click (); 364editor . element . querySelector < HTMLButtonElement >( 'button[type="submit"]' ) ! . click (); 365await until (() => ! editor . element . open ); 366assertEquals ( server . requests . at ( - 1 )?. body , { 367name :"New project" , 368defaultRoot :1 , 369roots :[ 370{ environment :"local" , cwd :"/new/main" }, 371{ environment :"remote" , cwd :"/new/feature" }, 372], 373}); 374assertEquals ( server . requests . at ( - 1 )?. method , "POST" ); 375}); 376}); 377 378Deno . test ( "workspace removal offers an opt-in move on save and resets it when reopening" , async () => { 379await withPage ( async ({ scope, server}) => { 380const { projectEditor} = await import ( "./projects.tsx" ); 381server . respond (() => ({ id :"saved" })); 382const editor = projectEditor ( scope , new Signal ([ "local" , "remote" ]), new Signal ( projects )); 383document . body . append ( editor . element ); 384editor . open ( "berry" ); 385const move = editor . element . querySelector < HTMLInputElement >( 'input[type="checkbox"]' ) ! ; 386assert ( move . closest ( "label" ) ! . hidden ); 387editor . element . querySelector < HTMLButtonElement >( '[aria-label="remove workspace 2"]' ) ! . click (); 388assert ( ! move . closest ( "label" ) ! . hidden ); 389assert ( ! move . checked ); 390assertEquals ( server . requests . length , 0 ); 391move . click (); 392editor . element . querySelector < HTMLButtonElement >( 'button[type="submit"]' ) ! . click (); 393await until (() => ! editor . element . open ); 394assertEquals ( server . requests . at ( - 1 )?. body , { 395name :"Berry" , 396roots :[{ environment :"local" , cwd :"/work/berry" }], 397defaultRoot :0 , 398moveConversations :true , 399}); 400 401editor . open ( "berry" ); 402assert ( ! move . checked ); 403editor . element . querySelector < HTMLButtonElement >( '[aria-label="remove workspace 1"]' ) ! . click (); 404editor . element . querySelector < HTMLButtonElement >( 'button[type="submit"]' ) ! . click (); 405await until (() => ! editor . element . open ); 406assertEquals ( server . requests . at ( - 1 )?. body , { 407name :"Berry" , 408roots :[{ environment :"remote" , cwd :"/workspaces/berry" }], 409defaultRoot :0 , 410}); 411 412editor . open ( "berry" ); 413editor . element . querySelector < HTMLButtonElement >( '[aria-label="remove workspace 2"]' ) ! . click (); 414move . click (); 415[ ...editor . element . querySelectorAll < HTMLButtonElement >( "button" )]. find (( button ) => 416button . textContent === "cancel" 417) ! . click (); 418assertEquals ( server . requests . length , 2 ); 419editor . open ( "berry" ); 420assert ( ! move . checked ); 421assert ( move . closest ( "label" ) ! . hidden ); 422}); 423});