char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
cbc8039
main
1use std:: fs; 2use std:: io:: Write ; 3use std:: path::{ Path , PathBuf }; 4use std:: process::{ Command , Output , Stdio }; 5 6fn command ( project : & Path , cache : & Path ) ->Command { 7let mut command =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )); 8 command 9. current_dir ( project) 10. env ( "AVRIL_CACHE_DIR" , cache) 11. stdin ( Stdio :: piped ()) 12. stdout ( Stdio :: piped ()) 13. stderr ( Stdio :: piped ()); 14 command 15} 16 17fn run ( project : & Path , cache : & Path , entry : & Path ) ->String { 18let output =command ( project, cache) 19. arg ( "run" ) 20. arg ( entry) 21. output () 22. unwrap (); 23assert_success ( & output); 24String :: from_utf8 ( output. stdout ). unwrap () 25} 26 27fn repl ( project : & Path , cache : & Path , input : & str ) ->String { 28repl_in ( project, cache, input) 29} 30 31fn repl_in ( directory : & Path , cache : & Path , input : & str ) ->String { 32repl_args ( directory, cache, input, & [ "repl" , "-0" ]) 33} 34 35fn repl_args ( directory : & Path , cache : & Path , input : & str , args : & [ & str ]) ->String { 36let mut child =command ( directory, cache). args ( args). spawn (). unwrap (); 37 child 38. stdin 39. take () 40. unwrap () 41. write_all ( input. as_bytes ()) 42. unwrap (); 43let output = child. wait_with_output (). unwrap (); 44assert_success ( & output); 45String :: from_utf8 ( output. stdout ). unwrap () 46} 47 48fn assert_success ( output : & Output ) { 49assert! ( 50 output. status . success (), 51"{}" , 52String :: from_utf8_lossy ( & output. stderr ) 53); 54} 55 56fn write ( path : & Path , source : & str ) { 57 fs:: create_dir_all ( path. parent (). unwrap ()). unwrap (); 58 fs:: write ( path, source). unwrap (); 59} 60 61fn file_count ( directory : & Path ) ->usize { 62if !directory. exists () { 63return 0 ; 64} 65 fs:: read_dir ( directory) 66. unwrap () 67. map ( |entry|{ 68let entry = entry. unwrap (); 69if entry. file_type (). unwrap (). is_dir () { 70file_count ( & entry. path ()) 71} else { 721 73} 74}) 75. sum () 76} 77 78struct Fixture { 79_dir : tempfile:: TempDir , 80project : PathBuf , 81cache : PathBuf , 82} 83 84impl Fixture { 85fn new () ->Self { 86let dir = tempfile:: tempdir (). unwrap (); 87let project = dir. path (). join ( "project" ); 88let cache = dir. path (). join ( "cache" ); 89 fs:: create_dir_all ( & project). unwrap (); 90Self { _dir : dir, project, cache} 91} 92 93fn run ( & self , entry : & Path ) ->String { 94run ( & self . project , & self . cache , entry) 95} 96} 97 98# [ test ] 99fn storage_api_semantics_and_quota () { 100let fixture =Fixture :: new (); 101let entry = fixture. project . join ( "main.js" ); 102write ( 103& entry, 104r#" 105if (!(localStorage instanceof Storage) || 106Object.prototype.toString.call(localStorage) !== '[object Storage]') throw Error('Interface'); 107try { new Storage(); throw Error('Constructor accepted'); } 108catch (error) { if (!(error instanceof TypeError)) throw error; } 109 110localStorage.setItem(42, { toString() { return 'value'; } }); 111if (localStorage.getItem('42') !== 'value' || localStorage.getItem(42) !== 'value') throw Error('Coercion'); 112localStorage.setItem('b', '2'); 113localStorage.setItem('a', '1'); 114if (localStorage.length !== 3 || localStorage.key(0) !== '42' || 115localStorage.key(1) !== 'a' || localStorage.key(3) !== null) throw Error('Enumeration'); 116 117localStorage.named = 'proxy'; 118if (localStorage.getItem('named') !== 'proxy' || localStorage.named !== 'proxy' || 119!('named' in localStorage) || !Object.keys(localStorage).includes('named')) throw Error('Named properties'); 120delete localStorage.named; 121if (localStorage.getItem('named') !== null || 'named' in localStorage) throw Error('Named delete'); 122 123localStorage.setItem('length', 'stored'); 124if (typeof localStorage.length !== 'number' || localStorage.getItem('length') !== 'stored') throw Error('Built-in shadowing'); 125 126try { localStorage.setItem(Symbol(), 'x'); throw Error('Symbol accepted'); } 127catch (error) { if (!(error instanceof TypeError)) throw error; } 128try { structuredClone(localStorage); throw Error('Clone accepted'); } 129catch (error) { if (error.name !== 'DataCloneError') throw error; } 130 131localStorage.clear(); 132try { 133localStorage.setItem('big', 'x'.repeat(10 * 1024 * 1024)); 134throw Error('Quota accepted'); 135} catch (error) { 136if (!(error instanceof DOMException) || error.name !== 'QuotaExceededError' || error.code !== 22) throw error; 137} 138if (localStorage.getItem('big') !== null || localStorage.length !== 0) throw Error('Quota was not atomic'); 139 140sessionStorage.setItem('key', 'session'); 141if (sessionStorage.getItem('key') !== 'session' || sessionStorage.length !== 1) throw Error('Session storage'); 142console.log('semantics'); 143"# , 144); 145assert_eq! ( fixture. run ( & entry), "semantics\n" ); 146} 147 148# [ test ] 149fn storage_preserves_receiver_checks_coercion_and_callable_shapes () { 150let fixture =Fixture :: new (); 151let entry = fixture. project . join ( "main.js" ); 152write ( 153& entry, 154r#" 155const check = (ok, message) => { if (!ok) throw Error(message); }; 156const typeError = fn => { 157try { fn(); } catch (error) { 158if (error instanceof TypeError) return; 159throw error; 160} 161throw Error('Expected TypeError'); 162}; 163check(Storage.length === 0, 'Constructor arity'); 164for (const [name, length] of [['key', 1], ['getItem', 1], ['setItem', 2], ['removeItem', 1], ['clear', 0]]) { 165check(Storage.prototype[name].length === length, name + ' arity'); 166} 167const hostile = { toString() { throw Error('Coercion before validation'); } }; 168const length = Object.getOwnPropertyDescriptor(Storage.prototype, 'length').get; 169for (const receiver of [null, undefined, 42, {}, Object.create(sessionStorage), new Proxy(sessionStorage, {})]) { 170typeError(() => Storage.prototype.getItem.call(receiver, hostile)); 171typeError(() => Storage.prototype.setItem.call(receiver, hostile, hostile)); 172typeError(() => Storage.prototype.key.call(receiver, hostile)); 173typeError(() => Storage.prototype.removeItem.call(receiver, hostile)); 174typeError(() => Storage.prototype.clear.call(receiver)); 175typeError(() => length.call(receiver)); 176} 177typeError(() => sessionStorage.setItem(hostile)); 178typeError(() => sessionStorage.getItem()); 179typeError(() => sessionStorage.key()); 180 181sessionStorage.setItem('a', 'value'); 182let conversions = 0; 183check(sessionStorage.key({ valueOf() { conversions++; return 2 ** 32; } }) === 'a', 'ToUint32'); 184check(conversions === 1, 'Repeated index conversion'); 185check(sessionStorage.key(-1) === null, 'Unsigned index'); 186typeError(() => sessionStorage.key(1n)); 187typeError(() => sessionStorage.key(Symbol())); 188 189const order = []; 190sessionStorage.setItem( 191{ toString() { order.push('key'); return 'b'; } }, 192{ toString() { order.push('value'); return 'converted'; } }, 193); 194check(order.join(',') === 'key,value', 'Conversion order'); 195check(sessionStorage.getItem('b') === 'converted', 'Converted value'); 196const symbol = Symbol('own'); 197sessionStorage[symbol] = 42; 198check(sessionStorage[symbol] === 42 && Reflect.ownKeys(sessionStorage).includes(symbol), 'Symbol property'); 199check(sessionStorage.length === 2, 'Symbol stored as a string key'); 200check(delete sessionStorage[symbol], 'Symbol deletion'); 201console.log('contracts'); 202"# , 203); 204assert_eq! ( fixture. run ( & entry), "contracts\n" ); 205} 206 207# [ test ] 208fn storage_uses_captured_intrinsics () { 209let fixture =Fixture :: new (); 210let entry = fixture. project . join ( "main.js" ); 211write ( 212& entry, 213r#" 214const originals = []; 215const poison = () => { throw Error('Poisoned intrinsic'); }; 216for (const [object, names] of [ 217[WeakMap.prototype, ['get', 'set']], 218[String.prototype, ['isWellFormed']], 219[Reflect, ['get', 'set', 'has', 'ownKeys', 'defineProperty', 'deleteProperty']], 220]) { 221for (const name of names) { 222originals.push([object, name, object[name]]); 223object[name] = poison; 224} 225} 226try { 227sessionStorage.named = 'value'; 228if (sessionStorage.named !== 'value' || sessionStorage.getItem('named') !== 'value') throw Error('Read'); 229if (!('named' in sessionStorage) || !Object.keys(sessionStorage).includes('named')) throw Error('Keys'); 230Object.defineProperty(sessionStorage, 'defined', { value: 42, configurable: true }); 231if (sessionStorage.defined !== '42') throw Error('Define'); 232const child = Object.create(sessionStorage); 233child.own = 'child'; 234if (child.own !== 'child' || sessionStorage.getItem('own') !== null) throw Error('Receiver'); 235const symbol = Symbol('own'); 236sessionStorage[symbol] = 42; 237if (sessionStorage[symbol] !== 42 || !delete sessionStorage[symbol]) throw Error('Symbol'); 238delete sessionStorage.named; 239if (sessionStorage.getItem('named') !== null) throw Error('Delete'); 240sessionStorage.clear(); 241if (sessionStorage.length !== 0) throw Error('Clear'); 242} finally { 243for (const [object, name, value] of originals) object[name] = value; 244} 245console.log('captured'); 246"# , 247); 248assert_eq! ( fixture. run ( & entry), "captured\n" ); 249} 250 251# [ test ] 252fn local_storage_persists_by_config_and_entry_while_session_does_not () { 253let fixture =Fixture :: new (); 254let entry = fixture. project . join ( "main.js" ); 255write ( 256& entry, 257"localStorage.setItem('key', 'local'); sessionStorage.setItem('key', 'session'); console.log('stored');" , 258); 259assert_eq! ( fixture. run ( & entry), "stored\n" ); 260 261write ( 262& entry, 263"console.log(localStorage.getItem('key'), sessionStorage.getItem('key'));" , 264); 265assert_eq! ( fixture. run ( & entry), "local null\n" ); 266 267let other = fixture. project . join ( "other.js" ); 268write ( & other, "console.log(localStorage.getItem('key'));" ); 269assert_eq! ( fixture. run ( & other), "null\n" ); 270 271// Discovering an avril.conf in an ancestor rekeys the same entry point. 272let nested = fixture. project . join ( "sub/main.js" ); 273write ( 274& nested, 275"if (localStorage.getItem('key') === null) { localStorage.setItem('key', 'nested'); console.log('stored'); } else console.log(localStorage.getItem('key'));" , 276); 277assert_eq! ( fixture. run ( & nested), "stored\n" ); 278write ( 279& fixture. project . join ( "avril.conf" ), 280"name: \"storage-test\",\n" , 281); 282write ( & nested, "console.log(localStorage.getItem('key'));" ); 283assert_eq! ( fixture. run ( & nested), "null\n" ); 284} 285 286# [ test ] 287fn untouched_storage_creates_no_files_and_repl_persists_without_capabilities () { 288let fixture =Fixture :: new (); 289let entry = fixture. project . join ( "main.js" ); 290write ( & entry, "console.log('clean');" ); 291assert_eq! ( fixture. run ( & entry), "clean\n" ); 292assert_eq! ( file_count ( & fixture. cache ), 0 ); 293 294assert_eq! ( 295repl ( 296& fixture. project , 297& fixture. cache , 298"localStorage.setItem('key', 'repl')\n.exit\n" 299), 300"undefined\n" 301); 302assert_eq! ( 303repl ( 304& fixture. project , 305& fixture. cache , 306"localStorage.getItem('key')\n.exit\n" 307), 308"\"repl\"\n" 309); 310} 311 312# [ test ] 313fn proxy_brand_and_descriptor_semantics () { 314let fixture =Fixture :: new (); 315let entry = fixture. project . join ( "main.js" ); 316write ( 317& entry, 318r#" 319localStorage.setItem('a', '1'); 320localStorage.setItem('b', '2'); 321if (!localStorage.hasOwnProperty('a') || localStorage.valueOf() !== localStorage) throw Error('Brand'); 322if (Storage.prototype.getItem.call(localStorage, 'a') !== '1') throw Error('Prototype receiver'); 323if (localStorage.getItem !== localStorage.getItem) throw Error('Method identity'); 324if (Object.getOwnPropertyDescriptor(localStorage, 'a').value !== '1' || 325Object.getOwnPropertyDescriptor(localStorage, 'a').value !== '1') throw Error('Descriptor'); 326localStorage.setItem('length', 'stored'); 327if (Object.hasOwn(localStorage, 'length') || typeof localStorage.length !== 'number') throw Error('Shadowed visibility'); 328if (delete localStorage.length !== true || localStorage.getItem('length') !== 'stored') throw Error('Shadowed delete'); 329try { localStorage.setItem('bad', '\uD800'); throw Error('Surrogate accepted'); } 330catch (error) { if (!(error instanceof TypeError)) throw error; } 331try { Object.defineProperty(localStorage, 'nc', { value: 'v', configurable: false }); throw Error('Define accepted'); } 332catch (error) { if (localStorage.getItem('nc') !== null) throw error; } 333try { Object.defineProperty(localStorage, 'acc', { get() { return 1; } }); throw Error('Accessor accepted'); } 334catch (error) { if (localStorage.getItem('acc') !== null) throw error; } 335try { Object.freeze(localStorage); throw Error('Freeze accepted'); } 336catch (error) { if (!(error instanceof TypeError)) throw error; } 337if (localStorage.getItem('a') !== '1' || !Object.keys(localStorage).includes('b')) throw Error('Freeze attempt broke storage'); 338const derived = Object.create(localStorage); 339derived.own = 'x'; 340if (localStorage.getItem('own') !== null || derived.own !== 'x') throw Error('Receiver'); 341if (localStorage.getItem('\uD800') !== null || localStorage['\uD800'] !== undefined || 342('\uD800' in localStorage) || Object.hasOwn(localStorage, '\uD800')) throw Error('Ill-formed read'); 343localStorage.removeItem('\uD800'); 344// Patching the prototype must not reroute named access or expose 345// any hidden object, and patched methods see the public proxy. 346const original = Storage.prototype.setItem; 347let calls = 0, receiver = null; 348Storage.prototype.setItem = function (key, value) { calls++; receiver = this; return original.call(this, key, value); }; 349try { 350localStorage.named = 'via proxy'; 351if (calls !== 0 || localStorage.getItem('named') !== 'via proxy') throw Error('Named write rerouted'); 352localStorage.setItem('direct', '1'); 353if (calls !== 1 || receiver !== localStorage) throw Error('Patched receiver'); 354} finally { Storage.prototype.setItem = original; } 355console.log('proxy'); 356"# , 357); 358assert_eq! ( fixture. run ( & entry), "proxy\n" ); 359} 360 361# [ test ] 362fn reads_do_not_modify_the_database_and_corruption_names_the_file () { 363let fixture =Fixture :: new (); 364let entry = fixture. project . join ( "main.js" ); 365write ( & entry, "localStorage.setItem('a', '1');" ); 366 fixture. run ( & entry); 367let database = fixture 368. cache 369. join ( "webstorage/v1" ) 370. read_dir () 371. unwrap () 372. next () 373. unwrap () 374. unwrap () 375. path (); 376let before = fs:: read ( & database). unwrap (); 377let modified = fs:: metadata ( & database). unwrap (). modified (). unwrap (); 378write ( 379& entry, 380"console.log(localStorage.getItem('a'), localStorage.length, localStorage.key(0));" , 381); 382assert_eq! ( fixture. run ( & entry), "1 1 a\n" ); 383assert_eq! ( fs:: read ( & database). unwrap (), before); 384assert_eq! ( 385 fs:: metadata ( & database). unwrap (). modified (). unwrap (), 386 modified 387); 388 389 fs:: write ( & database, b"totally not a database" ). unwrap (); 390let output =command ( & fixture. project , & fixture. cache ) 391. arg ( "run" ) 392. arg ( & entry) 393. output () 394. unwrap (); 395assert! ( !output. status . success ()); 396let stderr =String :: from_utf8_lossy ( & output. stderr ); 397assert! ( stderr. contains ( "localStorage (" ), "{stderr}" ); 398assert! ( 399 stderr. contains ( & database. to_string_lossy (). to_string ()), 400"{stderr}" 401); 402assert! ( stderr. contains ( "delete the file to reset" ), "{stderr}" ); 403} 404 405# [ test ] 406fn over_quota_first_write_and_info_create_no_storage_file () { 407let fixture =Fixture :: new (); 408let entry = fixture. project . join ( "main.js" ); 409write ( 410& entry, 411r#" 412try { localStorage.setItem('big', 'x'.repeat(11 * 1024 * 1024)); } catch {} 413console.log('rejected'); 414"# , 415); 416assert_eq! ( fixture. run ( & entry), "rejected\n" ); 417assert_eq! ( file_count ( & fixture. cache ), 0 ); 418 419let output =command ( & fixture. project , & fixture. cache ) 420. arg ( "info" ) 421. arg ( & entry) 422. output () 423. unwrap (); 424assert_success ( & output); 425assert! ( !fixture. cache . join ( "webstorage" ). exists ()); 426} 427 428# [ test ] 429fn repl_in_a_subdirectory_shares_the_project_storage () { 430let fixture =Fixture :: new (); 431write ( 432& fixture. project . join ( "avril.conf" ), 433"name: \"repl-test\",\n" , 434); 435let sub = fixture. project . join ( "sub" ); 436 fs:: create_dir_all ( & sub). unwrap (); 437// Unrestricted sessions, so config discovery applies; the -0 case is 438// covered by the same-directory REPL test above. 439assert_eq! ( 440repl_args ( 441& fixture. project , 442& fixture. cache , 443"localStorage.setItem('key', 'shared')\n.exit\n" , 444& [ "repl" ] 445), 446"undefined\n" 447); 448assert_eq! ( 449repl_args ( 450& sub, 451& fixture. cache , 452"localStorage.getItem('key')\n.exit\n" , 453& [ "repl" ] 454), 455"\"shared\"\n" 456); 457} 458 459# [ test ] 460fn workers_have_no_storage () { 461let fixture =Fixture :: new (); 462write ( 463& fixture. project . join ( "worker.js" ), 464"console.log('worker', typeof localStorage, typeof sessionStorage, typeof Storage);" , 465); 466let entry = fixture. project . join ( "main.js" ); 467write ( 468& entry, 469r#" 470const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); 471await new Promise(resolve => setTimeout(resolve, 300)); 472worker.terminate(); 473console.log('main', typeof localStorage); 474"# , 475); 476assert_eq! ( 477 fixture. run ( & entry), 478"worker undefined undefined undefined\nmain object\n" 479); 480} 481 482# [ test ] 483fn concurrent_processes_block_and_retry_instead_of_failing () { 484let fixture =Fixture :: new (); 485let entry = fixture. project . join ( "main.js" ); 486write ( 487& entry, 488r#" 489for (let i = 0; i < 100; i++) { 490localStorage.setItem('key', 'value' + i); 491if (localStorage.getItem('key') === null) throw Error('Lost value'); 492localStorage.length; 493} 494console.log('done'); 495"# , 496); 497let first =command ( & fixture. project , & fixture. cache ) 498. arg ( "run" ) 499. arg ( & entry) 500. spawn () 501. unwrap (); 502let second =command ( & fixture. project , & fixture. cache ) 503. arg ( "run" ) 504. arg ( & entry) 505. spawn () 506. unwrap (); 507for childin [ first, second] { 508let output = child. wait_with_output (). unwrap (); 509assert_success ( & output); 510assert_eq! ( String :: from_utf8 ( output. stdout ). unwrap (), "done\n" ); 511} 512}