File
Blob: src/rust/jsg-test/lib.rs
| 1 | // Copyright (c) 2026 Cloudflare, Inc. |
| 2 | // Licensed under the Apache 2.0 license found in the LICENSE file or at: |
| 3 | // https://opensource.org/licenses/Apache-2.0 |
| 4 | |
| 5 | use std::pin::Pin; |
| 6 | |
| 7 | use jsg::FromJS; |
| 8 | use jsg::v8; |
| 9 | use kj_rs::KjOwn; |
| 10 | |
| 11 | #[cfg(test)] |
| 12 | mod tests; |
| 13 | |
| 14 | #[cxx::bridge(namespace = "workerd::rust::jsg_test")] |
| 15 | mod ffi { |
| 16 | #[namespace = "workerd::rust::jsg"] |
| 17 | unsafe extern "C++" { |
| 18 | include!("workerd/rust/jsg/ffi.h"); |
| 19 | type Isolate = jsg::v8::ffi::Isolate; |
| 20 | type Local = jsg::v8::ffi::Local; |
| 21 | } |
| 22 | |
| 23 | #[derive(Debug)] |
| 24 | struct EvalResult { |
| 25 | success: bool, |
| 26 | value: KjMaybe<Local>, |
| 27 | } |
| 28 | |
| 29 | enum GcType { |
| 30 | /// Full (major) GC — collects both young and old generations, plus cppgc heap. |
| 31 | Full = 0, |
| 32 | /// Minor (scavenge) GC — collects only the young generation. |
| 33 | Minor = 1, |
| 34 | } |
| 35 | |
| 36 | unsafe extern "C++" { |
| 37 | include!("workerd/rust/jsg-test/ffi.h"); |
| 38 | type TestHarness; |
| 39 | type EvalContext; |
| 40 | |
| 41 | pub unsafe fn create_test_harness() -> KjOwn<TestHarness>; |
| 42 | pub unsafe fn run_in_context( |
| 43 | self: &TestHarness, |
| 44 | data: usize, /* callback */ |
| 45 | callback: unsafe fn(usize /* callback */, *mut Isolate, Pin<&mut EvalContext>), |
| 46 | ); |
| 47 | |
| 48 | pub unsafe fn eval(self: &EvalContext, code: &str) -> EvalResult; |
| 49 | pub unsafe fn set_global(self: &EvalContext, name: &str, value: Local); |
| 50 | |
| 51 | /// Triggers garbage collection for testing purposes. |
| 52 | /// Note: For GC to actually collect objects, they must not be reachable from the |
| 53 | /// current HandleScope. |
| 54 | #[expect(clippy::allow_attributes)] // Only used in tests, but #[expect(dead_code)] fails during test builds |
| 55 | #[allow(dead_code)] |
| 56 | pub unsafe fn request_gc(isolate: *mut Isolate, gc_type: GcType); |
| 57 | |
| 58 | /// Creates a V8 object with the C++ `WORKERD_WRAPPABLE_TAG` in its internal fields. |
| 59 | /// Used to test that Rust unwrap correctly rejects non-Rust wrappable objects. |
| 60 | #[expect(clippy::allow_attributes)] |
| 61 | #[allow(dead_code)] |
| 62 | pub unsafe fn create_cpp_tagged_object(isolate: *mut Isolate) -> Local; |
| 63 | |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | pub struct Harness(KjOwn<ffi::TestHarness>); |
| 68 | |
| 69 | pub struct EvalContext<'a> { |
| 70 | inner: &'a ffi::EvalContext, |
| 71 | isolate: v8::IsolatePtr, |
| 72 | } |
| 73 | |
| 74 | #[derive(Debug)] |
| 75 | pub enum EvalError<'a> { |
| 76 | UncoercibleResult { |
| 77 | value: v8::Local<'a, v8::Value>, |
| 78 | message: String, |
| 79 | }, |
| 80 | Exception(v8::Local<'a, v8::Value>), |
| 81 | EvalFailed, |
| 82 | } |
| 83 | |
| 84 | impl EvalError<'_> { |
| 85 | /// Extracts a `jsg::Error` from an `EvalError::Exception` variant. |
| 86 | /// |
| 87 | /// # Panics |
| 88 | /// |
| 89 | /// Panics if `self` is not `EvalError::Exception`, or if the value cannot be converted to a |
| 90 | /// `jsg::Error`. |
| 91 | pub fn unwrap_jsg_err(&self, lock: &mut jsg::Lock) -> jsg::Error { |
| 92 | match self { |
| 93 | EvalError::Exception(value) => jsg::Error::from_js(lock, value.clone()) |
| 94 | .expect("Failed to convert exception to jsg::Error"), |
| 95 | _ => panic!("Unexpected error"), |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | impl EvalContext<'_> { |
| 100 | pub fn eval<T>(&self, lock: &mut jsg::Lock, code: &str) -> Result<T, EvalError<'_>> |
| 101 | where |
| 102 | T: jsg::FromJS<ResultType = T>, |
| 103 | { |
| 104 | // SAFETY: self.inner is a valid EvalContext from C++; code is a valid str. |
| 105 | let result = unsafe { self.inner.eval(code) }; |
| 106 | let opt_local: Option<v8::ffi::Local> = result.value.into(); |
| 107 | |
| 108 | if result.success { |
| 109 | match opt_local { |
| 110 | Some(local) => { |
| 111 | // SAFETY: self.isolate is valid and local is from a successful eval result. |
| 112 | let local = unsafe { v8::Local::from_ffi(self.isolate, local) }; |
| 113 | match T::from_js(lock, local.clone()) { |
| 114 | Err(e) => Err(EvalError::UncoercibleResult { |
| 115 | value: local, |
| 116 | message: e.to_string(), |
| 117 | }), |
| 118 | Ok(value) => Ok(value), |
| 119 | } |
| 120 | } |
| 121 | None => unreachable!(), |
| 122 | } |
| 123 | } else { |
| 124 | match opt_local { |
| 125 | Some(local) => { |
| 126 | // SAFETY: self.isolate is valid and local is from an eval exception. |
| 127 | let value = unsafe { v8::Local::from_ffi(self.isolate, local) }; |
| 128 | Err(EvalError::Exception(value)) |
| 129 | } |
| 130 | None => Err(EvalError::EvalFailed), |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /// Evaluates JavaScript code and returns the raw `Local<Value>` without conversion. |
| 136 | /// |
| 137 | /// Useful for obtaining handles (e.g. functions) that aren't `FromJS` types. |
| 138 | pub fn eval_raw(&self, code: &str) -> Result<v8::Local<'_, v8::Value>, EvalError<'_>> { |
| 139 | // SAFETY: self.inner is a valid EvalContext from C++; code is a valid str. |
| 140 | let result = unsafe { self.inner.eval(code) }; |
| 141 | let opt_local: Option<v8::ffi::Local> = result.value.into(); |
| 142 | |
| 143 | if result.success { |
| 144 | match opt_local { |
| 145 | // SAFETY: self.isolate is valid and local is from a successful eval result. |
| 146 | Some(local) => Ok(unsafe { v8::Local::from_ffi(self.isolate, local) }), |
| 147 | None => unreachable!(), |
| 148 | } |
| 149 | } else { |
| 150 | match opt_local { |
| 151 | Some(local) => { |
| 152 | // SAFETY: self.isolate is valid and local is from an eval exception. |
| 153 | let value = unsafe { v8::Local::from_ffi(self.isolate, local) }; |
| 154 | Err(EvalError::Exception(value)) |
| 155 | } |
| 156 | None => Err(EvalError::EvalFailed), |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | pub fn set_global(&self, name: &str, value: v8::Local<v8::Value>) { |
| 162 | // SAFETY: self.inner is a valid EvalContext and value is a valid Local handle. |
| 163 | unsafe { self.inner.set_global(name, value.into_ffi()) } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | impl Harness { |
| 168 | pub fn new() -> Self { |
| 169 | // SAFETY: create_test_harness initializes the V8 platform and returns a valid harness. |
| 170 | Self(unsafe { ffi::create_test_harness() }) |
| 171 | } |
| 172 | |
| 173 | /// Runs a callback within a V8 context. |
| 174 | /// |
| 175 | /// The callback is passed through C++ via a data pointer since CXX doesn't support |
| 176 | /// closures directly. The monomorphized trampoline function receives the pointer |
| 177 | /// and reconstructs the closure. |
| 178 | /// |
| 179 | /// The callback returns `Result<(), jsg::Error>` to allow use of the `?` operator. |
| 180 | /// If an error is returned, the test will panic. |
| 181 | pub fn run_in_context<F>(&self, callback: F) |
| 182 | where |
| 183 | F: FnOnce(&mut jsg::Lock, &mut EvalContext) -> Result<(), jsg::Error>, |
| 184 | { |
| 185 | #[expect(clippy::needless_pass_by_value)] |
| 186 | fn trampoline<F>( |
| 187 | data: usize, |
| 188 | isolate: *mut v8::ffi::Isolate, |
| 189 | context: Pin<&mut ffi::EvalContext>, |
| 190 | ) where |
| 191 | F: FnOnce(&mut jsg::Lock, &mut EvalContext) -> Result<(), jsg::Error>, |
| 192 | { |
| 193 | // SAFETY: data was cast from &raw mut Option<F> in run_in_context below. |
| 194 | let cb = unsafe { &mut *(data as *mut Option<F>) }; |
| 195 | if let Some(callback) = cb.take() { |
| 196 | // SAFETY: isolate is a valid pointer provided by the C++ test harness. |
| 197 | let isolate_ptr = unsafe { v8::IsolatePtr::from_ffi(isolate) }; |
| 198 | let mut eval_context = EvalContext { |
| 199 | inner: &context, |
| 200 | isolate: isolate_ptr, |
| 201 | }; |
| 202 | // SAFETY: isolate is a valid pointer provided by the C++ test harness. |
| 203 | let mut lock = unsafe { jsg::Lock::from_isolate_ptr(isolate) }; |
| 204 | if let Err(e) = callback(&mut lock, &mut eval_context) { |
| 205 | panic!("Test failed: {}: {}", e.name, e.message); |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | let mut callback = Some(callback); |
| 211 | // SAFETY: callback pointer is valid for the duration of run_in_context. |
| 212 | unsafe { |
| 213 | self.0 |
| 214 | .run_in_context(&raw mut callback as usize, trampoline::<F>); |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | pub fn request_gc(lock: &mut jsg::Lock) { |
| 219 | // SAFETY: isolate is valid and locked (guaranteed by Lock). |
| 220 | unsafe { ffi::request_gc(lock.isolate().as_ffi(), ffi::GcType::Full) }; |
| 221 | } |
| 222 | |
| 223 | pub fn request_minor_gc(lock: &mut jsg::Lock) { |
| 224 | // SAFETY: isolate is valid and locked (guaranteed by Lock). |
| 225 | unsafe { ffi::request_gc(lock.isolate().as_ffi(), ffi::GcType::Minor) }; |
| 226 | } |
| 227 | |
| 228 | /// Creates a V8 object tagged with the C++ `WORKERD_WRAPPABLE_TAG`. |
| 229 | /// Used to test that Rust unwrap rejects non-Rust wrappable objects. |
| 230 | pub fn create_cpp_tagged_object<'a>(lock: &mut jsg::Lock) -> v8::Local<'a, v8::Value> { |
| 231 | // SAFETY: isolate is valid and locked (guaranteed by Lock). |
| 232 | unsafe { |
| 233 | let local = ffi::create_cpp_tagged_object(lock.isolate().as_ffi()); |
| 234 | v8::Local::from_ffi(lock.isolate(), local) |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | impl Default for Harness { |
| 240 | fn default() -> Self { |
| 241 | Self::new() |
| 242 | } |
| 243 | } |