Skip to content
File

Blob: src/rust/jsg-test/lib.rs

rust244 lines
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 
5use std::pin::Pin;
6 
7use jsg::FromJS;
8use jsg::v8;
9use kj_rs::KjOwn;
10 
11#[cfg(test)]
12mod tests;
13 
14#[cxx::bridge(namespace = "workerd::rust::jsg_test")]
15mod 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 
67pub struct Harness(KjOwn<ffi::TestHarness>);
68 
69pub struct EvalContext<'a> {
70 inner: &'a ffi::EvalContext,
71 isolate: v8::IsolatePtr,
72}
73 
74#[derive(Debug)]
75pub 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 
84impl 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}
99impl 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 
167impl 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 
239impl Default for Harness {
240 fn default() -> Self {
241 Self::new()
242 }
243}