Skip to main content

xript_runtime/
lib.rs

1mod error;
2pub mod fragment;
3pub mod handle;
4mod manifest;
5mod sandbox;
6
7pub use error::{Result, ValidationIssue, XriptError};
8pub use fragment::{FragmentInstance, FragmentResult, ModInstance};
9pub use manifest::{
10    Binding, Capability, FragmentBinding, FragmentDeclaration, FragmentEvent, FunctionBinding,
11    HookDef, Limits, Manifest, ModManifest, NamespaceBinding, Parameter, Slot,
12};
13pub use handle::XriptHandle;
14pub use sandbox::{
15    AsyncHostFn, ConsoleHandler, ExecutionResult, HostBindings, HostFn, RuntimeOptions,
16    XriptRuntime,
17};
18
19pub fn create_runtime(manifest_json: &str, options: RuntimeOptions) -> Result<XriptRuntime> {
20    let manifest: Manifest = serde_json::from_str(manifest_json)?;
21    XriptRuntime::new(manifest, options)
22}
23
24pub fn create_runtime_from_value(
25    manifest: serde_json::Value,
26    options: RuntimeOptions,
27) -> Result<XriptRuntime> {
28    let manifest: Manifest =
29        serde_json::from_value(manifest).map_err(XriptError::Json)?;
30    XriptRuntime::new(manifest, options)
31}
32
33pub fn create_runtime_from_file(
34    path: &std::path::Path,
35    options: RuntimeOptions,
36) -> Result<XriptRuntime> {
37    let content = std::fs::read_to_string(path)?;
38    create_runtime(&content, options)
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44
45    fn minimal_manifest() -> &'static str {
46        r#"{ "xript": "0.1", "name": "test-app" }"#
47    }
48
49    #[test]
50    fn creates_runtime_from_minimal_manifest() {
51        let rt = create_runtime(
52            minimal_manifest(),
53            RuntimeOptions {
54                host_bindings: HostBindings::new(),
55                capabilities: vec![],
56                console: ConsoleHandler::default(),
57            },
58        );
59        assert!(rt.is_ok());
60    }
61
62    #[test]
63    fn rejects_empty_xript_field() {
64        let result = create_runtime(
65            r#"{ "xript": "", "name": "test" }"#,
66            RuntimeOptions {
67                host_bindings: HostBindings::new(),
68                capabilities: vec![],
69                console: ConsoleHandler::default(),
70            },
71        );
72        assert!(result.is_err());
73        assert!(matches!(
74            result.unwrap_err(),
75            XriptError::ManifestValidation { .. }
76        ));
77    }
78
79    #[test]
80    fn rejects_empty_name_field() {
81        let result = create_runtime(
82            r#"{ "xript": "0.1", "name": "" }"#,
83            RuntimeOptions {
84                host_bindings: HostBindings::new(),
85                capabilities: vec![],
86                console: ConsoleHandler::default(),
87            },
88        );
89        assert!(result.is_err());
90    }
91
92    #[test]
93    fn executes_simple_expressions() {
94        let rt = create_runtime(
95            minimal_manifest(),
96            RuntimeOptions {
97                host_bindings: HostBindings::new(),
98                capabilities: vec![],
99                console: ConsoleHandler::default(),
100            },
101        )
102        .unwrap();
103
104        let result = rt.execute("2 + 2").unwrap();
105        assert_eq!(result.value, serde_json::json!(4));
106        assert!(result.duration_ms >= 0.0);
107    }
108
109    #[test]
110    fn executes_string_expressions() {
111        let rt = create_runtime(
112            minimal_manifest(),
113            RuntimeOptions {
114                host_bindings: HostBindings::new(),
115                capabilities: vec![],
116                console: ConsoleHandler::default(),
117            },
118        )
119        .unwrap();
120
121        let result = rt.execute("'hello' + ' ' + 'world'").unwrap();
122        assert_eq!(result.value, serde_json::json!("hello world"));
123    }
124
125    #[test]
126    fn supports_standard_builtins() {
127        let rt = create_runtime(
128            minimal_manifest(),
129            RuntimeOptions {
130                host_bindings: HostBindings::new(),
131                capabilities: vec![],
132                console: ConsoleHandler::default(),
133            },
134        )
135        .unwrap();
136
137        let result = rt.execute("Math.max(1, 5, 3)").unwrap();
138        assert_eq!(result.value, serde_json::json!(5));
139
140        let result = rt.execute("JSON.stringify({ a: 1 })").unwrap();
141        assert_eq!(result.value, serde_json::json!("{\"a\":1}"));
142    }
143
144    #[test]
145    fn blocks_eval() {
146        let rt = create_runtime(
147            minimal_manifest(),
148            RuntimeOptions {
149                host_bindings: HostBindings::new(),
150                capabilities: vec![],
151                console: ConsoleHandler::default(),
152            },
153        )
154        .unwrap();
155
156        let result = rt.execute("eval('1 + 1')");
157        assert!(result.is_err());
158    }
159
160    #[test]
161    fn blocks_process_and_require() {
162        let rt = create_runtime(
163            minimal_manifest(),
164            RuntimeOptions {
165                host_bindings: HostBindings::new(),
166                capabilities: vec![],
167                console: ConsoleHandler::default(),
168            },
169        )
170        .unwrap();
171
172        let result = rt.execute("typeof process").unwrap();
173        assert_eq!(result.value, serde_json::json!("undefined"));
174
175        let result = rt.execute("typeof require").unwrap();
176        assert_eq!(result.value, serde_json::json!("undefined"));
177    }
178
179    #[test]
180    fn routes_console_output() {
181        use std::sync::{Arc, Mutex};
182
183        let logs: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
184        let logs_clone = logs.clone();
185
186        let rt = create_runtime(
187            minimal_manifest(),
188            RuntimeOptions {
189                host_bindings: HostBindings::new(),
190                capabilities: vec![],
191                console: ConsoleHandler {
192                    log: Box::new(move |msg| logs_clone.lock().unwrap().push(msg.to_string())),
193                    warn: Box::new(|_| {}),
194                    error: Box::new(|_| {}),
195                },
196            },
197        )
198        .unwrap();
199
200        rt.execute("console.log('hello from sandbox')").unwrap();
201
202        let captured = logs.lock().unwrap();
203        assert_eq!(captured.len(), 1);
204        assert_eq!(captured[0], "hello from sandbox");
205    }
206
207    #[test]
208    fn exposes_manifest() {
209        let rt = create_runtime(
210            minimal_manifest(),
211            RuntimeOptions {
212                host_bindings: HostBindings::new(),
213                capabilities: vec![],
214                console: ConsoleHandler::default(),
215            },
216        )
217        .unwrap();
218
219        assert_eq!(rt.manifest().name, "test-app");
220        assert_eq!(rt.manifest().xript, "0.1");
221    }
222
223    #[test]
224    fn rejects_invalid_json() {
225        let result = create_runtime(
226            "not json",
227            RuntimeOptions {
228                host_bindings: HostBindings::new(),
229                capabilities: vec![],
230                console: ConsoleHandler::default(),
231            },
232        );
233        assert!(result.is_err());
234    }
235
236    #[test]
237    fn enforces_timeout() {
238        let rt = create_runtime(
239            r#"{ "xript": "0.1", "name": "test", "limits": { "timeout_ms": 100 } }"#,
240            RuntimeOptions {
241                host_bindings: HostBindings::new(),
242                capabilities: vec![],
243                console: ConsoleHandler::default(),
244            },
245        )
246        .unwrap();
247
248        let result = rt.execute("while(true) {}");
249        assert!(result.is_err());
250        assert!(matches!(
251            result.unwrap_err(),
252            XriptError::ExecutionLimit { .. }
253        ));
254    }
255
256    #[test]
257    fn calls_host_function() {
258        let manifest = r#"{
259            "xript": "0.1",
260            "name": "test",
261            "bindings": {
262                "add": {
263                    "description": "adds two numbers",
264                    "params": [
265                        { "name": "a", "type": "number" },
266                        { "name": "b", "type": "number" }
267                    ]
268                }
269            }
270        }"#;
271
272        let mut bindings = HostBindings::new();
273        bindings.add_function("add", |args: &[serde_json::Value]| {
274            let a = args.get(0).and_then(|v| v.as_f64()).unwrap_or(0.0);
275            let b = args.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
276            Ok(serde_json::json!(a + b))
277        });
278
279        let rt = create_runtime(
280            manifest,
281            RuntimeOptions {
282                host_bindings: bindings,
283                capabilities: vec![],
284                console: ConsoleHandler::default(),
285            },
286        )
287        .unwrap();
288
289        let result = rt.execute("add(3, 4)").unwrap();
290        assert_eq!(result.value, serde_json::json!(7.0));
291    }
292
293    #[test]
294    fn host_function_errors_become_exceptions() {
295        let manifest = r#"{
296            "xript": "0.1",
297            "name": "test",
298            "bindings": {
299                "fail": {
300                    "description": "always fails"
301                }
302            }
303        }"#;
304
305        let mut bindings = HostBindings::new();
306        bindings.add_function("fail", |_: &[serde_json::Value]| {
307            Err("intentional error".into())
308        });
309
310        let rt = create_runtime(
311            manifest,
312            RuntimeOptions {
313                host_bindings: bindings,
314                capabilities: vec![],
315                console: ConsoleHandler::default(),
316            },
317        )
318        .unwrap();
319
320        let result = rt.execute("try { fail(); 'no error' } catch(e) { e.message }");
321        assert!(result.is_ok());
322        assert_eq!(result.unwrap().value, serde_json::json!("intentional error"));
323    }
324
325    #[test]
326    fn denies_ungranated_capabilities() {
327        let manifest = r#"{
328            "xript": "0.1",
329            "name": "test",
330            "bindings": {
331                "dangerousOp": {
332                    "description": "requires permission",
333                    "capability": "dangerous"
334                }
335            },
336            "capabilities": {
337                "dangerous": {
338                    "description": "allows dangerous operations"
339                }
340            }
341        }"#;
342
343        let mut bindings = HostBindings::new();
344        bindings.add_function("dangerousOp", |_: &[serde_json::Value]| {
345            Ok(serde_json::json!("should not reach"))
346        });
347
348        let rt = create_runtime(
349            manifest,
350            RuntimeOptions {
351                host_bindings: bindings,
352                capabilities: vec![],
353                console: ConsoleHandler::default(),
354            },
355        )
356        .unwrap();
357
358        let result = rt.execute("try { dangerousOp(); 'no error' } catch(e) { e.message }");
359        assert!(result.is_ok());
360        let msg = result.unwrap().value.as_str().unwrap().to_string();
361        assert!(msg.contains("capability"));
362    }
363
364    #[test]
365    fn grants_capabilities() {
366        let manifest = r#"{
367            "xript": "0.1",
368            "name": "test",
369            "bindings": {
370                "dangerousOp": {
371                    "description": "requires permission",
372                    "capability": "dangerous"
373                }
374            },
375            "capabilities": {
376                "dangerous": {
377                    "description": "allows dangerous operations"
378                }
379            }
380        }"#;
381
382        let mut bindings = HostBindings::new();
383        bindings.add_function("dangerousOp", |_: &[serde_json::Value]| {
384            Ok(serde_json::json!("access granted"))
385        });
386
387        let rt = create_runtime(
388            manifest,
389            RuntimeOptions {
390                host_bindings: bindings,
391                capabilities: vec!["dangerous".into()],
392                console: ConsoleHandler::default(),
393            },
394        )
395        .unwrap();
396
397        let result = rt.execute("dangerousOp()").unwrap();
398        assert_eq!(result.value, serde_json::json!("access granted"));
399    }
400
401    #[test]
402    fn missing_binding_throws() {
403        let manifest = r#"{
404            "xript": "0.1",
405            "name": "test",
406            "bindings": {
407                "notProvided": {
408                    "description": "host didn't register this"
409                }
410            }
411        }"#;
412
413        let rt = create_runtime(
414            manifest,
415            RuntimeOptions {
416                host_bindings: HostBindings::new(),
417                capabilities: vec![],
418                console: ConsoleHandler::default(),
419            },
420        )
421        .unwrap();
422
423        let result = rt.execute("try { notProvided(); 'no error' } catch(e) { e.message }");
424        assert!(result.is_ok());
425        let msg = result.unwrap().value.as_str().unwrap().to_string();
426        assert!(msg.contains("not provided"));
427    }
428
429    #[test]
430    fn parses_mod_manifest() {
431        let json = r#"{
432            "xript": "0.3",
433            "name": "health-panel",
434            "version": "1.0.0",
435            "title": "Health Panel",
436            "author": "Test Author",
437            "capabilities": ["ui-mount"],
438            "fragments": [
439                {
440                    "id": "health-bar",
441                    "slot": "sidebar.left",
442                    "format": "text/html",
443                    "source": "fragments/panel.html",
444                    "bindings": [
445                        { "name": "health", "path": "player.health" }
446                    ],
447                    "events": [
448                        { "selector": "[data-action='heal']", "on": "click", "handler": "onHeal" }
449                    ],
450                    "priority": 10
451                }
452            ]
453        }"#;
454
455        let mod_manifest: ModManifest = serde_json::from_str(json).unwrap();
456        assert_eq!(mod_manifest.name, "health-panel");
457        assert_eq!(mod_manifest.version, "1.0.0");
458        assert_eq!(mod_manifest.fragments.as_ref().unwrap().len(), 1);
459
460        let frag = &mod_manifest.fragments.as_ref().unwrap()[0];
461        assert_eq!(frag.id, "health-bar");
462        assert_eq!(frag.slot, "sidebar.left");
463        assert_eq!(frag.priority, Some(10));
464        assert_eq!(frag.bindings.as_ref().unwrap().len(), 1);
465        assert_eq!(frag.events.as_ref().unwrap().len(), 1);
466    }
467
468    #[test]
469    fn validates_mod_manifest_required_fields() {
470        let invalid = r#"{
471            "xript": "",
472            "name": "",
473            "version": ""
474        }"#;
475
476        let mod_manifest: ModManifest = serde_json::from_str(invalid).unwrap();
477        let result = manifest::validate_mod_manifest(&mod_manifest);
478        assert!(result.is_err());
479        if let Err(XriptError::ManifestValidation { issues }) = result {
480            assert!(issues.len() >= 3);
481        }
482    }
483
484    #[test]
485    fn sanitizes_script_tags() {
486        let input = r#"<script>alert('xss')</script><p>safe</p>"#;
487        let output = fragment::sanitize_html(input);
488        assert!(!output.contains("<script>"));
489        assert!(output.contains("<p>safe</p>"));
490    }
491
492    #[test]
493    fn sanitizes_event_attributes() {
494        let input = r#"<div onclick="alert('xss')">test</div>"#;
495        let output = fragment::sanitize_html(input);
496        assert!(!output.contains("onclick"));
497        assert!(output.contains("test"));
498    }
499
500    #[test]
501    fn preserves_safe_content() {
502        let input = r#"<div class="panel" data-bind="health" aria-label="hp" role="progressbar"><span>100</span></div>"#;
503        let output = fragment::sanitize_html(input);
504        assert!(output.contains("class=\"panel\""));
505        assert!(output.contains("data-bind=\"health\""));
506        assert!(output.contains("aria-label=\"hp\""));
507        assert!(output.contains("role=\"progressbar\""));
508        assert!(output.contains("<span>100</span>"));
509    }
510
511    #[test]
512    fn resolves_data_bind() {
513        let source = r#"<span data-bind="health">0</span>"#;
514        let mut bindings = std::collections::HashMap::new();
515        bindings.insert("health".to_string(), serde_json::json!(75));
516
517        let result = fragment::process_fragment("test-frag", source, &bindings);
518        assert!(result.html.contains("75"));
519        assert!(!result.html.contains(">0<"));
520    }
521
522    #[test]
523    fn evaluates_data_if() {
524        let source = r#"<div data-if="health < 50" class="warning">low!</div>"#;
525        let mut bindings = std::collections::HashMap::new();
526        bindings.insert("health".to_string(), serde_json::json!(30));
527
528        let result = fragment::process_fragment("test-frag", source, &bindings);
529        assert_eq!(result.visibility.get("health < 50"), Some(&true));
530
531        bindings.insert("health".to_string(), serde_json::json!(80));
532        let result = fragment::process_fragment("test-frag", source, &bindings);
533        assert_eq!(result.visibility.get("health < 50"), Some(&false));
534    }
535
536    #[test]
537    fn cross_validates_slot_exists() {
538        let app_manifest = r#"{
539            "xript": "0.3",
540            "name": "test-app",
541            "slots": [
542                { "id": "sidebar.left", "accepts": ["text/html"] }
543            ]
544        }"#;
545
546        let mod_json = r#"{
547            "xript": "0.3",
548            "name": "test-mod",
549            "version": "1.0.0",
550            "fragments": [
551                { "id": "panel", "slot": "nonexistent", "format": "text/html", "source": "panel.html" }
552            ]
553        }"#;
554
555        let rt = create_runtime(
556            app_manifest,
557            RuntimeOptions {
558                host_bindings: HostBindings::new(),
559                capabilities: vec![],
560                console: ConsoleHandler::default(),
561            },
562        )
563        .unwrap();
564
565        let result = rt.load_mod(
566            mod_json,
567            std::collections::HashMap::new(),
568            &std::collections::HashSet::new(),
569            None,
570        );
571        assert!(result.is_err());
572    }
573
574    #[test]
575    fn cross_validates_format_accepted() {
576        let app_manifest = r#"{
577            "xript": "0.3",
578            "name": "test-app",
579            "slots": [
580                { "id": "sidebar.left", "accepts": ["text/html"] }
581            ]
582        }"#;
583
584        let mod_json = r#"{
585            "xript": "0.3",
586            "name": "test-mod",
587            "version": "1.0.0",
588            "fragments": [
589                { "id": "panel", "slot": "sidebar.left", "format": "text/plain", "source": "panel.txt" }
590            ]
591        }"#;
592
593        let rt = create_runtime(
594            app_manifest,
595            RuntimeOptions {
596                host_bindings: HostBindings::new(),
597                capabilities: vec![],
598                console: ConsoleHandler::default(),
599            },
600        )
601        .unwrap();
602
603        let result = rt.load_mod(
604            mod_json,
605            std::collections::HashMap::new(),
606            &std::collections::HashSet::new(),
607            None,
608        );
609        assert!(result.is_err());
610    }
611
612    #[test]
613    fn cross_validates_capability_gating() {
614        let app_manifest = r#"{
615            "xript": "0.3",
616            "name": "test-app",
617            "slots": [
618                { "id": "main.overlay", "accepts": ["text/html"], "capability": "ui-mount" }
619            ]
620        }"#;
621
622        let mod_json = r#"{
623            "xript": "0.3",
624            "name": "test-mod",
625            "version": "1.0.0",
626            "fragments": [
627                { "id": "overlay", "slot": "main.overlay", "format": "text/html", "source": "<p>hi</p>", "inline": true }
628            ]
629        }"#;
630
631        let rt = create_runtime(
632            app_manifest,
633            RuntimeOptions {
634                host_bindings: HostBindings::new(),
635                capabilities: vec![],
636                console: ConsoleHandler::default(),
637            },
638        )
639        .unwrap();
640
641        let no_caps = std::collections::HashSet::new();
642        let result = rt.load_mod(mod_json, std::collections::HashMap::new(), &no_caps, None);
643        assert!(result.is_err());
644
645        let mut with_caps = std::collections::HashSet::new();
646        with_caps.insert("ui-mount".to_string());
647        let result = rt.load_mod(mod_json, std::collections::HashMap::new(), &with_caps, None);
648        assert!(result.is_ok());
649    }
650
651    #[test]
652    fn load_mod_integration() {
653        let app_manifest = r#"{
654            "xript": "0.3",
655            "name": "test-app",
656            "slots": [
657                { "id": "sidebar.left", "accepts": ["text/html"], "multiple": true }
658            ]
659        }"#;
660
661        let mod_json = r#"{
662            "xript": "0.3",
663            "name": "health-panel",
664            "version": "1.0.0",
665            "fragments": [
666                {
667                    "id": "health-bar",
668                    "slot": "sidebar.left",
669                    "format": "text/html",
670                    "source": "<div data-bind=\"health\"><span>0</span></div>",
671                    "inline": true,
672                    "bindings": [
673                        { "name": "health", "path": "player.health" }
674                    ]
675                }
676            ]
677        }"#;
678
679        let rt = create_runtime(
680            app_manifest,
681            RuntimeOptions {
682                host_bindings: HostBindings::new(),
683                capabilities: vec![],
684                console: ConsoleHandler::default(),
685            },
686        )
687        .unwrap();
688
689        let mod_instance = rt.load_mod(
690            mod_json,
691            std::collections::HashMap::new(),
692            &std::collections::HashSet::new(),
693            None,
694        )
695        .unwrap();
696
697        assert_eq!(mod_instance.name, "health-panel");
698        assert_eq!(mod_instance.fragments.len(), 1);
699        assert_eq!(mod_instance.fragments[0].id, "health-bar");
700
701        let data = serde_json::json!({ "player": { "health": 75 } });
702        let results = mod_instance.update_bindings(&data);
703        assert_eq!(results.len(), 1);
704        assert!(results[0].html.contains("75"));
705    }
706
707    #[test]
708    fn fragment_hook_registration() {
709        let rt = create_runtime(
710            minimal_manifest(),
711            RuntimeOptions {
712                host_bindings: HostBindings::new(),
713                capabilities: vec![],
714                console: ConsoleHandler::default(),
715            },
716        )
717        .unwrap();
718
719        let result = rt.execute("typeof hooks.fragment.mount").unwrap();
720        assert_eq!(result.value, serde_json::json!("function"));
721
722        let result = rt.execute("typeof hooks.fragment.unmount").unwrap();
723        assert_eq!(result.value, serde_json::json!("function"));
724
725        let result = rt.execute("typeof hooks.fragment.update").unwrap();
726        assert_eq!(result.value, serde_json::json!("function"));
727
728        let result = rt.execute("typeof hooks.fragment.suspend").unwrap();
729        assert_eq!(result.value, serde_json::json!("function"));
730
731        let result = rt.execute("typeof hooks.fragment.resume").unwrap();
732        assert_eq!(result.value, serde_json::json!("function"));
733    }
734
735    #[test]
736    fn load_mod_executes_entry_script() {
737        use std::sync::{Arc, Mutex};
738
739        let app_manifest = r#"{
740            "xript": "0.3",
741            "name": "test-app",
742            "bindings": {
743                "log": { "description": "log a message" }
744            },
745            "slots": [
746                { "id": "sidebar.left", "accepts": ["text/html"], "multiple": true }
747            ]
748        }"#;
749
750        let mod_json = r#"{
751            "xript": "0.3",
752            "name": "entry-mod",
753            "version": "1.0.0",
754            "fragments": [
755                {
756                    "id": "entry-panel",
757                    "slot": "sidebar.left",
758                    "format": "text/html",
759                    "source": "<p>hi</p>",
760                    "inline": true
761                }
762            ]
763        }"#;
764
765        let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
766        let captured_clone = captured.clone();
767
768        let mut bindings = HostBindings::new();
769        bindings.add_function("log", move |args: &[serde_json::Value]| {
770            let msg = args.first().and_then(|v| v.as_str()).unwrap_or("").to_string();
771            captured_clone.lock().unwrap().push(msg);
772            Ok(serde_json::Value::Null)
773        });
774
775        let rt = create_runtime(
776            app_manifest,
777            RuntimeOptions {
778                host_bindings: bindings,
779                capabilities: vec![],
780                console: ConsoleHandler::default(),
781            },
782        )
783        .unwrap();
784
785        let result = rt.load_mod(
786            mod_json,
787            std::collections::HashMap::new(),
788            &std::collections::HashSet::new(),
789            Some("log('entry executed')"),
790        );
791        assert!(result.is_ok());
792
793        let logs = captured.lock().unwrap();
794        assert_eq!(logs.len(), 1);
795        assert_eq!(logs[0], "entry executed");
796    }
797
798    #[test]
799    fn load_mod_entry_failure_returns_mod_entry_error() {
800        let app_manifest = r#"{
801            "xript": "0.3",
802            "name": "test-app",
803            "slots": [
804                { "id": "sidebar.left", "accepts": ["text/html"], "multiple": true }
805            ]
806        }"#;
807
808        let mod_json = r#"{
809            "xript": "0.3",
810            "name": "failing-mod",
811            "version": "1.0.0",
812            "fragments": [
813                {
814                    "id": "panel",
815                    "slot": "sidebar.left",
816                    "format": "text/html",
817                    "source": "<p>hi</p>",
818                    "inline": true
819                }
820            ]
821        }"#;
822
823        let rt = create_runtime(
824            app_manifest,
825            RuntimeOptions {
826                host_bindings: HostBindings::new(),
827                capabilities: vec![],
828                console: ConsoleHandler::default(),
829            },
830        )
831        .unwrap();
832
833        let result = rt.load_mod(
834            mod_json,
835            std::collections::HashMap::new(),
836            &std::collections::HashSet::new(),
837            Some("throw new Error('entry failed')"),
838        );
839        assert!(result.is_err());
840        assert!(matches!(result.unwrap_err(), XriptError::ModEntry { .. }));
841    }
842
843    #[test]
844    fn load_mod_without_entry_still_works() {
845        let app_manifest = r#"{
846            "xript": "0.3",
847            "name": "test-app",
848            "slots": [
849                { "id": "sidebar.left", "accepts": ["text/html"], "multiple": true }
850            ]
851        }"#;
852
853        let mod_json = r#"{
854            "xript": "0.3",
855            "name": "no-entry-mod",
856            "version": "1.0.0",
857            "fragments": [
858                {
859                    "id": "panel",
860                    "slot": "sidebar.left",
861                    "format": "text/html",
862                    "source": "<p>hi</p>",
863                    "inline": true
864                }
865            ]
866        }"#;
867
868        let rt = create_runtime(
869            app_manifest,
870            RuntimeOptions {
871                host_bindings: HostBindings::new(),
872                capabilities: vec![],
873                console: ConsoleHandler::default(),
874            },
875        )
876        .unwrap();
877
878        let result = rt.load_mod(
879            mod_json,
880            std::collections::HashMap::new(),
881            &std::collections::HashSet::new(),
882            None,
883        );
884        assert!(result.is_ok());
885    }
886
887    #[test]
888    fn strips_javascript_uris() {
889        let input = r#"<a href="javascript:alert('xss')">click</a>"#;
890        let output = fragment::sanitize_html(input);
891        assert!(!output.contains("javascript:"));
892        assert!(output.contains("click"));
893    }
894
895    #[test]
896    fn strips_iframe_elements() {
897        let input = r#"<iframe src="evil.com"></iframe><p>ok</p>"#;
898        let output = fragment::sanitize_html(input);
899        assert!(!output.contains("<iframe"));
900        assert!(output.contains("<p>ok</p>"));
901    }
902
903    #[test]
904    fn handle_is_send_and_sync() {
905        fn assert_send_sync<T: Send + Sync>() {}
906        assert_send_sync::<handle::XriptHandle>();
907    }
908
909    #[test]
910    fn handle_executes_code() {
911        let handle = handle::XriptHandle::new(
912            minimal_manifest().to_string(),
913            RuntimeOptions {
914                host_bindings: HostBindings::new(),
915                capabilities: vec![],
916                console: ConsoleHandler::default(),
917            },
918        )
919        .unwrap();
920
921        let result = handle.execute("2 + 2").unwrap();
922        assert_eq!(result.value, serde_json::json!(4));
923    }
924
925    #[test]
926    fn handle_returns_manifest_name() {
927        let handle = handle::XriptHandle::new(
928            minimal_manifest().to_string(),
929            RuntimeOptions {
930                host_bindings: HostBindings::new(),
931                capabilities: vec![],
932                console: ConsoleHandler::default(),
933            },
934        )
935        .unwrap();
936
937        assert_eq!(handle.manifest_name().unwrap(), "test-app");
938    }
939
940    #[test]
941    fn handle_works_across_threads() {
942        let handle = handle::XriptHandle::new(
943            minimal_manifest().to_string(),
944            RuntimeOptions {
945                host_bindings: HostBindings::new(),
946                capabilities: vec![],
947                console: ConsoleHandler::default(),
948            },
949        )
950        .unwrap();
951
952        let result = std::thread::spawn(move || handle.execute("1 + 1"))
953            .join()
954            .unwrap()
955            .unwrap();
956
957        assert_eq!(result.value, serde_json::json!(2));
958    }
959
960    #[test]
961    fn handle_propagates_errors() {
962        let handle = handle::XriptHandle::new(
963            minimal_manifest().to_string(),
964            RuntimeOptions {
965                host_bindings: HostBindings::new(),
966                capabilities: vec![],
967                console: ConsoleHandler::default(),
968            },
969        )
970        .unwrap();
971
972        let result = handle.execute("throw new Error('boom')");
973        assert!(result.is_err());
974        assert!(matches!(result.unwrap_err(), XriptError::Script(_)));
975    }
976
977    #[test]
978    fn handle_load_mod_works() {
979        let app_manifest = r#"{
980            "xript": "0.3",
981            "name": "test-app",
982            "slots": [
983                { "id": "sidebar.left", "accepts": ["text/html"] }
984            ]
985        }"#;
986
987        let handle = handle::XriptHandle::new(
988            app_manifest.to_string(),
989            RuntimeOptions {
990                host_bindings: HostBindings::new(),
991                capabilities: vec![],
992                console: ConsoleHandler::default(),
993            },
994        )
995        .unwrap();
996
997        let mod_json = r#"{
998            "xript": "0.3",
999            "name": "test-mod",
1000            "version": "1.0.0",
1001            "fragments": [
1002                { "id": "panel", "slot": "sidebar.left", "format": "text/html", "source": "<p>hi</p>", "inline": true }
1003            ]
1004        }"#;
1005
1006        let mod_instance = handle
1007            .load_mod(
1008                mod_json,
1009                std::collections::HashMap::new(),
1010                &std::collections::HashSet::new(),
1011                None,
1012            )
1013            .unwrap();
1014
1015        assert_eq!(mod_instance.name, "test-mod");
1016    }
1017
1018    #[test]
1019    fn calls_async_host_function() {
1020        let manifest = r#"{
1021            "xript": "0.1",
1022            "name": "test",
1023            "bindings": {
1024                "fetchData": {
1025                    "description": "fetches data asynchronously",
1026                    "async": true
1027                }
1028            }
1029        }"#;
1030
1031        let mut bindings = HostBindings::new();
1032        bindings.add_async_function("fetchData", |args: &[serde_json::Value]| {
1033            let key = args
1034                .first()
1035                .and_then(|v| v.as_str())
1036                .unwrap_or("default")
1037                .to_string();
1038            async move { Ok(serde_json::json!(format!("data for {}", key))) }
1039        });
1040
1041        let rt = create_runtime(
1042            manifest,
1043            RuntimeOptions {
1044                host_bindings: bindings,
1045                capabilities: vec![],
1046                console: ConsoleHandler::default(),
1047            },
1048        )
1049        .unwrap();
1050
1051        let result = rt
1052            .execute("(async () => await fetchData('users'))()")
1053            .unwrap();
1054        assert_eq!(result.value, serde_json::json!("data for users"));
1055    }
1056
1057    #[test]
1058    fn async_host_function_errors_become_exceptions() {
1059        let manifest = r#"{
1060            "xript": "0.1",
1061            "name": "test",
1062            "bindings": {
1063                "failAsync": {
1064                    "description": "always fails asynchronously"
1065                }
1066            }
1067        }"#;
1068
1069        let mut bindings = HostBindings::new();
1070        bindings.add_async_function("failAsync", |_: &[serde_json::Value]| {
1071            async { Err("async error occurred".into()) }
1072        });
1073
1074        let rt = create_runtime(
1075            manifest,
1076            RuntimeOptions {
1077                host_bindings: bindings,
1078                capabilities: vec![],
1079                console: ConsoleHandler::default(),
1080            },
1081        )
1082        .unwrap();
1083
1084        let result = rt
1085            .execute("(async () => { try { await failAsync(); return 'no error'; } catch(e) { return e.message; } })()")
1086            .unwrap();
1087        assert_eq!(result.value, serde_json::json!("async error occurred"));
1088    }
1089
1090    #[test]
1091    fn sync_and_async_bindings_coexist() {
1092        let manifest = r#"{
1093            "xript": "0.1",
1094            "name": "test",
1095            "bindings": {
1096                "syncAdd": {
1097                    "description": "adds two numbers synchronously",
1098                    "params": [
1099                        { "name": "a", "type": "number" },
1100                        { "name": "b", "type": "number" }
1101                    ]
1102                },
1103                "asyncFetch": {
1104                    "description": "fetches data asynchronously",
1105                    "async": true
1106                }
1107            }
1108        }"#;
1109
1110        let mut bindings = HostBindings::new();
1111        bindings.add_function("syncAdd", |args: &[serde_json::Value]| {
1112            let a = args.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
1113            let b = args.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
1114            Ok(serde_json::json!(a + b))
1115        });
1116        bindings.add_async_function("asyncFetch", |args: &[serde_json::Value]| {
1117            let key = args
1118                .first()
1119                .and_then(|v| v.as_str())
1120                .unwrap_or("none")
1121                .to_string();
1122            async move { Ok(serde_json::json!(format!("fetched {}", key))) }
1123        });
1124
1125        let rt = create_runtime(
1126            manifest,
1127            RuntimeOptions {
1128                host_bindings: bindings,
1129                capabilities: vec![],
1130                console: ConsoleHandler::default(),
1131            },
1132        )
1133        .unwrap();
1134
1135        let sync_result = rt.execute("syncAdd(10, 20)").unwrap();
1136        assert_eq!(sync_result.value, serde_json::json!(30.0));
1137
1138        let async_result = rt
1139            .execute("(async () => await asyncFetch('items'))()")
1140            .unwrap();
1141        assert_eq!(async_result.value, serde_json::json!("fetched items"));
1142    }
1143
1144    #[test]
1145    fn async_binding_returns_promise() {
1146        let manifest = r#"{
1147            "xript": "0.1",
1148            "name": "test",
1149            "bindings": {
1150                "asyncOp": {
1151                    "description": "async operation",
1152                    "async": true
1153                }
1154            }
1155        }"#;
1156
1157        let mut bindings = HostBindings::new();
1158        bindings.add_async_function("asyncOp", |_: &[serde_json::Value]| {
1159            async { Ok(serde_json::json!(42)) }
1160        });
1161
1162        let rt = create_runtime(
1163            manifest,
1164            RuntimeOptions {
1165                host_bindings: bindings,
1166                capabilities: vec![],
1167                console: ConsoleHandler::default(),
1168            },
1169        )
1170        .unwrap();
1171
1172        let result = rt.execute("asyncOp() instanceof Promise").unwrap();
1173        assert_eq!(result.value, serde_json::json!(true));
1174    }
1175
1176    #[test]
1177    fn async_await_chains_work() {
1178        let manifest = r#"{
1179            "xript": "0.1",
1180            "name": "test",
1181            "bindings": {
1182                "fetchUser": { "description": "fetch user", "async": true },
1183                "fetchRole": { "description": "fetch role", "async": true }
1184            }
1185        }"#;
1186
1187        let mut bindings = HostBindings::new();
1188        bindings.add_async_function("fetchUser", |args: &[serde_json::Value]| {
1189            let id = args.first().and_then(|v| v.as_i64()).unwrap_or(0);
1190            async move { Ok(serde_json::json!({"id": id, "name": "Alice"})) }
1191        });
1192        bindings.add_async_function("fetchRole", |args: &[serde_json::Value]| {
1193            let name = args.first().and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
1194            async move { Ok(serde_json::json!(format!("admin:{}", name))) }
1195        });
1196
1197        let rt = create_runtime(
1198            manifest,
1199            RuntimeOptions {
1200                host_bindings: bindings,
1201                capabilities: vec![],
1202                console: ConsoleHandler::default(),
1203            },
1204        )
1205        .unwrap();
1206
1207        let result = rt.execute(r#"
1208            (async () => {
1209                const user = await fetchUser(1);
1210                const role = await fetchRole(user.name);
1211                return role;
1212            })()
1213        "#).unwrap();
1214
1215        assert_eq!(result.value, serde_json::json!("admin:Alice"));
1216    }
1217}