use std::io; use avril_core::{Error, Extension, ModuleSource, Op, Runtime, RuntimeOptions}; use serde_json::{Value, json}; #[test] fn typed_ops_and_script_results() { let mut runtime = Runtime::new(RuntimeOptions { extensions: vec![Extension { name: "ext:test", dependencies: &[], ops: vec![Op::new("echo", |value: Value| Ok::<_, io::Error>(value))], modules: &[( "ext:test/main.js", r#"import {echo} from 'ext:core/ops'; globalThis.echo = echo; globalThis.bootstrapped = echo({message: "hello", values: [1, true, null]});"#, )], entry_point: Some("ext:test/main.js"), }], ..Default::default() }) .unwrap(); let value: Value = runtime .execute_script("echo.js", "echo(bootstrapped)") .unwrap(); assert_eq!( value, json!({"message": "hello", "values": [1, true, null]}) ); } #[test] fn op_errors_are_javascript_exceptions() { let mut runtime = Runtime::new(RuntimeOptions { extensions: vec![Extension { name: "ext:test", dependencies: &[], ops: vec![Op::new("fail", |_: Vec| { Err::<(), _>(io::Error::other("host failure")) })], modules: &[( "ext:test/main.js", "import {fail} from 'ext:core/ops'; globalThis.fail = fail;", )], entry_point: Some("ext:test/main.js"), }], ..Default::default() }) .unwrap(); let errors: Vec = runtime .execute_script( "errors.js", r#" [42, ["valid"]].map((args) => { try { fail(args); throw new Error("Expected an op error"); } catch (error) { return error.name + ":" + error.message; } }); "#, ) .unwrap(); assert!(errors[0].starts_with("TypeError:")); assert_eq!(errors[1], "Error:host failure"); } #[test] fn script_errors_do_not_poison_the_runtime() { let mut runtime = Runtime::new(RuntimeOptions::default()).unwrap(); for source in ["const =", "throw new Error('oops')"] { assert!(matches!( runtime.execute_script::<()>("failure.js", source), Err(Error::JavaScript(_)) )); } assert_eq!(runtime.execute_script::("ok.js", "6 * 7").unwrap(), 42); } #[test] fn duplicate_ops_are_rejected() { let result = Runtime::new(RuntimeOptions { extensions: vec![Extension { name: "ext:test", dependencies: &[], ops: vec![ Op::new("duplicate", |_: ()| Ok::<_, io::Error>(())), Op::new("duplicate", |_: ()| Ok::<_, io::Error>(())), ], modules: &[], entry_point: None, }], ..Default::default() }); assert!(matches!(result, Err(Error::DuplicateOp("duplicate")))); } #[test] fn failing_extensions_abort_initialization() { let result = Runtime::new(RuntimeOptions { extensions: vec![Extension { name: "ext:broken", dependencies: &[], ops: vec![], modules: &[("ext:broken/main.js", "throw new Error('bootstrap failed');")], entry_point: Some("ext:broken/main.js"), }], ..Default::default() }); assert!( matches!(result, Err(Error::JavaScript(message)) if message.contains("bootstrap failed")) ); } #[test] fn script_turns_drain_microtasks_and_report_rejections_without_leaking_them() { let mut runtime = Runtime::new(RuntimeOptions::default()).unwrap(); assert_eq!( runtime .execute_script::( "result.js", r#" const result = { value: 0 }; Promise.resolve().then(() => result.value = 42); result; "# ) .unwrap(), json!({ "value": 42 }) ); runtime .execute_script::<()>( "jobs.js", "globalThis.answer = 0; Promise.resolve().then(() => answer = 42);", ) .unwrap(); assert_eq!( runtime .execute_script::("answer.js", "answer") .unwrap(), 42 ); runtime .execute_script::<()>( "handled.js", "const p = Promise.reject('handled'); Promise.resolve().then(() => p.catch(() => {}));", ) .unwrap(); for source in [ "Promise.reject(new Error('first')); Promise.reject(new Error('second'));", "Promise.reject(new Error('discarded')); throw new Error('first');", ] { let error = runtime .execute_script::<()>("rejected.js", source) .unwrap_err(); assert!(error.to_string().contains("first"), "{error}"); runtime.run_event_loop().unwrap(); assert_eq!(runtime.execute_script::("good.js", "42").unwrap(), 42); } } #[test] fn embedding_event_loop_settles_async_ops_and_continuations() { let mut runtime = Runtime::new(RuntimeOptions { extensions: vec![Extension { name: "ext:async", dependencies: &[], ops: vec![ Op::asynchronous( "echo", |value: u32| async move { Ok::<_, io::Error>(value) }, ), Op::asynchronous("fail", |_: ()| async { Err::<(), _>(io::Error::other("host failure")) }), ], modules: &[( "ext:async/main.js", "import {echo, fail} from 'ext:core/ops'; globalThis.echo = echo; globalThis.fail = fail;", )], entry_point: Some("ext:async/main.js"), }], ..Default::default() }) .unwrap(); runtime .execute_script::<()>( "start.js", r#" globalThis.answer = 0; echo(41).then(value => echo(value + 1)).then(value => answer = value); "#, ) .unwrap(); assert_eq!( runtime .execute_script::("before.js", "answer") .unwrap(), 0 ); runtime.run_event_loop().unwrap(); assert_eq!( runtime.execute_script::("after.js", "answer").unwrap(), 42 ); runtime.execute_script::<()>("fail.js", "fail();").unwrap(); assert!( runtime .run_event_loop() .unwrap_err() .to_string() .contains("host failure") ); runtime.run_event_loop().unwrap(); } #[test] fn unreferenced_async_ops_do_not_keep_liveness_but_run_with_other_work() { use std::cell::RefCell; use std::rc::Rc; let (wait_sender, wait_receiver) = futures::channel::oneshot::channel::(); let (reference_sender, reference_receiver) = futures::channel::oneshot::channel::(); let wait_receiver = Rc::new(RefCell::new(Some(wait_receiver))); let reference_receiver = Rc::new(RefCell::new(Some(reference_receiver))); let mut runtime = Runtime::new(RuntimeOptions { extensions: vec![Extension { name: "ext:async", dependencies: &[], ops: vec![ Op::asynchronous_unreferenced("wait", { let receiver = wait_receiver.clone(); move |(): ()| { let receiver = receiver.borrow_mut().take().unwrap(); async move { receiver.await.map_err(io::Error::other) } } }), Op::asynchronous_unreferenced("ready", |(): ()| async { Ok::<_, io::Error>(7u32) }), Op::asynchronous("reference", move |(): ()| { let receiver = reference_receiver.borrow_mut().take().unwrap(); async move { receiver.await.map_err(io::Error::other) } }), ], modules: &[( "ext:async/main.js", "import {wait, ready, reference} from 'ext:core/ops'; globalThis.testWait = wait; globalThis.testReady = ready; globalThis.testReference = reference;", )], entry_point: Some("ext:async/main.js"), }], ..Default::default() }) .unwrap(); runtime .execute_script::<()>( "unreferenced.js", r#" globalThis.waitResult = undefined; globalThis.readyResult = undefined; testWait().then(value => waitResult = value); testReady().then(value => readyResult = value); "#, ) .unwrap(); runtime.run_event_loop().unwrap(); assert_eq!( runtime .execute_script::("ready.js", "readyResult") .unwrap(), 7 ); assert_eq!( runtime .execute_script::>("wait.js", "waitResult") .unwrap(), None ); wait_sender.send(11).unwrap(); runtime.run_event_loop().unwrap(); assert_eq!( runtime .execute_script::("wait.js", "waitResult") .unwrap(), 11 ); runtime .execute_script::<()>( "referenced.js", r#" globalThis.order = []; testReady().then(() => order.push('unreferenced')); testReference().then(() => order.push('referenced')); "#, ) .unwrap(); let sender = std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(10)); reference_sender.send(13).unwrap(); }); runtime.run_event_loop().unwrap(); sender.join().unwrap(); assert_eq!( runtime .execute_script::>("order.js", "order") .unwrap(), ["unreferenced", "referenced"] ); } #[test] fn module_failures_do_not_contaminate_later_turns() { struct Loader; impl avril_core::ModuleLoader for Loader { fn resolve(&self, specifier: &str, _: &str) -> Result { Ok(specifier.into()) } fn load(&self, name: &str) -> Result { Ok(match name { "pending" => "await new Promise(() => {});", "rejected" => "throw new Error('module failure');", _ => "export {};", } .into()) } } let mut runtime = Runtime::new(RuntimeOptions { module_loader: Some(std::rc::Rc::new(Loader)), ..Default::default() }) .unwrap(); assert!( runtime .execute_script::<()>("bad.js", "Promise.reject('script failure');") .is_err() ); runtime.execute_module("good").unwrap(); for (name, message) in [ ("pending", "no runnable host work"), ("rejected", "module failure"), ] { assert!( runtime .execute_module(name) .unwrap_err() .to_string() .contains(message) ); runtime.run_event_loop().unwrap(); } } #[test] fn stack_formatting_preserves_native_headers_and_getter_guards() { let mut runtime = Runtime::new(RuntimeOptions::default()).unwrap(); let stacks: Vec = runtime .execute_script( "stacks.js", r#" const original = new Error("original"); original.message = Symbol("changed"); original.name = "Renamed"; const hostile = new Error("hidden"); Object.defineProperty(hostile, "name", { get() { throw new Error("bad name"); } }); const nested = new Error("hidden"); Object.defineProperty(nested, "name", { get() { throw null; } }); const plain = { name: "Captured", message: "plain" }; Error.captureStackTrace(plain); [original.stack, hostile.stack, nested.stack, plain.stack]; "#, ) .unwrap(); for (stack, header) in stacks.iter().zip([ "Renamed: original", "", "", "Captured: plain", ]) { assert!( stack.starts_with(&format!("{header}\n at stacks.js:")), "{stack}" ); } let error = runtime .execute_script::<()>( "hostile.js", r#" const thrown = new Error("original"); Object.defineProperty(thrown, "name", { get() { throw new Error("bad name"); } }); throw thrown; "#, ) .unwrap_err() .to_string(); assert!( error.contains("\n at hostile.js:"), "{error}" ); assert_eq!(runtime.execute_script::("ok.js", "6 * 7").unwrap(), 42); } #[test] fn stack_formatting_uses_current_message() { let mut runtime = Runtime::new(RuntimeOptions::default()).unwrap(); let stacks: Vec = runtime .execute_script( "current.js", r#" const changed = new Error("original"); changed.message = "changed"; const lazy = new Error("creation"); Object.defineProperty(lazy, "message", { get() { return "lazy-now"; }, configurable: true, }); const multiline = new Error("original"); multiline.message = "line1\nline2"; function absent() {} const frameless = new Error("with frames"); frameless.message = "line1\nline2"; Error.captureStackTrace(frameless, absent); const plain = { name: "Shaped", message: "plain" }; Object.defineProperty(plain, "message", { get() { return "lazy-plain"; }, configurable: true, }); Error.captureStackTrace(plain, absent); const hostile = new Error("preserved"); Object.defineProperty(hostile, "message", { get() { throw new Error("bad message"); }, }); [changed.stack, lazy.stack, multiline.stack, frameless.stack, plain.stack, hostile.stack]; "#, ) .unwrap(); assert!( stacks[0].starts_with("Error: changed\n at current.js:"), "{}", stacks[0] ); assert!( stacks[1].starts_with("Error: lazy-now\n at current.js:"), "{}", stacks[1] ); assert!( stacks[2].starts_with("Error: line1\nline2\n at current.js:"), "{}", stacks[2] ); assert_eq!(stacks[3], "Error: line1\nline2", "{}", stacks[3]); assert_eq!(stacks[4], "Shaped: lazy-plain", "{}", stacks[4]); assert!( stacks[5].starts_with("Error: preserved\n at current.js:"), "{}", stacks[5] ); } #[test] fn custom_stack_hooks_preserve_v8_behavior() { let mut runtime = Runtime::new(RuntimeOptions::default()).unwrap(); let result: Value = runtime.execute_script("custom.js", r#" const result = { unset: Error.prepareStackTrace === undefined }; const custom = function(error, frames) { result.receiver = this === Error; result.message = error.message; return frames; }; Error.prepareStackTrace = custom; result.identity = Error.prepareStackTrace === custom; result.frames = Array.isArray(new Error("custom").stack); const retry = new Error("retry"); Error.prepareStackTrace = () => { throw "formatter threw"; }; try { retry.stack; } catch (error) { result.thrown = error; } Error.prepareStackTrace = () => "retried"; result.retry = retry.stack; Object.defineProperty(Error, "prepareStackTrace", { get() { throw "getter threw"; }, configurable: true, }); const getterRetry = new Error("getter retry"); try { getterRetry.stack; } catch (error) { result.getter = error; } delete Error.prepareStackTrace; result.getterRetry = getterRetry.stack.startsWith("Error: getter retry\n at custom.js:"); result.restored = new Error("restored").stack.startsWith("Error: restored\n at custom.js:"); result; "#).unwrap(); assert_eq!( result, json!({ "unset": true, "receiver": true, "message": "custom", "identity": true, "frames": true, "thrown": "formatter threw", "retry": "retried", "getter": "getter threw", "getterRetry": true, "restored": true, }) ); } #[test] fn dynamic_imports_wait_for_static_async_dependencies_and_share_evaluation() { struct Loader; impl avril_core::ModuleLoader for Loader { fn resolve(&self, specifier: &str, _: &str) -> Result { Ok(specifier.into()) } fn module_url(&self, name: &str) -> Option { Some(format!("test:{name}")) } fn load(&self, name: &str) -> Result { Ok(match name { "entry" => { r#" import 'waiting'; import 'observer'; globalThis.entryRuns = (globalThis.entryRuns ?? 0) + 1; globalThis.entryMeta = [import.meta.main, import.meta.url]; "# } "waiting" | "later" => { r#" export const value = await testReady(); export const main = import.meta.main; "# } "observer" => { r#" const [a, b] = await Promise.all([import('waiting'), import('waiting')]); globalThis.observed = [a === b, a.value, a.main]; "# } _ => "globalThis.otherMain = import.meta.main;", } .into()) } } let calls = std::rc::Rc::new(std::cell::Cell::new(0)); let counter = calls.clone(); let mut runtime = Runtime::new(RuntimeOptions { module_loader: Some(std::rc::Rc::new(Loader)), extensions: vec![Extension { name: "ext:ready", dependencies: &[], modules: &[( "ext:ready/main.js", "import {ready} from 'ext:core/ops'; globalThis.testReady = ready;", )], entry_point: Some("ext:ready/main.js"), ops: vec![Op::asynchronous("ready", move |_: ()| { counter.set(counter.get() + 1); async { Ok::<_, io::Error>(42) } })], }], }) .unwrap(); runtime.execute_module("entry").unwrap(); runtime.execute_module("entry").unwrap(); runtime.execute_module("other").unwrap(); let state: Value = runtime .execute_script( "check.js", r#" ({ observed, entryMeta, entryRuns, otherMain }) "#, ) .unwrap(); assert_eq!( state, json!({ "observed": [true, 42, false], "entryMeta": [true, "test:entry"], "entryRuns": 1, "otherMain": false, }) ); assert_eq!(calls.get(), 1); let result: Value = runtime .execute_script( "launch.js", r#" globalThis.launched = null; import('later').then(ns => { globalThis.launched = ns.value; }); launched; "#, ) .unwrap(); assert_eq!(result, Value::Null); assert_eq!( runtime .execute_script::("check.js", "launched") .unwrap(), Value::Null ); runtime.run_event_loop().unwrap(); assert_eq!(calls.get(), 2); assert_eq!( runtime .execute_script::("check.js", "launched") .unwrap(), 42 ); } #[test] fn registered_modules_capture_exports_without_a_loader() { let mut runtime = Runtime::new(RuntimeOptions::default()).unwrap(); runtime .execute_script::<()>( "bootstrap", "globalThis.values = { answer: 42, object: {} };", ) .unwrap(); runtime .register_module("test:values", |scope| { let key = avril_core::v8::String::new(scope, "values")?; let global = scope.get_current_context().global(scope); avril_core::v8::Local::::try_from( global.get(scope, key.into())?, ) .ok() }) .unwrap(); assert!( runtime .register_module("test:values", |scope| Some(avril_core::v8::Object::new( scope ))) .unwrap_err() .to_string() .contains("Module already registered") ); runtime.execute_script::<()>("modify", "globalThis.original = values.object; values.answer = 0; values.object = {}; globalThis.values = null;").unwrap(); runtime.execute_module("test:values").unwrap(); runtime.execute_script::<()>("import", "import('test:values').then(ns => { globalThis.answer = ns.answer; globalThis.same = ns.object === original; globalThis.namespace = ns; });").unwrap(); runtime.run_event_loop().unwrap(); assert_eq!( runtime.execute_script::("answer", "answer").unwrap(), 42 ); assert!(runtime.execute_script::("identity", "same").unwrap()); runtime .execute_script::<()>( "reimport", "import('test:values').then(ns => { globalThis.cached = ns === namespace; });", ) .unwrap(); runtime.run_event_loop().unwrap(); assert!(runtime.execute_script::("cache", "cached").unwrap()); assert!( runtime .execute_module("missing") .unwrap_err() .to_string() .contains("No module loader configured") ); } #[test] fn event_loop_runs_finalization_cleanup_tasks() { let mut runtime = Runtime::new(RuntimeOptions { extensions: vec![Extension { name: "ext:gc-test", dependencies: &[], modules: &[( "ext:gc-test/main.js", "import {collect} from 'ext:core/ops'; globalThis.collect = collect;", )], entry_point: Some("ext:gc-test/main.js"), ops: vec![Op::with_scope("collect", |scope, (): ()| { scope.low_memory_notification(); Ok::<_, io::Error>(()) })], }], ..Default::default() }) .unwrap(); runtime .execute_script::<()>( "garbage", r#" globalThis.cleaned = 0; globalThis.registry = new FinalizationRegistry(() => cleaned++); (()=>{for(let i=0;i<100;i++)registry.register({},i);})(); collect(); collect(); "#, ) .unwrap(); runtime.run_event_loop().unwrap(); assert!( runtime .execute_script::("cleaned", "cleaned > 0") .unwrap() ); } #[test] fn custom_stack_hooks_do_not_expose_private_formatter_functions() { let mut runtime = Runtime::new(RuntimeOptions::default()).unwrap(); assert!(runtime.execute_script::("hooks.js",r#" let nested; const hook=()=>{nested=new Error('inside hook');return 'custom';}; Error.prepareStackTrace=hook; new Error('trigger').stack; Error.prepareStackTrace=(_,frames)=>frames; try { const frames=nested.stack; if (!frames.length) throw Error('Missing frames'); for (const frame of frames) { const fn=frame.getFunction(); if (fn !== undefined && fn !== hook) throw Error('Private formatter function exposed'); } } finally {delete Error.prepareStackTrace;} true; "#).unwrap()); }