char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
0575637
main
1import { assert , assertEquals , assertStringIncludes } from "@std/assert" ; 2import { existsSync } from "node:fs" ; 3import type { Channel , ChannelMessage , Environment } from "@berry/api" ; 4import { localEnvironment } from "@berry/api/environment.ts" ; 5import { testExtension } from "@berry/api/testing.ts" ; 6import { tempDir , until } from "@berry/util" ; 7import { dirname , join } from "node:path" ; 8import terminal from "./mod.ts" ; 9import { Ghostty } from "./ghostty.ts" ; 10import { OUTPUT , SNAPSHOT } from "./protocol.ts" ; 11 12const WASM = new URL ( "./ghostty-vt.wasm" , import . meta. url ); 13const ready = existsSync ( WASM ) && Deno . build . os === "linux" ; 14 15/** This machine, with a plain shell rather than whatever logs in here. */ 16function plainShell () :Environment { 17const local = localEnvironment ( Deno . cwd ()); 18return new Proxy ( local , { 19get ( target , property ) { 20if ( property === "variables" ) return { ...target . variables , SHELL :"/bin/sh" , ENV :"" }; 21const value = Reflect . get ( target , property , target ); 22return typeof value === "function" ?value . bind ( target ) :value ; 23}, 24}); 25} 26 27/** A viewer: everything the channel has said, and a way to wait for what it will. */ 28function viewer ( channel :Channel ) { 29const said :ChannelMessage [] = []; 30let heard = () => {}; 31void ( async () => { 32for await ( const message of channel . messages ) { 33said . push ( message ); 34heard (); 35} 36})(); 37const output = () => 38new TextDecoder (). decode ( 39new Uint8Array ( 40said . flatMap (( m ) => m instanceof Uint8Array && m [ 0 ] === OUTPUT ?[ ...m . subarray ( 1 )] :[]), 41), 42); 43return { 44 said, 45 output, 46async until ( done :() => boolean ) { 47while ( ! done ()) await new Promise < void >(( resolve ) => heard = resolve ); 48}, 49type :( text :string ) => channel . send ( new TextEncoder (). encode ( text )), 50close :() => channel . close (), 51}; 52} 53 54const isSnapshot = ( m :ChannelMessage ) => m instanceof Uint8Array && m [ 0 ] === SNAPSHOT ; 55 56Deno . test ({ 57name :"a shell starts when asked, and its screen outlives its viewers until it exits" , 58ignore :! ready , 59async fn () { 60const tested = testExtension ( terminal , { env :plainShell () }); 61try { 62const first = viewer ( tested . connect ( "session" )); 63await first . until (() => first . said . length > 0 ); 64assertEquals ( first . said , [{ t :"none" }]); 65tested . connect ( "session" ). send ({ t :"start" , cols :40 , rows :10 }); 66await first . until (() => first . said . some ( isSnapshot )); 67first . type ( "echo marked $((6 * 7))\r" ); 68await first . until (() => first . output (). includes ( "marked 42" )); 69first . close (); 70 71// whoever comes later sees the screen so far, scrollback and all 72const later = viewer ( tested . connect ( "session" )); 73await later . until (() => later . said . some ( isSnapshot )); 74const snapshot = later . said . find ( isSnapshot ) as Uint8Array ; 75const ghostty = await Ghostty . load ( await Deno . readFile ( WASM )); 76assertStringIncludes ( ghostty . restore ( snapshot . subarray ( 1 )). text (), "marked 42" ); 77 78later . type ( "exit 3\r" ); 79await later . until (() => later . said . some (( m ) => ( m as { t ?:string }). t === "exit" )); 80assertEquals ( later . said . at ( - 1 ), { t :"exit" , code :3 }); 81 82// nothing starts again on its own: the last screen stays, with how it ended 83const after = viewer ( tested . connect ( "session" )); 84await after . until (() => after . said . length >= 2 ); 85assert ( isSnapshot ( after . said [ 0 ])); 86assertEquals ( after . said [ 1 ], { t :"exit" , code :3 }); 87} finally { 88await tested . dispose (); 89} 90}, 91}); 92 93Deno . test ({ 94name :"a resize reaches the shell and every viewer, and one start is one shell" , 95ignore :! ready , 96async fn () { 97const tested = testExtension ( terminal , { env :plainShell () }); 98try { 99const one = viewer ( tested . connect ( "session" )); 100const two = viewer ( tested . connect ( "session" )); 101tested . connect ( "session" ). send ({ t :"start" , cols :40 , rows :10 }); 102tested . connect ( "session" ). send ({ t :"start" , cols :40 , rows :10 }); 103await one . until (() => one . said . some ( isSnapshot )); 104tested . connect ( "session" ). send ({ t :"resize" , cols :60 , rows :20 }); 105await two . until (() => two . said . some (( m ) => ( m as { t ?:string }). t === "size" )); 106one . type ( "stty size\r" ); 107await one . until (() => one . output (). includes ( "20 60" )); 108assertEquals ( one . said . filter ( isSnapshot ). length , 1 ); 109} finally { 110await tested . dispose (); 111} 112}, 113}); 114 115Deno . test ({ 116name :"a shell's last output precedes its exit and a replacement shell" , 117ignore :! existsSync ( WASM ), 118async fn () { 119const env = localEnvironment ( Deno . cwd ()); 120let output ! :ReadableStreamDefaultController < Uint8Array >; 121let starts = 0 ; 122env . pty = () => 123Promise . resolve ({ 124readable :new ReadableStream ({ 125start ( controller ) { 126if ( ++ starts === 1 ) output = controller ; 127else controller . close (); 128}, 129}), 130writable :new WritableStream (), 131exited :Promise . resolve ( 0 ), 132kill () {}, 133resize () {}, 134}); 135const tested = testExtension ( terminal , { env}); 136let ended = false ; 137try { 138const channel = tested . connect ( "session" ); 139const view = viewer ( channel ); 140channel . send ({ t :"start" , cols :80 , rows :24 }); 141await until (() => view . said . some ( isSnapshot )); 142// output completion, not the former grace timer, must gate replacement 143await new Promise (( resolve ) => setTimeout ( resolve , 350 )); 144assertEquals ( view . said . some (( m ) => ( m as { t ?:string }). t === "exit" ), false ); 145output . enqueue ( new TextEncoder (). encode ( "farewell" )); 146output . close (); 147ended = true ; 148await until (() => view . said . some (( m ) => ( m as { t ?:string }). t === "exit" )); 149assertStringIncludes ( view . output (), "farewell" ); 150assertEquals ( view . said . at ( - 1 ), { t :"exit" , code :0 }); 151channel . send ({ t :"start" , cols :80 , rows :24 }); 152await until (() => starts === 2 ); 153} finally { 154if ( output && ! ended ) output . close (); 155await tested . dispose (); 156} 157}, 158}); 159 160Deno . test ({ 161name :"disposing a terminal drains output after hangup before freeing its screen" , 162ignore :! existsSync ( WASM ), 163async fn () { 164const env = localEnvironment ( Deno . cwd ()); 165let output ! :ReadableStreamDefaultController < Uint8Array >; 166const exited = Promise . withResolvers < number | null >(); 167env . pty = () => 168Promise . resolve ({ 169readable :new ReadableStream ({ start :( controller ) => void ( output = controller ) }), 170writable :new WritableStream (), 171exited :exited . promise , 172kill () { 173output . enqueue ( new TextEncoder (). encode ( "farewell" )); 174exited . resolve ( null ); 175}, 176resize () {}, 177}); 178const tested = testExtension ( terminal , { env}); 179let disposing :Promise < void > | undefined ; 180try { 181const channel = tested . connect ( "session" ); 182const view = viewer ( channel ); 183channel . send ({ t :"start" , cols :80 , rows :24 }); 184await until (() => view . said . some ( isSnapshot )); 185let disposed = false ; 186disposing = tested . dispose (). then (() => { 187disposed = true ; 188}); 189await until (() => view . output (). includes ( "farewell" )); 190assertEquals ( disposed , false ); 191} finally { 192disposing ??= tested . dispose (); 193output ?. close (); 194await disposing ; 195} 196}, 197}); 198 199Deno . test ({ 200name :"Windows terminals launch an interactive PowerShell on the target, preferring PS7 to PS5" , 201ignore :! existsSync ( WASM ), 202async fn () { 203await usingroot = await tempDir (); 204const binaries = [ 205"bin dir/pwsh.EXE" , 206"program files/PowerShell/7/pwsh.exe" , 207"legacy/powershell.EXE" , 208"windows/System32/WindowsPowerShell/v1.0/powershell.exe" , 209]. map (( path ) => join ( root . path , path )); 210for ( const path of binaries ) { 211await Deno . mkdir ( dirname ( path ), { recursive :true }); 212await Deno . writeTextFile ( path , "" ); 213} 214const invocations :( readonly string [])[] = []; 215const env = localEnvironment ( root . path ); 216Object . defineProperties ( env , { 217platform :{ value :"win32" }, 218variables :{ 219value :{ 220pAtH :`" ${ dirname ( binaries [ 0 ])} "; ${ dirname ( binaries [ 2 ])} ` , 221PaThExT :".EXE" , 222ProgramFiles :join ( root . path , "program files" ), 223SystemRoot :join ( root . path , "windows" ), 224SHELL :"/not/a/windows/shell" , 225}, 226}, 227}); 228env . pty = ( argv ) => { 229invocations . push ( argv ); 230return Promise . resolve ({ 231readable :new ReadableStream ({ start :( controller ) => controller . close () }), 232writable :new WritableStream (), 233exited :Promise . resolve ( 0 ), 234kill () {}, 235resize () {}, 236}); 237}; 238const tested = testExtension ( terminal , { env}); 239try { 240const channel = tested . connect ( "session" ); 241const view = viewer ( channel ); 242await until (() => view . said . length > 0 ); 243for ( const binary of binaries ) { 244view . said . length = 0 ; 245channel . send ({ t :"start" , cols :80 , rows :24 }); 246await until (() => view . said . some (( m ) => ( m as { t ?:string }). t === "exit" )); 247assertEquals ( invocations . at ( - 1 ), [ binary , "-NoLogo" ]); 248await Deno . remove ( binary ); 249} 250channel . send ({ t :"start" , cols :80 , rows :24 }); 251await until (() => view . said . some (( m ) => ( m as { t ?:string }). t === "error" )); 252assertEquals ( invocations . length , 4 ); 253} finally { 254await tested . dispose (); 255} 256}, 257});