char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
80904d5
main
1import { assert , assertEquals } from "@std/assert" ; 2import { EditorView } from "@codemirror/view" ; 3import { redo , undo } from "@codemirror/commands" ; 4import { until } from "@berry/util" ; 5import type { Save , Saved } from "./edit-types.ts" ; 6import type { JsonValue } from "@berry/api" ; 7import type { View , ViewContext } from "@berry/api/web.ts" ; 8import { fill , press , viewContext , withDom as dom } from "@berry/util/dom.ts" ; 9import { fileKey , sending } from "./model.ts" ; 10import type { FileDiff , Reply , Revision } from "./types.ts" ; 11import { diffView } from "./view.tsx" ; 12import { recoverClosed } from "./drafts.ts" ; 13 14const withDom :typeof dom = ( test , options = {}) => 15dom ( test , { ...options , globals :{ IntersectionObserver :undefined , ...options . globals } }); 16 17const revision = ( route :string , description :string ) :Revision => ({ 18 route, 19change :{ 20full :( route || "kpnvutls" ). padEnd ( 32 , "k" ), 21prefix :route ?route . slice ( 0 , 2 ) :"kp" , 22rest :route ?route . slice ( 2 , 8 ) :"nvutls" , 23}, 24commit :{ full :"74ea55bb" . repeat ( 5 ), prefix :"7" , rest :"4ea55bb" }, 25 description, 26author :{ name :"Test" , "test@example.com" , timestamp :"2026-01-01T00:00:00Z" }, 27refs :[], 28workingCopy :route === "" , 29empty :false , 30immutable :false , 31conflict :false , 32}); 33 34const LOG = [ revision ( "" , "" ), revision ( "vvkvpomk" , "subscription tweaks\n" )]; 35 36const modified = ( lines :string []) :FileDiff => ({ 37path :"src/total.ts" , 38from :null , 39status :"modified" , 40modes :{ old :"100644" , new :"100644" }, 41binary :false , 42hunks :[{ old :1 , new :1 , lines}], 43}); 44 45const LONG = [ 46 ...Array . from ({ length :12 }, ( _ , i ) => ` line ${ i + 1 } ` ), 47"-const total = a + b;" , 48"+const sum = a + b;" , 49" end" , 50]; 51 52function fixture ( 53files :( route :string ) => FileDiff [], 54{ vcs= "jj" , log= LOG } :{ vcs ?:Reply [ "vcs" ]; log ?:Revision [] } = {}, 55) { 56const calls :{ route :string ; limit :number ; known :string [] }[] = []; 57const context = viewContext ({ 58latest :1 , 59href :( route ) => `#/3mxd6epszps5d/diff/ ${ route } ` , 60request ( name , args ) { 61assertEquals ( name , "show" ); 62const { route, limit, known} = args as { route :string ; limit :number ; known :string [] }; 63calls . push ({ route, limit, known}); 64const reply :Reply = { 65 vcs, 66root :"/repo" , 67 log, 68revision :log . find (( r ) => r . route === route ) ! , 69files :sending ( files ( route ), known ), 70omitted :0 , 71}; 72return Promise . resolve ( reply as unknown as JsonValue ); 73}, 74}); 75return { calls, context}; 76} 77 78const settle = () => new Promise (( resolve ) => setTimeout ( resolve , 0 )); 79 80async function open ( context :ViewContext ) :Promise < View > { 81const view = diffView (); 82document . body . append ( view . element ); 83view . update ( context ); 84await settle (); 85return view ; 86} 87 88const text = ( element :Element , selector :string ) => 89[ ...element . querySelectorAll ( selector )]. map (( node ) => node . textContent ); 90 91Deno . test ( "the view shows the working copy and its history, and follows links and new entries" , () => 92withDom ( async () => { 93const working = modified ([ " a" , "-b" , "+c" ]); 94const { calls, context} = fixture (( route ) => 95route === "" ?[ working ] :[{ ...modified ([ "+x" ]), status :"added" }] 96); 97const view = await open ( context ); 98const links = [ ...view . element . querySelectorAll < HTMLAnchorElement >( ".diff-log a" )]; 99assertEquals ( links . map (( link ) => link . getAttribute ( "href" )), [ 100"#/3mxd6epszps5d/diff/" , 101"#/3mxd6epszps5d/diff/vvkvpomk" , 102]); 103assertEquals ( links . map (( link ) => link . getAttribute ( "aria-current" )), [ "page" , null ]); 104assertEquals ( text ( view . element , ".path" ), [ "src/total.ts" ]); 105assertEquals ( text ( view . element , "table.diff td.text" ), [ "a" , "b" , "c" ]); 106 107// a new entry asks again, saying which changes the view has, which it keeps drawn 108const table = view . element . querySelector ( "table.diff" ); 109view . update ({ ...context , latest :2 }); 110await settle (); 111assertEquals ( view . element . querySelector ( "table.diff" ), table ); 112assertEquals ([ ...view . element . querySelectorAll ( ".diff-log a" )], links ); 113view . update ({ ...context , latest :2 , route :"vvkvpomk" }); 114await settle (); 115assertEquals ( calls , [ 116{ route :"" , limit :100 , known :[] }, 117{ route :"" , limit :100 , known :[ fileKey ( working )] }, 118{ route :"vvkvpomk" , limit :100 , known :[ fileKey ( working )] }, 119]); 120assertEquals ( 121[ ...view . element . querySelectorAll ( ".diff-log a" )]. map (( a ) => a . getAttribute ( "aria-current" )), 122[ null , "page" ], 123); 124assertEquals ( text ( view . element , ".commit-subject" ), [ "subscription tweaks" ]); 125assertEquals ( text ( view . element , "table.diff td.text" ), [ "x" ]); 126view . dispose ?.(); 127})); 128 129Deno . test ( "descriptions reword in place, keeping drafts across redraws and failed saves" , () => 130withDom ( async () => { 131const log = [ revision ( "" , "" ), revision ( "vvkvpomk" , "subject\n\nfull body\n" )]; 132const { context :base } = fixture (() => [], { log}); 133let reject = true ; 134const calls :JsonValue [] = []; 135const context :ViewContext = { 136 ...base , 137route :log [ 1 ]. route , 138async request ( name , args , options ) { 139if ( name !== "reword" ) return await base . request ( name , args , options ); 140calls . push ( args ! ); 141if ( reject ) throw new Error ( "description changed elsewhere" ); 142log [ 1 ] = { ...log [ 1 ], description :( args as { description :string }). description }; 143return null ; 144}, 145}; 146const view = await open ( context ); 147try { 148view . element . querySelector ( ".commit-body" ) ! . dispatchEvent ( 149new MouseEvent ( "dblclick" , { bubbles :true }), 150); 151const input = view . element . querySelector < HTMLTextAreaElement >( ".diff-head textarea" ) ! ; 152assertEquals ( input . value , "subject\n\nfull body\n" ); 153assertEquals ( view . element . querySelector ( ".commit-subject" ), null ); 154fill ( input , "new subject\n\nnew body" ); 155view . update ({ ...context , latest :2 }); 156await settle (); 157assertEquals ( view . element . querySelector ( ".diff-head textarea" ), input ); 158assertEquals ( input . value , "new subject\n\nnew body" ); 159assertEquals ( document . activeElement , input ); 160 161const save = view . element . querySelector < HTMLButtonElement >( ".commit-reword button" ) ! ; 162save . click (); 163await settle (); 164assert ( 165view . element . querySelector ( '[role="alert"]' ) ! . textContent ! . includes ( "changed elsewhere" ), 166); 167assertEquals ( input . value , "new subject\n\nnew body" ); 168assertEquals ( save . disabled , false ); 169reject = false ; 170press ( input , "Enter" , { ctrlKey :true }); 171await settle (); 172assertEquals ( calls . length , 2 ); 173assertEquals ( calls [ 1 ], { 174root :"/repo" , 175change :log [ 1 ]. change ! . full , 176before :"subject\n\nfull body\n" , 177description :"new subject\n\nnew body" , 178}); 179assertEquals ( view . element . querySelector ( "textarea" ), null ); 180assertEquals ( text ( view . element , ".commit-subject" ), [ "new subject" ]); 181assertEquals ( text ( view . element , ".commit-body" ), [ "new body" ]); 182 183press ( view . element . querySelector ( ".commit-subject" ) ! , "F2" ); 184const again = view . element . querySelector < HTMLTextAreaElement >( ".diff-head textarea" ) ! ; 185assertEquals ( again . value , "new subject\n\nnew body" ); 186press ( again , "Escape" ); 187assertEquals ( view . element . querySelector ( "textarea" ), null ); 188assertEquals ( calls . length , 2 ); 189} finally { 190view . dispose ?.(); 191} 192})); 193 194Deno . test ( "a description draft is thrown away on navigation or when the description changes" , () => 195withDom ( async () => { 196const log = [ revision ( "" , "" ), revision ( "vvkvpomk" , "original\n" )]; 197const { context :base } = fixture (() => [], { log}); 198const context = { ...base , route :log [ 1 ]. route }; 199const view = await open ( context ); 200const edit = () => { 201view . element . querySelector ( ".commit-subject" ) ! . dispatchEvent ( new MouseEvent ( "dblclick" )); 202return view . element . querySelector < HTMLTextAreaElement >( ".diff-head textarea" ) ! ; 203}; 204fill ( edit (), "draft" ); 205view . update ({ ...context , route :"" }); 206assertEquals ( view . element . querySelector ( "textarea" ), null ); 207await settle (); 208view . update ( context ); 209await settle (); 210assertEquals ( edit (). value , "original\n" ); 211 212log [ 1 ] = { ...log [ 1 ], description :"edited elsewhere\n" }; 213view . update ({ ...context , latest :2 }); 214await settle (); 215assertEquals ( view . element . querySelector ( "textarea" ), null ); 216assertEquals ( text ( view . element , ".commit-subject" ), [ "edited elsewhere" ]); 217view . dispose ?.(); 218})); 219 220Deno . test ( "the index counts each file's lines and totals separate generated edits across refreshes" , () => 221withDom ( async () => { 222const source = { 223 ...modified ([]), 224hunks :[ 225{ old :1 , new :1 , lines :[ " context" , "-old" , "+new" , "\\ No newline at end of file" ] }, 226{ old :20 , new :20 , lines :[ "+extra" ] }, 227], 228}; 229let lock = { ...modified ([ "-old" , "+a" , "+b" , "+c" ]), path :"nested/Cargo.lock" }; 230const binary = { ...modified ([]), path :"image.png" , binary :true }; 231const { context} = fixture (() => [ source , lock , binary ]); 232const view = await open ( context ); 233try { 234assertEquals ( text ( view . element , ".diff-index .add-count" ), [ "+2" , "+3" , "+0" ]); 235assertEquals ( text ( view . element , ".diff-index .del-count" ), [ "-1" , "-1" , "-0" ]); 236assertEquals ( text ( view . element , ".diff-toolbar .add-count" ), [ "+2 (+3)" ]); 237assertEquals ( text ( view . element , ".diff-toolbar .del-count" ), [ "-1 (-1)" ]); 238 239lock = { ...lock , hunks :modified ([ "+a" ]). hunks }; 240view . update ({ ...context , latest :2 }); 241await settle (); 242assertEquals ( text ( view . element , ".diff-index .add-count" ), [ "+2" , "+1" , "+0" ]); 243assertEquals ( text ( view . element , ".diff-index .del-count" ), [ "-1" , "-0" , "-0" ]); 244assertEquals ( text ( view . element , ".diff-toolbar .add-count" ), [ "+2 (+1)" ]); 245assertEquals ( text ( view . element , ".diff-toolbar .del-count" ), [ "-1" ]); 246} finally { 247view . dispose ?.(); 248} 249})); 250 251Deno . test ( "an unchanged refresh preserves the heading, index focus, and diff rows" , () => 252withDom ( async () => { 253const { context} = fixture (() => [ modified ( LONG )]); 254const view = await open ( context ); 255try { 256const subject = view . element . querySelector ( ".commit-subject" ); 257const index = view . element . querySelector ( ".diff-index" ); 258const table = view . element . querySelector ( "table.diff" ); 259const link = index ! . querySelector < HTMLButtonElement >( "button" ) ! ; 260link . focus (); 261view . update ({ ...context , latest :2 }); 262assertEquals ( view . element . classList . contains ( "loading" ), false ); 263assert ( view . element . querySelector ( ".diff-head" ) ! . classList . contains ( "loading" )); 264await settle (); 265assertEquals ( view . element . querySelector ( ".commit-subject" ), subject ); 266assertEquals ( view . element . querySelector ( ".diff-index" ), index ); 267assertEquals ( view . element . querySelector ( "table.diff" ), table ); 268assertEquals ( document . activeElement , link ); 269} finally { 270view . dispose ?.(); 271} 272})); 273 274Deno . test ( "unchanged stretches unfold on request, and changed words are marked" , () => 275withDom ( async () => { 276const { context} = fixture (() => [ modified ( LONG )]); 277const view = await open ( context ); 278const fold = view . element . querySelector < HTMLButtonElement >( ".fold" ) ! ; 279assertEquals ( text ( view . element , "table.diff td.text" ). length , 6 ); 280fold . click (); 281assertEquals ( text ( view . element , "table.diff td.text" ). length , 15 ); 282assertEquals ( text ( view . element , "mark.word" ), [ "total" , "sum" ]); 283view . dispose ?.(); 284})); 285 286Deno . test ( "a viewed file folds away, and stays viewed until it changes again" , () => 287withDom ( async () => { 288let lines = LONG ; 289const { context} = fixture (() => [ modified ( lines )]); 290const first = await open ( context ); 291const box = first . element . querySelector < HTMLInputElement >( ".viewed input" ) ! ; 292box . click (); 293assert ( first . element . querySelector < HTMLElement >( ".file-body" ) ! . hidden ); 294first . dispose ?.(); 295 296const again = await open ( context ); 297assert ( again . element . querySelector < HTMLInputElement >( ".viewed input" ) ! . checked ); 298assert ( again . element . querySelector < HTMLElement >( ".file-body" ) ! . hidden ); 299again . dispose ?.(); 300 301lines = [ ...LONG , "+more" ]; 302const changed = await open ( context ); 303assertEquals ( 304changed . element . querySelector < HTMLInputElement >( ".viewed input" ) ! . checked , 305false , 306); 307assertEquals ( changed . element . querySelector < HTMLElement >( ".file-body" ) ! . hidden , false ); 308changed . dispose ?.(); 309})); 310 311Deno . test ( "side by side, removed and added lines share rows, and the choice is remembered" , () => 312withDom ( async () => { 313const { context} = fixture (() => [ modified ([ " a" , "-b" , "+B" , "+c" ])]); 314const first = await open ( context ); 315const split = [ ...first . element . querySelectorAll < HTMLButtonElement >( ".diff-layout button" )] 316. find (( button ) => button . textContent === "split" ) ! ; 317split . click (); 318const rows = ( view :View ) => 319[ ...view . element . querySelectorAll ( "table.diff tr" )]. map (( row ) => 320[ ...row . querySelectorAll ( ".text" )]. map (( cell ) => cell . textContent ). join ( "|" ) 321); 322await until (() => !! first . element . querySelector ( "table.diff.split" )); 323assertEquals ( rows ( first ), [ "a|a" , "b|B" , "|c" ]); 324first . dispose ?.(); 325const again = await open ( context ); 326assertEquals ( rows ( again ), [ "a|a" , "b|B" , "|c" ]); 327again . dispose ?.(); 328})); 329 330const button = ( element :Element , label :string ) => 331[ ...element . querySelectorAll < HTMLButtonElement >( "button" )]. find (( b ) => 332b . textContent ?. trim () === label 333) ! ; 334const editorOf = ( view :View ) => 335EditorView . findFromDOM ( view . element . querySelector ( ".cm-merge-b" ) ! ) ! ; 336const status = ( view :View ) => view . element . querySelector ( '[role="status"]' )?. textContent ; 337const plain = ( view :View ) => until (() => ! view . element . querySelector ( ".cm-editor" )); 338const insert = ( editor :EditorView , text :string ) => 339editor . dispatch ({ changes :{ from :0 , insert :text } }); 340const OUTSIDE = { tid :"other" , extension :null , route :"" }; 341 342/** A page served over plain http, which has no `crypto.randomUUID`. */ 343const withPage = < T > ( test :Parameters < typeof withDom < T >>[ 0 ]) => 344withDom ( test , { globals :{ crypto :{ getRandomValues :crypto . getRandomValues . bind ( crypto ) } } }); 345 346async function edit ( view :View ) { 347button ( view . element , "edit" ). click (); 348await until (() => !! view . element . querySelector ( ".cm-editor" )); 349return editorOf ( view ); 350} 351 352/** 353* A file to edit, whose disk answers as the extension would: opens read it, a save writes it if its version still 354* holds, once per save id, and exits record their reports. Hooks fail or hold a save before or after it writes, or 355* a report. 356*/ 357function editable ( 358{ content= "new\n" , original= "" , path= "src/total.ts" , conflicted= false } = {}, 359) { 360const disk = { content, version :"v0" }; 361const lines = ( text :string , mark :string ) => 362text . split ( "\n" ). filter (( line ) => line !== "" ). map (( line ) => mark + line ); 363const file = () => ({ 364 ...modified ([ ...lines ( original , "-" ), ...lines ( disk . content , "+" )]), 365 path, 366}); 367const base = fixture (() => [ file ()], { 368log :[{ ...LOG [ 0 ], conflict :conflicted }, { ...LOG [ 1 ], conflict :conflicted }], 369}); 370const saves :Save [] = []; 371const reports :string [] = []; 372const opens :string [] = []; 373const receipts = new Map < string , Saved >(); 374const hooks :{ 375save ?() :Promise < void > | void ; 376saved ?() :void ; 377finish ?() :Promise < void > | void ; 378} = {}; 379const context :ViewContext = { 380 ...base . context , 381async request ( name , args , options ) { 382if ( name === "save" ) { 383const save = args as unknown as Save ; 384saves . push ( structuredClone ( save )); 385await hooks . save ?.(); 386let receipt = receipts . get ( save . id ); 387if ( ! receipt ) { 388if ( save . version !== disk . version ) { 389throw new Error ( "This file changed on disk since you opened it." ); 390} 391Object . assign ( disk , { content :save . content , version :`v ${ receipts . size + 1 } ` }); 392const { id, working, path} = save ; 393receipt = { id, working, path, version :disk . version , patch :"human edits" }; 394receipts . set ( id , receipt ); 395} 396hooks . saved ?.(); 397return receipt as unknown as JsonValue ; 398} 399if ( name === "open" ) { 400const route = ( args as { route :string }). route ; 401opens . push ( route ); 402return { 403working :{ 404machine :"local" , 405root :"/repo" , 406vcs :"jj" , 407identity :"change" , 408 ...( route ?{ change :route } :{}), 409}, 410file :file (), 411 original, 412 ...disk , 413} as unknown as JsonValue ; 414} 415return base . context . request ( name , args , options ); 416}, 417async command ( name , args = "" ) { 418assertEquals ( name , "diff-finish" ); 419reports . push (( JSON . parse ( args ) as { session :string }). session ); 420await hooks . finish ?.(); 421return null ; 422}, 423}; 424return { context, disk, saves, reports, opens, hooks}; 425} 426 427Deno . test ( "new-side editing survives refresh and failed saves, and reports saved edits only on leaving the view" , () => 428withPage ( async () => { 429const { context, saves, reports, hooks} = editable ({ original :"old\n" }); 430hooks . save = () => { 431if ( saves . length === 1 ) throw new Error ( "Offline" ); 432}; 433const view = await open ( context ); 434const dblclick = ( selector :string ) => 435view . element . querySelector ( selector ) ! . dispatchEvent ( 436new MouseEvent ( "dblclick" , { bubbles :true }), 437); 438try { 439dblclick ( "td.text.del" ); 440await settle (); 441assertEquals ( view . element . querySelector ( ".cm-editor" ), null ); 442dblclick ( "td.text.add" ); 443await until (() => !! view . element . querySelector ( ".cm-editor" )); 444const editor = editorOf ( view ); 445editor . dispatch ({ changes :{ from :0 , to :3 , insert :"human\nextra" } }); 446view . update ({ ...context , latest :2 }); 447await settle (); 448assertEquals ( editorOf ( view ), editor ); 449button ( view . element , "save" ). click (); 450await until (() => status ( view ) === "Offline" ); 451assertEquals ( editor . state . doc . toString (), "human\nextra\n" ); 452button ( view . element , "save" ). click (); 453await plain ( view ); 454assertEquals ( text ( view . element , "table.diff td.text" ), [ "old" , "human" , "extra" ]); 455assertEquals ( saves [ 1 ], saves [ 0 ]); 456const history = { tid :context . tid , extension :"diff" , route :"vvkvpomk" }; 457assertEquals ( await view . beforeLeave ! ( history ), true ); 458assertEquals ( reports , []); 459assertEquals ( await view . beforeLeave ! ( OUTSIDE ), true ); 460assertEquals ( reports , [ saves [ 0 ]. session ]); 461} finally { 462view . dispose ?.(); 463} 464})); 465 466Deno . test ( "Save closes clean editors but retains edits made while saving" , () => 467withPage ( async () => { 468const { context, disk, saves, hooks} = editable (); 469const saving = Promise . withResolvers < void >(); 470hooks . save = () => saving . promise ; 471const view = await open ( context ); 472try { 473const editor = await edit ( view ); 474insert ( editor , "saved " ); 475button ( view . element , "save" ). click (); 476await until (() => saves . length === 1 ); 477insert ( editor , "later " ); 478saving . resolve (); 479await until (() => status ( view ) === "unsaved edits" ); 480assertEquals ( editorOf ( view ), editor ); 481assertEquals ( editor . state . doc . toString (), "later saved new\n" ); 482assertEquals ( disk . content , "saved new\n" ); 483 484button ( view . element , "save" ). click (); 485await plain ( view ); 486assertEquals ( text ( view . element , "table.diff td.text" ), [ "later saved new" ]); 487assertEquals ( disk . content , "later saved new\n" ); 488 489await edit ( view ); 490button ( view . element , "save" ). click (); 491await plain ( view ); 492assertEquals ( saves . length , 2 ); 493} finally { 494saving . resolve (); 495view . dispose ?.(); 496} 497})); 498 499Deno . test ( "leaving with unsaved edits can stay, or discard them and still report the saves before" , () => 500withPage ( async () => { 501const { context, saves, reports, hooks} = editable (); 502hooks . saved = () => { 503throw new Error ( "Save response lost" ); 504}; 505const reporting = Promise . withResolvers < void >(); 506hooks . finish = () => reporting . promise ; 507const view = await open ( context ); 508try { 509const editor = await edit ( view ); 510insert ( editor , "saved " ); 511button ( view . element , "save" ). click (); 512await until (() => status ( view ) === "Save response lost" ); 513insert ( editor , "unsaved " ); 514const stay = view . beforeLeave ! ( OUTSIDE ); 515await until (() => !! document . querySelector ( "dialog" )); 516button ( document . body , "stay" ). click (); 517assertEquals ( await stay , false ); 518assertEquals ( editor . state . doc . toString (), "unsaved saved new\n" ); 519const leaving = view . beforeLeave ! ( OUTSIDE ); 520await until (() => !! document . querySelector ( "dialog" )); 521button ( document . body , "discard and leave" ). click (); 522await until (() => reports . length === 1 ); 523// Reporting the saves can wait for the agent's turn, and must not hold navigation open. 524assertEquals ( await leaving , true ); 525assertEquals (( view . element as HTMLElement ). inert , false ); 526assertEquals ( view . element . querySelector ( ".cm-editor" ), null ); 527reporting . resolve (); 528await settle (); 529assertEquals ( saves . map (( save ) => save . content ), [ "saved new\n" ]); 530} finally { 531reporting . resolve (); 532view . dispose ?.(); 533} 534})); 535 536Deno . test ( "a failed exit report is recoverable after the view is reloaded" , () => 537withPage ( async () => { 538const { context, reports, hooks} = editable (); 539hooks . finish = () => { 540if ( reports . length === 1 ) throw new Error ( "Offline" ); 541}; 542const first = await open ( context ); 543insert ( await edit ( first ), "human " ); 544button ( first . element , "save" ). click (); 545await plain ( first ); 546// A failed report must not hold navigation open; it stays to retry on a later visit. 547assertEquals ( await first . beforeLeave ! ( OUTSIDE ), true ); 548await until (() => reports . length === 1 ); 549await until (() => first . element . textContent ?. includes ( "Could not report" )); 550first . dispose ?.(); 551first . element . remove (); 552const reloaded = await open ( context ); 553try { 554assertEquals ( reloaded . element . querySelector ( ".cm-editor" ), null ); 555assertEquals ( text ( reloaded . element , "table.diff td.text" ), [ "human new" ]); 556assertEquals ( await reloaded . beforeLeave ! ( OUTSIDE ), true ); 557assertEquals ( reports , [ reports [ 0 ], reports [ 0 ]]); 558} finally { 559reloaded . dispose ?.(); 560} 561})); 562 563Deno . test ( "a closed tab recovers an unacknowledged save before reporting, without saving its newer draft" , () => 564withPage ( async ( window ) => { 565const { context, saves, reports, hooks} = editable ({ path :"__proto__" }); 566hooks . saved = () => { 567if ( saves . length === 1 ) throw new Error ( "Save response lost" ); 568}; 569const first = await open ( context ); 570const editor = await edit ( first ); 571insert ( editor , "saved " ); 572button ( first . element , "save" ). click (); 573await until (() => status ( first ) === "Save response lost" ); 574insert ( editor , "unsaved " ); 575window . dispatchEvent ( new window . Event ( "pagehide" )); 576first . dispose ?.(); 577first . element . remove (); 578await recoverClosed ( context ); 579await recoverClosed ( context ); 580assertEquals ( saves . length , 2 ); 581assertEquals ( saves [ 1 ], saves [ 0 ]); 582assertEquals ( reports , [ saves [ 0 ]. session ]); 583const restored = await open ( context ); 584try { 585await until (() => !! restored . element . querySelector ( ".cm-editor" )); 586assertEquals ( editorOf ( restored ). state . doc . toString (), "unsaved saved new\n" ); 587} finally { 588restored . dispose ?.(); 589} 590})); 591 592Deno . test ( "leaving the diff reports saved edits without waiting, in a fresh session afterwards" , () => 593withPage ( async () => { 594const { context, saves, reports, hooks} = editable (); 595const reporting = Promise . withResolvers < void >(); 596hooks . finish = () => reports . length === 1 ?reporting . promise :undefined ; 597const here = { tid :context . tid , extension :"diff" , route :context . route }; 598const view = await open ( context ); 599try { 600let editor = await edit ( view ); 601insert ( editor , "first " ); 602button ( view . element , "save" ). click (); 603await plain ( view ); 604await edit ( view ); 605const leaving = view . beforeLeave ! ( OUTSIDE ); 606await until (() => reports . length === 1 ); 607// A busy agent holds the report open, which must not hold navigation open. 608assertEquals ( await leaving , true ); 609assertEquals ( view . element . querySelector ( ".cm-editor" ), null ); 610const returning = view . beforeLeave ! ( here ); 611assertEquals ( await returning , true ); 612reporting . resolve (); 613await settle (); 614editor = await edit ( view ); 615insert ( editor , "second " ); 616button ( view . element , "save" ). click (); 617await plain ( view ); 618assertEquals ( saves . length , 2 ); 619assert ( saves [ 1 ]. session !== saves [ 0 ]. session ); 620editor = await edit ( view ); 621insert ( editor , "unsaved " ); 622const exit = view . beforeLeave ! ( OUTSIDE ); 623await until (() => !! document . querySelector ( "dialog" )); 624const back = view . beforeLeave ! ( here ); 625assertEquals ( await exit , false ); 626assertEquals ( await back , false ); 627assertEquals ( document . querySelector ( "dialog" ), null ); 628assertEquals ( editor . state . doc . toString (), "unsaved second first new\n" ); 629assertEquals ( reports , [ saves [ 0 ]. session ]); 630} finally { 631reporting . resolve (); 632view . dispose ?.(); 633} 634})); 635 636Deno . test ( "Ctrl+Enter saves; cancel asks before discarding unsaved edits, Escape twice does not, and neither touches saves" , () => 637withPage ( async () => { 638const { context, disk, saves, hooks} = editable (); 639let confirmations = 0 ; 640let discard = false ; 641document . defaultView ! . confirm = () => ( confirmations ++ , discard ); 642const saving = Promise . withResolvers < void >(); 643hooks . save = () => saving . promise ; 644const mod = / Mac / . test ( navigator . platform ) ?{ metaKey :true } :{ ctrlKey :true }; 645const key = ( editor :EditorView , key :string , init :KeyboardEventInit = {}) => 646editor . contentDOM . dispatchEvent ( 647new KeyboardEvent ( "keydown" , { key, bubbles :true , cancelable :true , ...init }), 648); 649const view = await open ( context ); 650try { 651await edit ( view ); 652button ( view . element , "cancel" ). click (); 653assertEquals ( view . element . querySelector ( ".cm-editor" ), null ); 654assertEquals ( confirmations , 0 ); 655 656let editor = await edit ( view ); 657insert ( editor , "draft " ); 658button ( view . element , "cancel" ). click (); 659assertEquals ( editorOf ( view ), editor ); 660discard = true ; 661button ( view . element , "cancel" ). click (); 662assertEquals ( view . element . querySelector ( ".cm-editor" ), null ); 663 664editor = await edit ( view ); 665assertEquals ( editor . state . doc . toString (), "new\n" ); 666insert ( editor , "saved " ); 667key ( editor , "Enter" , mod ); 668await until (() => saves . length === 1 ); 669assert ( button ( view . element , "cancel" ). disabled ); 670key ( editor , "Escape" ); 671key ( editor , "Escape" ); 672assertEquals ( editorOf ( view ), editor ); 673saving . resolve (); 674await plain ( view ); 675 676editor = await edit ( view ); 677insert ( editor , "unsaved " ); 678key ( editor , "Escape" ); 679assertEquals ( editorOf ( view ), editor ); 680insert ( editor , "more " ); 681key ( editor , "Escape" ); 682assertEquals ( editorOf ( view ), editor ); 683key ( editor , "Escape" ); 684assertEquals ( view . element . querySelector ( ".cm-editor" ), null ); 685assertEquals ( confirmations , 2 ); 686assertEquals ( saves . length , 1 ); 687assertEquals ( disk . content , "saved new\n" ); 688} finally { 689saving . resolve (); 690view . dispose ?.(); 691} 692})); 693 694Deno . test ( "disk changes merge into an open draft, keeping its cursor and undo, and overlaps wait for a choice" , () => 695withPage ( async () => { 696const { context, disk, saves} = editable ({ content :"a\nb\nc\n" }); 697const agent = async ( content :string , latest :number ) => { 698Object . assign ( disk , { content, version :`agent ${ latest } ` }); 699view . update ({ ...context , latest}); 700await settle (); 701}; 702const view = await open ( context ); 703try { 704let editor = await edit ( view ); 705editor . dispatch ({ changes :{ from :0 , insert :"mine " }, selection :{ anchor :6 } }); 706 707await agent ( "a\nb\nc agent\n" , 2 ); 708await until (() => status ( view ) === "merged changes from disk" ); 709assertEquals ( editorOf ( view ), editor ); 710assertEquals ( editor . state . doc . toString (), "mine a\nb\nc agent\n" ); 711assertEquals ( editor . state . selection . main . head , 6 ); 712undo ( editor ); 713assertEquals ( editor . state . doc . toString (), "a\nb\nc agent\n" ); 714redo ( editor ); 715 716await agent ( "A\nb\nc agent\n" , 3 ); 717await until (() => !! status ( view )?. includes ( "where you edited" )); 718assertEquals ( editor . state . doc . toString (), "mine a\nb\nc agent\n" ); 719button ( view . element , "keep mine" ). click (); 720button ( view . element , "save" ). click (); 721await plain ( view ); 722assertEquals ( saves . at ( - 1 ) ! . version , "agent3" ); 723assertEquals ( disk . content , "mine a\nb\nc agent\n" ); 724 725editor = await edit ( view ); 726editor . dispatch ({ changes :{ from :editor . state . doc . length , insert :"end\n" } }); 727Object . assign ( disk , { content :"mine a\nB\nc agent\n" , version :"unseen" }); 728button ( view . element , "save" ). click (); 729await until (() => editor . state . doc . toString () === "mine a\nB\nc agent\nend\n" ); 730assertEquals ( editorOf ( view ), editor ); 731assertEquals ( disk . content , "mine a\nB\nc agent\n" ); 732 733await agent ( "other\n" , 5 ); 734await until (() => !! status ( view )?. includes ( "where you edited" )); 735button ( view . element , "take disk's" ). click (); 736assertEquals ( editor . state . doc . toString (), "other\n" ); 737undo ( editor ); 738assertEquals ( editor . state . doc . toString (), "mine a\nB\nc agent\nend\n" ); 739} finally { 740view . dispose ?.(); 741} 742})); 743 744Deno . test ( "a jj commit's files are editable, and a save that would conflict later commits can be forced" , () => 745withPage ( async () => { 746const { context, saves, opens, hooks} = editable (); 747hooks . save = () => { 748if ( ! saves . at ( - 1 ) ! . force ) { 749throw new Error ( "Saving would cause conflicts in 1 later commit (wmqqymrk)." ); 750} 751}; 752const view = await open ({ ...context , route :"vvkvpomk" }); 753try { 754insert ( await edit ( view ), "human " ); 755assert ( opens . length > 0 && opens . every (( route ) => route === "vvkvpomk" )); 756button ( view . element , "save" ). click (); 757await until (() => !! status ( view )?. includes ( "Save anyway" )); 758button ( view . element , "save anyway" ). click (); 759await plain ( view ); 760assertEquals ( saves . map (( save ) => [ save . id , save . force ]), [ 761[ saves [ 0 ]. id , undefined ], 762[ saves [ 0 ]. id , true ], 763]); 764} finally { 765view . dispose ?.(); 766} 767})); 768 769Deno . test ( "a conflicted commit's files can still be edited" , () => 770withPage ( async () => { 771const { context, saves} = editable ({ conflicted :true }); 772const view = await open ({ ...context , route :"vvkvpomk" }); 773try { 774insert ( await edit ( view ), "human " ); 775button ( view . element , "save" ). click (); 776await plain ( view ); 777assertEquals ( saves . map (( save ) => [ save . working . change , save . content ]), [ 778[ "vvkvpomk" , "human new\n" ], 779]); 780} finally { 781view . dispose ?.(); 782} 783})); 784 785Deno . test ( "flush reports saved edits without leaving, in a fresh session afterwards" , () => 786withPage ( async () => { 787const { context, saves, reports} = editable (); 788const view = await open ( context ); 789try { 790insert ( await edit ( view ), "first " ); 791button ( view . element , "save" ). click (); 792await plain ( view ); 793assertEquals ( reports , []); 794await view . flush ! (); 795assertEquals ( reports , [ saves [ 0 ]. session ]); 796// a second flush has nothing new to report 797await view . flush ! (); 798assertEquals ( reports , [ saves [ 0 ]. session ]); 799assertEquals ( view . element . querySelector ( ".cm-editor" ), null ); 800 801insert ( await edit ( view ), "second " ); 802button ( view . element , "save" ). click (); 803await plain ( view ); 804assert ( saves [ 1 ]. session !== saves [ 0 ]. session ); 805await view . flush ! (); 806assertEquals ( reports , [ saves [ 0 ]. session , saves [ 1 ]. session ]); 807// leaving afterwards has nothing new to report 808assertEquals ( await view . beforeLeave ! ( OUTSIDE ), true ); 809assertEquals ( reports , [ saves [ 0 ]. session , saves [ 1 ]. session ]); 810} finally { 811view . dispose ?.(); 812} 813})); 814 815Deno . test ( "flush keeps dirty drafts unsaved and a failed flush stays to retry" , () => 816withPage ( async () => { 817const { context, saves, reports, hooks} = editable (); 818let fail = false ; 819hooks . finish = () => { 820if ( fail ) throw new Error ( "Offline" ); 821}; 822const view = await open ( context ); 823try { 824const editor = await edit ( view ); 825insert ( editor , "draft " ); 826await view . flush ! (); 827assertEquals ( saves , []); 828assertEquals ( reports , []); 829assertEquals ( editor . state . doc . toString (), "draft new\n" ); 830 831button ( view . element , "save" ). click (); 832await plain ( view ); 833fail = true ; 834let failed = false ; 835try { 836await view . flush ! (); 837} catch { 838failed = true ; 839} 840assertEquals ( failed , true ); 841assertEquals ( reports , [ saves [ 0 ]. session ]); 842fail = false ; 843await view . flush ! (); 844assertEquals ( reports , [ saves [ 0 ]. session , saves [ 0 ]. session ]); 845} finally { 846view . dispose ?.(); 847} 848}));