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}