1use crate::api::{self, HostBridge};
10use bext_plugin_api::lifecycle::LifecyclePlugin;
11use bext_plugin_api::types::{PluginManifest, SandboxPermissions};
12use std::path::PathBuf;
13use std::sync::{Arc, Mutex};
14use std::time::Instant;
15
16pub struct QuickJsPluginConfig {
18 pub name: String,
19 pub path: PathBuf,
21 pub priority: u32,
22 pub permissions: SandboxPermissions,
23 pub config: serde_json::Value,
24}
25
26struct QuickJsPlugin {
28 manifest: PluginManifest,
29 priority: u32,
30 rt: rquickjs::Runtime,
31 ctx: rquickjs::Context,
32 bridge: Arc<HostBridge>,
33 deadline: Arc<Mutex<Option<Instant>>>,
35}
36
37pub struct QuickJsPluginRuntime {
44 storage_root: PathBuf,
45 plugins: Vec<QuickJsPlugin>,
46}
47
48impl QuickJsPluginRuntime {
49 pub fn new(storage_root: PathBuf) -> Self {
50 Self {
51 storage_root,
52 plugins: Vec::new(),
53 }
54 }
55
56 pub fn load_plugin(&mut self, config: QuickJsPluginConfig) -> Result<(), String> {
57 if !config.path.exists() {
58 return Err(format!("JS plugin not found: {}", config.path.display()));
59 }
60
61 tracing::info!(name = %config.name, path = %config.path.display(), "loading QuickJS plugin");
62
63 let source = std::fs::read_to_string(&config.path).map_err(|e| format!("read JS: {e}"))?;
64
65 let rt = rquickjs::Runtime::new().map_err(|e| format!("create QuickJS runtime: {e}"))?;
67
68 rt.set_memory_limit(config.permissions.max_memory_mb as usize * 1024 * 1024);
69 rt.set_max_stack_size(1024 * 1024); let deadline: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
73 let deadline_check = deadline.clone();
74 rt.set_interrupt_handler(Some(Box::new(move || {
75 if let Ok(guard) = deadline_check.lock() {
76 if let Some(dl) = *guard {
77 return Instant::now() > dl;
78 }
79 }
80 false
81 })));
82
83 let bridge = Arc::new(HostBridge::new(
85 config.name.clone(),
86 config.permissions,
87 &self.storage_root,
88 config.config,
89 ));
90
91 let ctx =
92 rquickjs::Context::full(&rt).map_err(|e| format!("create QuickJS context: {e}"))?;
93
94 let bridge_ref = bridge.clone();
96 ctx.with(|ctx| -> Result<(), String> {
97 api::register_globals(&ctx, bridge_ref)
98 .map_err(|e| format!("register globals: {e}"))?;
99
100 ctx.eval::<(), _>(source.as_bytes())
102 .map_err(|e| format!("eval plugin: {e}"))?;
103
104 Ok(())
105 })?;
106
107 let manifest = PluginManifest {
108 name: config.name.clone(),
109 version: "1.0.0".into(),
110 description: format!("QuickJS plugin: {}", config.path.display()),
111 capabilities: vec![bext_plugin_api::types::PluginCapability::Lifecycle],
112 provides_capabilities: Vec::new(),
113 requires_capabilities: Vec::new(),
114 };
115
116 tracing::info!(name = %manifest.name, "QuickJS plugin loaded");
117
118 self.plugins.push(QuickJsPlugin {
119 manifest,
120 priority: config.priority,
121 rt,
122 ctx,
123 bridge,
124 deadline,
125 });
126
127 Ok(())
128 }
129
130 pub fn into_lifecycle_plugins(&mut self) -> Vec<Box<dyn LifecyclePlugin>> {
131 std::mem::take(&mut self.plugins)
132 .into_iter()
133 .map(|p| {
134 let max_time_secs = p.bridge.permissions.max_time_secs;
135 Box::new(QuickJsLifecycleAdapter {
136 manifest: p.manifest,
137 priority: p.priority,
138 state: Mutex::new(QuickJsState {
139 ctx: p.ctx,
140 _rt: p.rt,
141 _bridge: p.bridge,
142 deadline: p.deadline,
143 max_time_secs,
144 }),
145 }) as Box<dyn LifecyclePlugin>
146 })
147 .collect()
148 }
149
150 pub fn len(&self) -> usize {
151 self.plugins.len()
152 }
153
154 pub fn is_empty(&self) -> bool {
155 self.plugins.is_empty()
156 }
157}
158
159struct QuickJsState {
164 ctx: rquickjs::Context,
165 _rt: rquickjs::Runtime,
166 _bridge: Arc<HostBridge>,
167 deadline: Arc<Mutex<Option<Instant>>>,
168 max_time_secs: u64,
169}
170
171struct QuickJsLifecycleAdapter {
177 manifest: PluginManifest,
178 priority: u32,
179 state: Mutex<QuickJsState>,
180}
181
182impl QuickJsLifecycleAdapter {
183 fn set_deadline(state: &QuickJsState, secs: u64) {
185 if let Ok(mut dl) = state.deadline.lock() {
186 *dl = Some(Instant::now() + std::time::Duration::from_secs(secs));
187 }
188 }
189
190 fn clear_deadline(state: &QuickJsState) {
191 if let Ok(mut dl) = state.deadline.lock() {
192 *dl = None;
193 }
194 }
195
196 fn call_lifecycle(
199 state: &QuickJsState,
200 fn_name: &str,
201 args_json: &[&str],
202 ) -> Result<(), String> {
203 Self::set_deadline(state, state.max_time_secs);
204 let result = state.ctx.with(|ctx| -> Result<(), String> {
205 let globals = ctx.globals();
206 let func: Option<rquickjs::Function> = globals.get(fn_name).ok();
207
208 let func = match func {
209 Some(f) if f.is_function() => f,
210 _ => return Ok(()), };
212
213 match args_json.len() {
215 0 => {
216 func.call::<_, ()>(())
217 .map_err(|e| format!("{fn_name}: {e}"))?;
218 }
219 1 => {
220 let arg: rquickjs::Value = ctx
221 .json_parse(args_json[0].to_string())
222 .unwrap_or(rquickjs::Value::new_undefined(ctx.clone()));
223 func.call::<_, ()>((arg,))
224 .map_err(|e| format!("{fn_name}: {e}"))?;
225 }
226 2 => {
227 let arg0: rquickjs::Value = ctx
228 .json_parse(args_json[0].to_string())
229 .unwrap_or(rquickjs::Value::new_undefined(ctx.clone()));
230 let arg1: rquickjs::Value = ctx
231 .json_parse(args_json[1].to_string())
232 .unwrap_or(rquickjs::Value::new_undefined(ctx.clone()));
233 func.call::<_, ()>((arg0, arg1))
234 .map_err(|e| format!("{fn_name}: {e}"))?;
235 }
236 _ => {
237 return Err(format!("{fn_name}: too many args (max 2)"));
238 }
239 }
240
241 Ok(())
242 });
243 Self::clear_deadline(state);
244 result
245 }
246}
247
248impl LifecyclePlugin for QuickJsLifecycleAdapter {
249 fn name(&self) -> &str {
250 &self.manifest.name
251 }
252
253 fn priority(&self) -> u32 {
254 self.priority
255 }
256
257 fn on_server_start(&self, config_json: &str) -> Result<(), String> {
258 let guard = self
259 .state
260 .lock()
261 .map_err(|e| format!("lock poisoned: {e}"))?;
262 Self::call_lifecycle(&guard, "onServerStart", &[config_json])
263 }
264
265 fn on_server_stop(&self) -> Result<(), String> {
266 let guard = self
267 .state
268 .lock()
269 .map_err(|e| format!("lock poisoned: {e}"))?;
270 Self::call_lifecycle(&guard, "onServerStop", &[])
271 }
272
273 fn on_request_complete(&self, event_json: &str) -> Result<(), String> {
274 let guard = self
275 .state
276 .lock()
277 .map_err(|e| format!("lock poisoned: {e}"))?;
278 Self::call_lifecycle(&guard, "onRequestComplete", &[event_json])
279 }
280
281 fn on_cache_write(&self, key: &str, tags_json: &str) -> Result<(), String> {
282 let guard = self
283 .state
284 .lock()
285 .map_err(|e| format!("lock poisoned: {e}"))?;
286 let key_json = serde_json::to_string(key).unwrap_or_default();
287 Self::call_lifecycle(&guard, "onCacheWrite", &[&key_json, tags_json])
288 }
289
290 fn on_cache_invalidate(&self, pattern: &str, count: u32) -> Result<(), String> {
291 let guard = self
292 .state
293 .lock()
294 .map_err(|e| format!("lock poisoned: {e}"))?;
295 let pattern_json = serde_json::to_string(pattern).unwrap_or_default();
296 let count_json = count.to_string();
297 Self::call_lifecycle(&guard, "onCacheInvalidate", &[&pattern_json, &count_json])
298 }
299
300 fn on_reload(&self) -> Result<(), String> {
301 let guard = self
302 .state
303 .lock()
304 .map_err(|e| format!("lock poisoned: {e}"))?;
305 Self::call_lifecycle(&guard, "onReload", &[])
306 }
307
308 fn cleanup(&self) -> Result<(), String> {
309 let guard = self
310 .state
311 .lock()
312 .map_err(|e| format!("lock poisoned: {e}"))?;
313 Self::call_lifecycle(&guard, "cleanup", &[])
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 fn write_temp_plugin(name: &str, source: &str) -> (tempfile::TempDir, PathBuf) {
322 let dir = tempfile::tempdir().expect("create tempdir");
323 let path = dir.path().join(format!("{name}.js"));
324 std::fs::write(&path, source).expect("write plugin file");
325 (dir, path)
326 }
327
328 fn load_lifecycle(
330 name: &str,
331 source: &str,
332 permissions: SandboxPermissions,
333 config: serde_json::Value,
334 ) -> (tempfile::TempDir, Box<dyn LifecyclePlugin>) {
335 let (dir, path) = write_temp_plugin(name, source);
336 let storage_root = dir.path().join("storage");
337 let _ = std::fs::create_dir_all(&storage_root);
338
339 let mut rt = QuickJsPluginRuntime::new(storage_root);
340 rt.load_plugin(QuickJsPluginConfig {
341 name: name.into(),
342 path,
343 priority: 500,
344 permissions,
345 config,
346 })
347 .expect("load plugin");
348
349 let mut plugins = rt.into_lifecycle_plugins();
350 assert_eq!(plugins.len(), 1);
351 let plugin = plugins.remove(0);
352 (dir, plugin)
353 }
354
355 #[test]
358 fn runtime_empty() {
359 let rt = QuickJsPluginRuntime::new(PathBuf::from("/tmp/bext-quickjs"));
360 assert!(rt.is_empty());
361 assert_eq!(rt.len(), 0);
362 }
363
364 #[test]
365 fn load_nonexistent_fails() {
366 let mut rt = QuickJsPluginRuntime::new(PathBuf::from("/tmp/bext-quickjs"));
367 let result = rt.load_plugin(QuickJsPluginConfig {
368 name: "test".into(),
369 path: "/nonexistent/plugin.js".into(),
370 priority: 1000,
371 permissions: SandboxPermissions::default(),
372 config: serde_json::Value::Null,
373 });
374 assert!(result.is_err());
375 assert!(result.unwrap_err().contains("not found"));
376 }
377
378 #[test]
381 fn on_server_start_called() {
382 let source = r#"
383 function onServerStart(config) {
384 // Store something to prove we ran
385 bext.storage.set("started", "yes");
386 }
387 "#;
388 let (dir, plugin) = load_lifecycle(
389 "starter",
390 source,
391 SandboxPermissions::default(),
392 serde_json::json!({}),
393 );
394
395 let result = plugin.on_server_start("{}");
396 assert!(result.is_ok(), "on_server_start failed: {:?}", result);
397
398 let storage_path = dir.path().join("storage").join("starter").join("started");
400 assert!(storage_path.exists(), "storage file should exist");
401 let val = std::fs::read_to_string(&storage_path).unwrap();
402 assert_eq!(val, "yes");
403 }
404
405 #[test]
408 fn no_functions_all_noop() {
409 let source = r#"
410 // This plugin defines no lifecycle hooks
411 var x = 42;
412 "#;
413 let (_dir, plugin) = load_lifecycle(
414 "empty",
415 source,
416 SandboxPermissions::default(),
417 serde_json::json!({}),
418 );
419
420 assert!(plugin.on_server_start("{}").is_ok());
421 assert!(plugin.on_server_stop().is_ok());
422 assert!(plugin.on_request_complete("{}").is_ok());
423 assert!(plugin.on_cache_write("key", "[]").is_ok());
424 assert!(plugin.on_cache_invalidate("*", 5).is_ok());
425 assert!(plugin.on_reload().is_ok());
426 assert!(plugin.cleanup().is_ok());
427 }
428
429 #[test]
432 fn on_request_complete_receives_event() {
433 let source = r#"
434 function onRequestComplete(event) {
435 bext.storage.set("status", String(event.status));
436 bext.storage.set("path", event.path);
437 }
438 "#;
439 let (dir, plugin) = load_lifecycle(
440 "reqlog",
441 source,
442 SandboxPermissions::default(),
443 serde_json::json!({}),
444 );
445
446 let event = serde_json::json!({
447 "path": "/api/products",
448 "method": "GET",
449 "status": 200,
450 "render_time_us": 1500
451 });
452 let result = plugin.on_request_complete(&event.to_string());
453 assert!(result.is_ok(), "on_request_complete failed: {:?}", result);
454
455 let storage_dir = dir.path().join("storage").join("reqlog");
456 assert_eq!(
457 std::fs::read_to_string(storage_dir.join("status")).unwrap(),
458 "200"
459 );
460 assert_eq!(
461 std::fs::read_to_string(storage_dir.join("path")).unwrap(),
462 "/api/products"
463 );
464 }
465
466 #[test]
469 fn plugin_manifest_name() {
470 let source = "var x = 1;";
471 let (_dir, plugin) = load_lifecycle(
472 "my-plugin",
473 source,
474 SandboxPermissions::default(),
475 serde_json::json!({}),
476 );
477
478 assert_eq!(plugin.name(), "my-plugin");
479 assert_eq!(plugin.priority(), 500);
480 }
481
482 #[test]
485 fn memory_limit_enforcement() {
486 let source = r#"
488 function onServerStart(config) {
489 // Allocate big arrays in a loop to bust the heap limit
490 var arrays = [];
491 for (var i = 0; i < 100000; i++) {
492 arrays.push(new Array(10000));
493 }
494 }
495 "#;
496 let (dir, path) = write_temp_plugin("memhog", source);
497 let storage_root = dir.path().join("storage");
498 let _ = std::fs::create_dir_all(&storage_root);
499
500 let mut rt = QuickJsPluginRuntime::new(storage_root);
501 let perms = SandboxPermissions {
502 max_memory_mb: 2, ..Default::default()
504 };
505 rt.load_plugin(QuickJsPluginConfig {
506 name: "memhog".into(),
507 path,
508 priority: 1000,
509 permissions: perms,
510 config: serde_json::Value::Null,
511 })
512 .expect("load plugin");
513
514 let mut plugins = rt.into_lifecycle_plugins();
515 let plugin = plugins.remove(0);
516
517 let result = plugin.on_server_start("{}");
519 assert!(result.is_err(), "expected memory limit error");
520 }
521
522 #[test]
525 fn timeout_enforcement() {
526 let source = r#"
527 function onServerStart(config) {
528 while (true) {} // infinite loop
529 }
530 "#;
531 let (dir, path) = write_temp_plugin("looper", source);
532 let storage_root = dir.path().join("storage");
533 let _ = std::fs::create_dir_all(&storage_root);
534
535 let mut rt = QuickJsPluginRuntime::new(storage_root);
536 let perms = SandboxPermissions {
537 max_time_secs: 1, ..Default::default()
539 };
540 rt.load_plugin(QuickJsPluginConfig {
541 name: "looper".into(),
542 path,
543 priority: 1000,
544 permissions: perms,
545 config: serde_json::Value::Null,
546 })
547 .expect("load plugin");
548
549 let mut plugins = rt.into_lifecycle_plugins();
550 let plugin = plugins.remove(0);
551
552 let start = Instant::now();
553 let result = plugin.on_server_start("{}");
554 let elapsed = start.elapsed();
555
556 assert!(result.is_err(), "expected timeout error");
557 assert!(
559 elapsed < std::time::Duration::from_secs(5),
560 "timeout took too long: {:?}",
561 elapsed
562 );
563 }
564
565 #[test]
568 fn console_log_no_panic() {
569 let source = r#"
570 function onServerStart(config) {
571 console.log("hello from plugin");
572 console.warn("warning msg");
573 console.error("error msg");
574 console.info("info msg");
575 console.debug("debug msg");
576 }
577 "#;
578 let (_dir, plugin) = load_lifecycle(
579 "logger",
580 source,
581 SandboxPermissions::default(),
582 serde_json::json!({}),
583 );
584
585 let result = plugin.on_server_start("{}");
586 assert!(result.is_ok(), "console logging failed: {:?}", result);
587 }
588
589 #[test]
592 fn storage_roundtrip() {
593 let source = r#"
595 function onServerStart(config) {
596 // Set a value
597 var ok = bext.storage.set("counter", "42");
598 if (!ok) throw new Error("storage.set failed");
599
600 // Get it back
601 var val = bext.storage.get("counter");
602 if (val !== "42") throw new Error("expected '42', got: " + val);
603
604 // Delete it
605 bext.storage.delete("counter");
606
607 // Should be null/undefined now (loose equality covers both)
608 var deleted = bext.storage.get("counter");
609 if (deleted != null) throw new Error("expected null/undefined after delete, got: " + deleted);
610
611 // Record success
612 bext.storage.set("roundtrip", "passed");
613 }
614 "#;
615 let (dir, plugin) = load_lifecycle(
616 "storagetest",
617 source,
618 SandboxPermissions::default(),
619 serde_json::json!({}),
620 );
621
622 let result = plugin.on_server_start("{}");
623 assert!(result.is_ok(), "storage roundtrip failed: {:?}", result);
624
625 let storage_dir = dir.path().join("storage").join("storagetest");
626 assert_eq!(
627 std::fs::read_to_string(storage_dir.join("roundtrip")).unwrap(),
628 "passed"
629 );
630 }
631
632 #[test]
635 fn config_accessible() {
636 let source = r#"
637 function onServerStart(config) {
638 // bext.config is injected at load time
639 bext.storage.set("markup", String(bext.config.markup_pct));
640 bext.storage.set("discount", String(bext.config.vip_discount));
641 }
642 "#;
643 let config = serde_json::json!({
644 "markup_pct": 15,
645 "vip_discount": 0.1
646 });
647 let (dir, plugin) =
648 load_lifecycle("configtest", source, SandboxPermissions::default(), config);
649
650 let result = plugin.on_server_start("{}");
651 assert!(result.is_ok(), "config access failed: {:?}", result);
652
653 let storage_dir = dir.path().join("storage").join("configtest");
654 assert_eq!(
655 std::fs::read_to_string(storage_dir.join("markup")).unwrap(),
656 "15"
657 );
658 assert_eq!(
659 std::fs::read_to_string(storage_dir.join("discount")).unwrap(),
660 "0.1"
661 );
662 }
663
664 #[test]
667 fn on_cache_write_args() {
668 let source = r#"
669 function onCacheWrite(key, tags) {
670 bext.storage.set("cache-key", key);
671 bext.storage.set("cache-tags", JSON.stringify(tags));
672 }
673 "#;
674 let (dir, plugin) = load_lifecycle(
675 "cachetest",
676 source,
677 SandboxPermissions::default(),
678 serde_json::json!({}),
679 );
680
681 let result = plugin.on_cache_write("/products/1", r#"["product","page"]"#);
682 assert!(result.is_ok(), "on_cache_write failed: {:?}", result);
683
684 let storage_dir = dir.path().join("storage").join("cachetest");
685 assert_eq!(
686 std::fs::read_to_string(storage_dir.join("cache-key")).unwrap(),
687 "/products/1"
688 );
689 }
690
691 #[test]
694 fn on_cache_invalidate_args() {
695 let source = r#"
696 function onCacheInvalidate(pattern, count) {
697 bext.storage.set("inv-pattern", pattern);
698 bext.storage.set("inv-count", String(count));
699 }
700 "#;
701 let (dir, plugin) = load_lifecycle(
702 "invtest",
703 source,
704 SandboxPermissions::default(),
705 serde_json::json!({}),
706 );
707
708 let result = plugin.on_cache_invalidate("/products/*", 7);
709 assert!(result.is_ok(), "on_cache_invalidate failed: {:?}", result);
710
711 let storage_dir = dir.path().join("storage").join("invtest");
712 assert_eq!(
713 std::fs::read_to_string(storage_dir.join("inv-pattern")).unwrap(),
714 "/products/*"
715 );
716 assert_eq!(
717 std::fs::read_to_string(storage_dir.join("inv-count")).unwrap(),
718 "7"
719 );
720 }
721
722 #[test]
725 fn on_reload_and_cleanup() {
726 let source = r#"
727 function onReload() {
728 bext.storage.set("reloaded", "true");
729 }
730 function cleanup() {
731 bext.storage.set("cleaned", "true");
732 }
733 "#;
734 let (dir, plugin) = load_lifecycle(
735 "lifecycletest",
736 source,
737 SandboxPermissions::default(),
738 serde_json::json!({}),
739 );
740
741 assert!(plugin.on_reload().is_ok());
742 assert!(plugin.cleanup().is_ok());
743
744 let storage_dir = dir.path().join("storage").join("lifecycletest");
745 assert_eq!(
746 std::fs::read_to_string(storage_dir.join("reloaded")).unwrap(),
747 "true"
748 );
749 assert_eq!(
750 std::fs::read_to_string(storage_dir.join("cleaned")).unwrap(),
751 "true"
752 );
753 }
754
755 #[test]
758 fn multiple_plugins() {
759 let dir = tempfile::tempdir().expect("create tempdir");
760 let storage_root = dir.path().join("storage");
761 let _ = std::fs::create_dir_all(&storage_root);
762
763 let path1 = dir.path().join("plugin1.js");
764 std::fs::write(
765 &path1,
766 r#"function onServerStart(c) { bext.storage.set("who", "plugin1"); }"#,
767 )
768 .unwrap();
769 let path2 = dir.path().join("plugin2.js");
770 std::fs::write(
771 &path2,
772 r#"function onServerStart(c) { bext.storage.set("who", "plugin2"); }"#,
773 )
774 .unwrap();
775
776 let mut rt = QuickJsPluginRuntime::new(storage_root.clone());
777 rt.load_plugin(QuickJsPluginConfig {
778 name: "p1".into(),
779 path: path1,
780 priority: 100,
781 permissions: SandboxPermissions::default(),
782 config: serde_json::Value::Null,
783 })
784 .unwrap();
785 rt.load_plugin(QuickJsPluginConfig {
786 name: "p2".into(),
787 path: path2,
788 priority: 200,
789 permissions: SandboxPermissions::default(),
790 config: serde_json::Value::Null,
791 })
792 .unwrap();
793
794 assert_eq!(rt.len(), 2);
795 assert!(!rt.is_empty());
796
797 let plugins = rt.into_lifecycle_plugins();
798 assert_eq!(plugins.len(), 2);
799
800 for p in &plugins {
801 assert!(p.on_server_start("{}").is_ok());
802 }
803
804 assert_eq!(
806 std::fs::read_to_string(storage_root.join("p1").join("who")).unwrap(),
807 "plugin1"
808 );
809 assert_eq!(
810 std::fs::read_to_string(storage_root.join("p2").join("who")).unwrap(),
811 "plugin2"
812 );
813 }
814
815 #[test]
818 fn syntax_error_on_load() {
819 let source = r#"
820 function onServerStart( {{{ INVALID SYNTAX
821 "#;
822 let (dir, path) = write_temp_plugin("badsyntax", source);
823 let storage_root = dir.path().join("storage");
824 let _ = std::fs::create_dir_all(&storage_root);
825
826 let mut rt = QuickJsPluginRuntime::new(storage_root);
827 let result = rt.load_plugin(QuickJsPluginConfig {
828 name: "badsyntax".into(),
829 path,
830 priority: 1000,
831 permissions: SandboxPermissions::default(),
832 config: serde_json::Value::Null,
833 });
834 assert!(result.is_err(), "expected syntax error");
835 }
836
837 #[test]
840 fn metric_emission() {
841 let source = r#"
842 function onRequestComplete(event) {
843 bext.metric("request_count", 1.0, '{"method":"GET"}');
844 bext.metric("latency_us", 1500.0); // omitted tags — JS wrapper defaults to "{}"
845 }
846 "#;
847 let (_dir, plugin) = load_lifecycle(
848 "metrictest",
849 source,
850 SandboxPermissions::default(),
851 serde_json::json!({}),
852 );
853
854 let result = plugin.on_request_complete(r#"{"status":200}"#);
855 assert!(result.is_ok(), "metric emission failed: {:?}", result);
856 }
857
858 #[test]
861 fn on_server_stop() {
862 let source = r#"
863 function onServerStop() {
864 bext.storage.set("stopped", "yes");
865 }
866 "#;
867 let (dir, plugin) = load_lifecycle(
868 "stoptest",
869 source,
870 SandboxPermissions::default(),
871 serde_json::json!({}),
872 );
873
874 assert!(plugin.on_server_stop().is_ok());
875
876 let storage_dir = dir.path().join("storage").join("stoptest");
877 assert_eq!(
878 std::fs::read_to_string(storage_dir.join("stopped")).unwrap(),
879 "yes"
880 );
881 }
882}