Skip to content
File

Blob: src/rust/jsg-test/tests/resource_callback.rs

rust710 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 
5//! Tests for resource method callbacks.
6//!
7//! These tests ensure that resource methods can be called from JavaScript and that
8//! the resource pointer is correctly unwrapped. This specifically validates that
9//! V8's internal field embedder data type tags are used correctly when getting
10//! aligned pointers from internal fields.
11 
12use std::cell::Cell;
13use std::rc::Rc;
14 
15use jsg::ExceptionType;
16use jsg::Number;
17use jsg::ToJS;
18use jsg_macros::jsg_constructor;
19use jsg_macros::jsg_method;
20use jsg_macros::jsg_resource;
21use jsg_macros::jsg_static_constant;
22 
23#[jsg_resource]
24struct EchoResource {
25 prefix: String,
26}
27 
28#[jsg_resource]
29#[expect(clippy::unnecessary_wraps)]
30impl EchoResource {
31 #[jsg_method]
32 pub fn echo(&self, message: String) -> Result<String, jsg::Error> {
33 Ok(format!("{}{}", self.prefix, message))
34 }
35 
36 #[jsg_method]
37 pub fn greet(&self, name: &str) -> String {
38 format!("{}{}!", self.prefix, name)
39 }
40}
41 
42#[jsg_resource]
43struct DirectReturnResource {
44 name: String,
45 counter: Rc<Cell<u32>>,
46}
47 
48#[jsg_resource]
49impl DirectReturnResource {
50 #[jsg_method]
51 pub fn get_name(&self) -> String {
52 self.name.clone()
53 }
54 
55 #[jsg_method]
56 pub fn is_valid(&self) -> bool {
57 !self.name.is_empty()
58 }
59 
60 #[jsg_method]
61 pub fn get_counter(&self) -> jsg::Number {
62 jsg::Number::from(self.counter.get())
63 }
64 
65 #[jsg_method]
66 pub fn increment_counter(&self) {
67 self.counter.set(self.counter.get() + 1);
68 }
69 
70 #[jsg_method]
71 pub fn maybe_name(&self) -> Option<String> {
72 Some(self.name.clone()).filter(|s| !s.is_empty())
73 }
74}
75 
76/// Validates that resource methods can be called from JavaScript.
77/// This test ensures the embedder data type tag is correctly used when
78/// unwrapping resource pointers from V8 internal fields.
79#[test]
80fn resource_method_callback_receives_correct_self() {
81 let harness = crate::Harness::new();
82 harness.run_in_context(|lock, ctx| {
83 let resource = jsg::Rc::new(EchoResource {
84 prefix: "Hello, ".to_owned(),
85 });
86 let wrapped = resource.to_js(lock);
87 ctx.set_global("echoResource", wrapped);
88 
89 // Call the method from JavaScript
90 let result: String = ctx.eval(lock, "echoResource.echo('World!')").unwrap();
91 assert_eq!(result, "Hello, World!");
92 Ok(())
93 });
94}
95 
96/// Validates that multiple method calls work correctly on the same resource.
97#[test]
98fn resource_method_can_be_called_multiple_times() {
99 let harness = crate::Harness::new();
100 harness.run_in_context(|lock, ctx| {
101 let resource = jsg::Rc::new(EchoResource {
102 prefix: ">> ".to_owned(),
103 });
104 let wrapped = resource.to_js(lock);
105 ctx.set_global("echo", wrapped);
106 
107 // First call
108 let result: String = ctx.eval(lock, "echo.echo('first')").unwrap();
109 assert_eq!(result, ">> first");
110 
111 // Second call
112 let result: String = ctx.eval(lock, "echo.echo('second')").unwrap();
113 assert_eq!(result, ">> second");
114 Ok(())
115 });
116}
117 
118/// Validates that methods can accept &str parameters.
119#[test]
120fn resource_method_accepts_str_ref_parameter() {
121 let harness = crate::Harness::new();
122 harness.run_in_context(|lock, ctx| {
123 let resource = jsg::Rc::new(EchoResource {
124 prefix: "Hello, ".to_owned(),
125 });
126 let wrapped = resource.to_js(lock);
127 ctx.set_global("echo", wrapped);
128 
129 let result: String = ctx.eval(lock, "echo.greet('World')").unwrap();
130 assert_eq!(result, "Hello, World!");
131 Ok(())
132 });
133}
134 
135/// Validates that methods can return values directly without Result wrapper.
136#[test]
137fn resource_method_returns_non_result_values() {
138 let harness = crate::Harness::new();
139 harness.run_in_context(|lock, ctx| {
140 let counter = Rc::new(Cell::new(42));
141 let resource = jsg::Rc::new(DirectReturnResource {
142 name: "TestResource".to_owned(),
143 counter: counter.clone(),
144 });
145 let wrapped = resource.to_js(lock);
146 ctx.set_global("resource", wrapped);
147 
148 // Test getString returns string
149 let result: String = ctx.eval(lock, "resource.getName()").unwrap();
150 assert_eq!(result, "TestResource");
151 
152 // Test isValid returns boolean
153 let result: bool = ctx.eval(lock, "resource.isValid()").unwrap();
154 assert!(result);
155 
156 // Test getCounter returns number
157 let result: Number = ctx.eval(lock, "resource.getCounter()").unwrap();
158 assert!((result.value() - 42.0).abs() < f64::EPSILON);
159 
160 // Test incrementCounter returns undefined (we just check it doesn't error)
161 let _: Option<bool> = ctx.eval(lock, "resource.incrementCounter()").unwrap();
162 assert_eq!(counter.get(), 43);
163 
164 // Test maybeName returns string for Some
165 let result: String = ctx.eval(lock, "resource.maybeName()").unwrap();
166 assert_eq!(result, "TestResource");
167 Ok(())
168 });
169}
170 
171#[jsg_resource]
172struct MathResource;
173 
174#[jsg_resource]
175impl MathResource {
176 #[jsg_method]
177 pub fn add(a: Number, b: Number) -> Number {
178 Number::new(a.value() + b.value())
179 }
180 
181 #[jsg_method]
182 pub fn greet(name: String) -> String {
183 format!("Hello, {name}!")
184 }
185 
186 #[jsg_method]
187 pub fn divide(a: Number, b: Number) -> Result<Number, jsg::Error> {
188 if b.value() == 0.0 {
189 return Err(jsg::Error::new_range_error("Division by zero"));
190 }
191 Ok(Number::new(a.value() / b.value()))
192 }
193 
194 #[jsg_method]
195 pub fn get_prefix(&self) -> String {
196 "math".to_owned()
197 }
198}
199 
200/// Validates that methods without &self are registered as static methods on the class.
201#[test]
202fn static_method_callable_on_class() {
203 let harness = crate::Harness::new();
204 harness.run_in_context(|lock, ctx| {
205 let constructor = jsg::resource::function_template_of::<MathResource>(lock);
206 ctx.set_global("MathResource", constructor.into());
207 
208 let result: Number = ctx.eval(lock, "MathResource.add(2, 3)").unwrap();
209 assert!((result.value() - 5.0).abs() < f64::EPSILON);
210 
211 let result: String = ctx.eval(lock, "MathResource.greet('World')").unwrap();
212 assert_eq!(result, "Hello, World!");
213 Ok(())
214 });
215}
216 
217/// Validates that instance methods still work when static methods are present.
218#[test]
219fn instance_and_static_methods_coexist() {
220 let harness = crate::Harness::new();
221 harness.run_in_context(|lock, ctx| {
222 // Expose the class constructor as a global
223 let constructor = jsg::resource::function_template_of::<MathResource>(lock);
224 ctx.set_global("MathResource", constructor.into());
225 
226 // Allocate and expose an instance as a global
227 let resource = jsg::Rc::new(MathResource);
228 let wrapped = resource.to_js(lock);
229 ctx.set_global("math", wrapped);
230 
231 // Instance method works on the object
232 let result: String = ctx.eval(lock, "math.getPrefix()").unwrap();
233 assert_eq!(result, "math");
234 
235 // Static method works on the class
236 let result: Number = ctx.eval(lock, "MathResource.add(10, 20)").unwrap();
237 assert!((result.value() - 30.0).abs() < f64::EPSILON);
238 
239 // Static methods are NOT on the instance
240 let is_undefined: bool = ctx.eval(lock, "typeof math.add === 'undefined'").unwrap();
241 assert!(is_undefined);
242 Ok(())
243 });
244}
245 
246/// Validates that static methods with Result return type work on the success path.
247#[test]
248fn static_method_result_return_type() {
249 let harness = crate::Harness::new();
250 harness.run_in_context(|lock, ctx| {
251 let constructor = jsg::resource::function_template_of::<MathResource>(lock);
252 ctx.set_global("MathResource", constructor.into());
253 
254 let result: String = ctx.eval(lock, "MathResource.greet('Rust')").unwrap();
255 assert_eq!(result, "Hello, Rust!");
256 Ok(())
257 });
258}
259 
260/// Validates that static methods propagate JS exceptions from `Result::Err`.
261#[test]
262fn static_method_throws_exception() {
263 let harness = crate::Harness::new();
264 harness.run_in_context(|lock, ctx| {
265 let constructor = jsg::resource::function_template_of::<MathResource>(lock);
266 ctx.set_global("MathResource", constructor.into());
267 
268 // Valid call succeeds
269 let result: Number = ctx.eval(lock, "MathResource.divide(10, 2)").unwrap();
270 assert!((result.value() - 5.0).abs() < f64::EPSILON);
271 
272 // Division by zero throws a RangeError
273 let err = ctx
274 .eval::<Number>(lock, "MathResource.divide(1, 0)")
275 .unwrap_err()
276 .unwrap_jsg_err(lock);
277 assert_eq!(err.name, ExceptionType::RangeError);
278 assert!(err.message.contains("Division by zero"));
279 Ok(())
280 });
281}
282 
283/// Validates that Option<T> returns null for None.
284#[test]
285fn resource_method_returns_null_for_none() {
286 let harness = crate::Harness::new();
287 harness.run_in_context(|lock, ctx| {
288 let resource = jsg::Rc::new(DirectReturnResource {
289 name: String::new(),
290 counter: Rc::new(Cell::new(0)),
291 });
292 let wrapped = resource.to_js(lock);
293 ctx.set_global("resource", wrapped);
294 
295 let result: Option<String> = ctx.eval(lock, "resource.maybeName()").unwrap();
296 assert!(result.is_none());
297 Ok(())
298 });
299}
300 
301// =============================================================================
302// Static constant tests
303// =============================================================================
304 
305#[jsg_resource]
306struct ConstantResource;
307 
308#[jsg_resource]
309impl ConstantResource {
310 #[jsg_static_constant]
311 pub const MAX_SIZE: u32 = 1024;
312 
313 #[jsg_static_constant]
314 pub const STATUS_OK: i32 = 0;
315 
316 #[jsg_static_constant]
317 pub const STATUS_ERROR: i32 = -1;
318 
319 #[jsg_static_constant]
320 pub const SCALE_FACTOR: f64 = 2.5;
321 
322 #[jsg_method]
323 pub fn get_name(&self) -> String {
324 "constant_resource".to_owned()
325 }
326}
327 
328/// Validates that static constants are accessible on the constructor.
329#[test]
330fn static_constant_accessible_on_constructor() {
331 let harness = crate::Harness::new();
332 harness.run_in_context(|lock, ctx| {
333 let constructor = jsg::resource::function_template_of::<ConstantResource>(lock);
334 ctx.set_global("ConstantResource", constructor.into());
335 
336 let result: Number = ctx.eval(lock, "ConstantResource.MAX_SIZE").unwrap();
337 assert!((result.value() - 1024.0).abs() < f64::EPSILON);
338 
339 let result: Number = ctx.eval(lock, "ConstantResource.STATUS_OK").unwrap();
340 assert!((result.value() - 0.0).abs() < f64::EPSILON);
341 
342 let result: Number = ctx.eval(lock, "ConstantResource.STATUS_ERROR").unwrap();
343 assert!((result.value() - (-1.0)).abs() < f64::EPSILON);
344 
345 let result: Number = ctx.eval(lock, "ConstantResource.SCALE_FACTOR").unwrap();
346 assert!((result.value() - 2.5).abs() < f64::EPSILON);
347 Ok(())
348 });
349}
350 
351/// Validates that static constants are also accessible on instances (via prototype).
352#[test]
353fn static_constant_accessible_on_instance() {
354 let harness = crate::Harness::new();
355 harness.run_in_context(|lock, ctx| {
356 let resource = jsg::Rc::new(ConstantResource {});
357 let wrapped = resource.to_js(lock);
358 ctx.set_global("obj", wrapped);
359 
360 let result: Number = ctx.eval(lock, "obj.MAX_SIZE").unwrap();
361 assert!((result.value() - 1024.0).abs() < f64::EPSILON);
362 
363 let result: Number = ctx.eval(lock, "obj.STATUS_OK").unwrap();
364 assert!((result.value() - 0.0).abs() < f64::EPSILON);
365 Ok(())
366 });
367}
368 
369/// Validates that static constants are read-only (not writable).
370#[test]
371fn static_constant_is_read_only() {
372 let harness = crate::Harness::new();
373 harness.run_in_context(|lock, ctx| {
374 let constructor = jsg::resource::function_template_of::<ConstantResource>(lock);
375 ctx.set_global("ConstantResource", constructor.into());
376 
377 // Attempt to overwrite should silently fail (strict mode would throw).
378 // The value should remain unchanged.
379 let result: Number = ctx
380 .eval(
381 lock,
382 "ConstantResource.MAX_SIZE = 9999; ConstantResource.MAX_SIZE",
383 )
384 .unwrap();
385 assert!((result.value() - 1024.0).abs() < f64::EPSILON);
386 Ok(())
387 });
388}
389 
390/// Validates that static constants coexist with methods.
391#[test]
392fn static_constant_coexists_with_methods() {
393 let harness = crate::Harness::new();
394 harness.run_in_context(|lock, ctx| {
395 let resource = jsg::Rc::new(ConstantResource {});
396 
397 let constructor = jsg::resource::function_template_of::<ConstantResource>(lock);
398 ctx.set_global("ConstantResource", constructor.into());
399 
400 let wrapped = resource.to_js(lock);
401 ctx.set_global("obj", wrapped);
402 
403 // Instance method works
404 let result: String = ctx.eval(lock, "obj.getName()").unwrap();
405 assert_eq!(result, "constant_resource");
406 
407 // Static constant works on constructor
408 let result: Number = ctx.eval(lock, "ConstantResource.MAX_SIZE").unwrap();
409 assert!((result.value() - 1024.0).abs() < f64::EPSILON);
410 
411 // Static constant works on instance
412 let result: Number = ctx.eval(lock, "obj.MAX_SIZE").unwrap();
413 assert!((result.value() - 1024.0).abs() < f64::EPSILON);
414 Ok(())
415 });
416}
417 
418// =============================================================================
419// catch_panic tests
420// =============================================================================
421 
422/// A resource whose `panic_now` method unconditionally panics.
423/// Used to verify that a panic inside a `#[jsg_method]` callback is caught and
424/// converted to a JavaScript exception rather than aborting the process.
425#[jsg_resource]
426struct PanicResource;
427 
428#[jsg_resource]
429impl PanicResource {
430 #[jsg_method]
431 pub fn panic_now(&self) {
432 panic!("intentional panic in jsg_method");
433 }
434}
435 
436/// Full round-trip: a JS "request handler" function calls a Rust-backed API
437/// method that panics. Verifies that:
438///
439/// 1. `catch_panic` converts the panic to an `"internal error"` JS exception
440/// whose message does NOT expose the raw Rust panic string to JS.
441/// 2. `terminate_execution()` is called so no further JS executes.
442/// 3. The process is not aborted.
443///
444/// This mirrors a real Worker fetch handler that delegates to a Rust-backed
445/// binding: the panic must error the request cleanly and terminate the isolate.
446#[test]
447fn panic_in_rust_backed_api_errors_request_and_terminates_isolate() {
448 let harness = crate::Harness::new();
449 harness.run_in_context(|lock, ctx| {
450 let resource = jsg::Rc::new(PanicResource);
451 let wrapped = resource.to_js(lock);
452 ctx.set_global("obj", wrapped);
453 
454 // Simulate a fetch-handler that delegates to a Rust-backed binding.
455 // The handler calls `obj.panicNow()` — just like real Worker code
456 // would call into a Rust-implemented API method.
457 ctx.eval_raw("function handleRequest() { while(true) { obj.panicNow(); } throw null; }")
458 .unwrap();
459 
460 // Invoke the handler — the panic inside panicNow() is caught by
461 // catch_panic, which calls throw_internal_error() then
462 // terminate_execution(). The internal error exception is set first
463 // and is visible to eval's TryCatch; it carries "internal error" in
464 // its message while hiding the raw panic string from JS.
465 let err = ctx
466 .eval::<bool>(lock, "handleRequest()")
467 .unwrap_err()
468 .unwrap_jsg_err(lock);
469 
470 assert!(
471 err.message.contains("internal error"),
472 "JS error message should say \"internal error\", got: {:?}",
473 err.message
474 );
475 assert!(
476 !err.message.contains("intentional panic in jsg_method"),
477 "raw panic message must not be exposed to JS, got: {:?}",
478 err.message
479 );
480 
481 Ok(())
482 });
483}
484 
485// =============================================================================
486// Receiver guard tests
487// =============================================================================
488 
489/// Validates that destructuring an instance method and calling it without a
490/// receiver throws a `TypeError: Illegal invocation` rather than reaching Rust.
491///
492/// This tests the `v8::Signature` guard described in the SAFETY comment of the
493/// `#[jsg_method]` callback generated by `jsg-macros`.
494#[test]
495fn instance_method_throws_on_missing_receiver() {
496 let harness = crate::Harness::new();
497 harness.run_in_context(|lock, ctx| {
498 let resource = jsg::Rc::new(EchoResource {
499 prefix: "Hello, ".to_owned(),
500 });
501 let wrapped = resource.to_js(lock);
502 ctx.set_global("obj", wrapped);
503 
504 // Destructure the method and call it with no receiver (`this` === global
505 // / undefined in strict mode). V8's Signature check must fire before the
506 // Rust callback is invoked.
507 let err = ctx
508 .eval::<String>(lock, "const { echo } = obj; echo('world')")
509 .unwrap_err()
510 .unwrap_jsg_err(lock);
511 assert_eq!(err.name, ExceptionType::TypeError);
512 assert!(
513 err.message.to_lowercase().contains("illegal invocation")
514 || err.message.to_lowercase().contains("illegal"),
515 "unexpected error message: {}",
516 err.message
517 );
518 Ok(())
519 });
520}
521 
522/// Validates that `Reflect.apply` with a plain-object receiver also throws
523/// `TypeError: Illegal invocation`.
524#[test]
525fn instance_method_throws_on_wrong_receiver_via_reflect_apply() {
526 let harness = crate::Harness::new();
527 harness.run_in_context(|lock, ctx| {
528 let resource = jsg::Rc::new(EchoResource {
529 prefix: "Hello, ".to_owned(),
530 });
531 let wrapped = resource.to_js(lock);
532 ctx.set_global("obj", wrapped);
533 
534 let err = ctx
535 .eval::<String>(lock, "Reflect.apply(obj.echo, {}, ['world'])")
536 .unwrap_err()
537 .unwrap_jsg_err(lock);
538 assert_eq!(err.name, ExceptionType::TypeError);
539 assert!(
540 err.message.to_lowercase().contains("illegal invocation")
541 || err.message.to_lowercase().contains("illegal"),
542 "unexpected error message: {}",
543 err.message
544 );
545 Ok(())
546 });
547}
548 
549/// Validates that a method called on the correct receiver still works after
550/// the above receiver-guard checks, confirming normal dispatch is unaffected.
551#[test]
552fn instance_method_works_on_correct_receiver() {
553 let harness = crate::Harness::new();
554 harness.run_in_context(|lock, ctx| {
555 let resource = jsg::Rc::new(EchoResource {
556 prefix: "Hi, ".to_owned(),
557 });
558 let wrapped = resource.to_js(lock);
559 ctx.set_global("obj", wrapped);
560 
561 // `Function.prototype.call` with the correct `this` must succeed.
562 let result: String = ctx.eval(lock, "obj.echo.call(obj, 'world')").unwrap();
563 assert_eq!(result, "Hi, world");
564 Ok(())
565 });
566}
567 
568// =============================================================================
569// Constructor tests
570// =============================================================================
571 
572#[jsg_resource]
573struct Greeting {
574 message: String,
575}
576 
577#[jsg_resource]
578impl Greeting {
579 #[jsg_constructor]
580 fn constructor(message: String) -> Self {
581 Self { message }
582 }
583 
584 #[jsg_method]
585 fn get_message(&self) -> String {
586 self.message.clone()
587 }
588}
589 
590/// Resources without `#[jsg_constructor]` should throw when called with `new`.
591#[test]
592fn resource_without_constructor_throws() {
593 let harness = crate::Harness::new();
594 harness.run_in_context(|lock, ctx| {
595 let constructor = jsg::resource::function_template_of::<EchoResource>(lock);
596 ctx.set_global("EchoResource", constructor.into());
597 
598 let result: Result<Number, _> = ctx.eval(lock, "new EchoResource('hi')");
599 assert!(result.is_err(), "should throw illegal constructor");
600 Ok(())
601 });
602}
603 
604/// A `#[jsg_constructor]` method is callable from JavaScript via `new`.
605#[test]
606fn constructor_creates_instance() {
607 let harness = crate::Harness::new();
608 harness.run_in_context(|lock, ctx| {
609 let constructor = jsg::resource::function_template_of::<Greeting>(lock);
610 ctx.set_global("Greeting", constructor.into());
611 
612 let result: String = ctx
613 .eval(lock, "new Greeting('hello').getMessage()")
614 .unwrap();
615 assert_eq!(result, "hello");
616 Ok(())
617 });
618}
619 
620/// Constructor arguments are converted from JS types via `FromJS`.
621#[test]
622fn constructor_converts_arguments() {
623 let harness = crate::Harness::new();
624 harness.run_in_context(|lock, ctx| {
625 let constructor = jsg::resource::function_template_of::<Greeting>(lock);
626 ctx.set_global("Greeting", constructor.into());
627 
628 // Number is coerced to string by V8
629 let result: String = ctx
630 .eval(lock, "new Greeting(String(42)).getMessage()")
631 .unwrap();
632 assert_eq!(result, "42");
633 Ok(())
634 });
635}
636 
637/// Multiple `new` calls create distinct JS objects.
638#[test]
639fn constructor_creates_distinct_objects() {
640 let harness = crate::Harness::new();
641 harness.run_in_context(|lock, ctx| {
642 let constructor = jsg::resource::function_template_of::<Greeting>(lock);
643 ctx.set_global("Greeting", constructor.into());
644 
645 let result: String = ctx
646 .eval(
647 lock,
648 "let a = new Greeting('one'); let b = new Greeting('two'); \
649 a.getMessage() + ',' + b.getMessage()",
650 )
651 .unwrap();
652 assert_eq!(result, "one,two");
653 Ok(())
654 });
655}
656 
657/// `instanceof` works correctly for constructor-created instances.
658#[test]
659fn constructor_instanceof_works() {
660 let harness = crate::Harness::new();
661 harness.run_in_context(|lock, ctx| {
662 let constructor = jsg::resource::function_template_of::<Greeting>(lock);
663 ctx.set_global("Greeting", constructor.into());
664 
665 let result: String = ctx
666 .eval(
667 lock,
668 "let g = new Greeting('test'); \
669 String(g instanceof Greeting)",
670 )
671 .unwrap();
672 assert_eq!(result, "true");
673 Ok(())
674 });
675}
676 
677// Constructor with Lock parameter
678 
679#[jsg_resource]
680struct Counter {
681 value: Number,
682}
683 
684#[jsg_resource]
685impl Counter {
686 #[jsg_constructor]
687 fn constructor(_lock: &mut jsg::Lock, value: Number) -> Self {
688 Self { value }
689 }
690 
691 #[jsg_method]
692 fn get_value(&self) -> Number {
693 self.value
694 }
695}
696 
697/// `#[jsg_constructor]` with a `Lock` parameter works.
698#[test]
699fn constructor_with_lock_parameter() {
700 let harness = crate::Harness::new();
701 harness.run_in_context(|lock, ctx| {
702 let constructor = jsg::resource::function_template_of::<Counter>(lock);
703 ctx.set_global("Counter", constructor.into());
704 
705 let result: Number = ctx.eval(lock, "new Counter(99).getValue()").unwrap();
706 assert!((result.value() - 99.0).abs() < f64::EPSILON);
707 Ok(())
708 });
709}