Skip to main content

bext_plugin_quickjs/
runtime.rs

1//! QuickJS plugin runtime — loads and manages JS lifecycle plugins.
2//!
3//! Each plugin gets its own QuickJS Runtime with:
4//! - Memory limit (default 64 MB)
5//! - Max stack size (default 1 MB)
6//! - Interrupt handler for wall-clock timeout enforcement
7//! - Sandboxed API surface (no filesystem/network access except via bext.*)
8
9use 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
16/// Configuration for loading a single QuickJS plugin.
17pub struct QuickJsPluginConfig {
18    pub name: String,
19    /// Path to the .js plugin file.
20    pub path: PathBuf,
21    pub priority: u32,
22    pub permissions: SandboxPermissions,
23    pub config: serde_json::Value,
24}
25
26/// A loaded QuickJS plugin: owns the runtime + context and implements LifecyclePlugin.
27struct QuickJsPlugin {
28    manifest: PluginManifest,
29    priority: u32,
30    rt: rquickjs::Runtime,
31    ctx: rquickjs::Context,
32    bridge: Arc<HostBridge>,
33    /// Call deadline — refreshed before each lifecycle call.
34    deadline: Arc<Mutex<Option<Instant>>>,
35}
36
37// rquickjs::Runtime and Context are Send (via the `parallel` feature) but not Sync.
38// QuickJsPlugin is only used as a transient builder struct before being consumed by
39// into_lifecycle_plugins(), where the non-Sync fields are moved into a Mutex.
40// No Sync bound is needed here — QuickJsPluginRuntime is used single-threaded.
41
42/// Manages loading and lifecycle of QuickJS plugins.
43pub 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        // Create runtime with memory + stack limits
66        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); // 1 MB stack
70
71        // Install interrupt handler for wall-clock timeout
72        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        // Create context and register APIs
84        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        // Register globals and evaluate plugin source
95        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            // Evaluate the plugin source — this defines the exported functions
101            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
159// ---------------------------------------------------------------------------
160// LifecyclePlugin adapter
161// ---------------------------------------------------------------------------
162
163struct 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
171// rquickjs::Runtime and Context are Send (via `parallel` feature) but not Sync.
172// QuickJsState is only accessed through std::sync::Mutex<QuickJsState> in the
173// adapter, which requires T: Send (not T: Sync) to be Send + Sync itself.
174// Therefore no unsafe impl is needed — all fields are already Send.
175
176struct QuickJsLifecycleAdapter {
177    manifest: PluginManifest,
178    priority: u32,
179    state: Mutex<QuickJsState>,
180}
181
182impl QuickJsLifecycleAdapter {
183    /// Set a wall-clock deadline before calling a JS function.
184    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    /// Call a global JS function by name, passing JSON args. Returns Ok(()) if the
197    /// function doesn't exist (not all hooks are required).
198    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(()), // Hook not defined — that's fine
211            };
212
213            // Build JS arguments from JSON strings
214            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    /// Helper: create a temp dir + write a JS file, return (dir, file_path).
321    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    /// Helper: load a plugin from source and return the lifecycle adapter.
329    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    // ── Basic runtime tests ──────────────────────────────────────────
356
357    #[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    // ── Plugin with onServerStart ────────────────────────────────────
379
380    #[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        // Verify storage was written
399        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    // ── Plugin with no functions (all hooks are no-ops) ──────────────
406
407    #[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    // ── onRequestComplete receives correct event data ────────────────
430
431    #[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    // ── Plugin manifest (name from config) ───────────────────────────
467
468    #[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    // ── Memory limit enforcement ─────────────────────────────────────
483
484    #[test]
485    fn memory_limit_enforcement() {
486        // Try to allocate a huge array that exceeds the memory limit
487        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, // Only 2 MB
503            ..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        // Should error due to memory limit
518        let result = plugin.on_server_start("{}");
519        assert!(result.is_err(), "expected memory limit error");
520    }
521
522    // ── Timeout enforcement ──────────────────────────────────────────
523
524    #[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, // 1 second timeout
538            ..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        // Should finish within a reasonable time (allow up to 3s for interrupt checking)
558        assert!(
559            elapsed < std::time::Duration::from_secs(5),
560            "timeout took too long: {:?}",
561            elapsed
562        );
563    }
564
565    // ── Console.log doesn't panic ────────────────────────────────────
566
567    #[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    // ── Storage get/set/delete roundtrip ─────────────────────────────
590
591    #[test]
592    fn storage_roundtrip() {
593        // Use loose equality (== null) since rquickjs maps None → undefined
594        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    // ── bext.config is readable ──────────────────────────────────────
633
634    #[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    // ── onCacheWrite receives key + tags ─────────────────────────────
665
666    #[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    // ── onCacheInvalidate receives pattern + count ───────────────────
692
693    #[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    // ── onReload + cleanup ───────────────────────────────────────────
723
724    #[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    // ── Multiple plugins in one runtime ──────────────────────────────
756
757    #[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        // Each plugin wrote to its own storage
805        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    // ── JS syntax error during load ──────────────────────────────────
816
817    #[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    // ── bext.metric doesn't panic ────────────────────────────────────
838
839    #[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    // ── onServerStop ─────────────────────────────────────────────────
859
860    #[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}