1use super::lowered::{DriveError, LoweredPage};
17use super::snapshot::DocSnapshot;
18use super::trace::{Divergence, PageTrace, Stop, TraceStep};
19use super::{Drive, DrivenPage, PageTier, RemotePage};
20use crate::analysis::{PolicyRegistry, TypeRegistry};
21use crate::ast::stmt::{Expr, Literal, Stmt, UiStmt};
22use crate::intern::Interner;
23use crate::semantics::dom;
24use crate::semantics::world::PageWorld;
25use serde::Serialize;
26use std::path::Path;
27use std::time::Duration;
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum PageTierKind {
33 LoweredHere,
36 LoweredVm,
38 Browser,
40}
41
42impl PageTierKind {
43 pub fn label(self) -> &'static str {
44 match self {
45 PageTierKind::LoweredHere => "lowered tree-walker",
46 PageTierKind::LoweredVm => "lowered VM",
47 PageTierKind::Browser => "browser",
48 }
49 }
50}
51
52pub trait RemotePageLauncher {
54 fn launch(&self, arrival: &Arrival) -> Result<Box<dyn RemotePage>, String>;
57}
58
59#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct Arrival<'s> {
67 pub source: &'s str,
69 pub route: String,
71 pub answers: Vec<GivenAnswer>,
73 pub mounts: Vec<String>,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct GivenAnswer {
81 pub method: String,
82 pub url: String,
83 pub status: i64,
84 pub body: String,
85}
86
87#[derive(Clone, Copy, Debug)]
89pub struct World {
90 pub caps: logicaffeine_system::testworld::Capabilities,
91 pub seed: u64,
92 pub budget: u64,
94}
95
96impl World {
97 pub fn for_test(
100 name: &str,
101 suite_seed: u64,
102 caps: logicaffeine_system::testworld::Capabilities,
103 budget: u64,
104 ) -> World {
105 let mut h: u64 = 0xCBF2_9CE4_8422_2325 ^ suite_seed;
106 for b in name.bytes() {
107 h ^= u64::from(b);
108 h = h.wrapping_mul(0x0000_0100_0000_01B3);
109 }
110 World {
111 caps,
112 seed: h,
113 budget,
114 }
115 }
116
117 pub fn gen_seed(&self) -> u64 {
119 self.seed ^ 0x5DEE_CE66_1234_ABCD
120 }
121
122 fn enter(&self, route: Option<&str>) -> WorldGuard {
138 let guard = WorldGuard(Some(PageWorld::take()));
139 crate::concurrency::net_inbox::set_net_offline(true);
140 logicaffeine_system::testworld::install(logicaffeine_system::testworld::TestWorld::new(
141 self.caps, self.seed,
142 ));
143 dom::arrive(&crate::web_host::browser_host_html(super::PAGE_STEM));
144 if let Some(path) = route {
145 dom::init_route(path, "");
146 }
147 guard
148 }
149}
150
151struct WorldGuard(Option<PageWorld>);
153
154impl Drop for WorldGuard {
155 fn drop(&mut self) {
156 if let Some(world) = self.0.take() {
157 world.restore();
158 }
159 }
160}
161
162#[derive(Clone, Debug)]
165pub struct HereRun {
166 pub trace: PageTrace,
167 pub refused: Option<(usize, String)>,
168}
169
170impl HereRun {
171 pub fn tier_run(&self) -> TierRun {
173 match &self.refused {
174 Some((statement, reason)) => TierRun::Refused {
175 statement: *statement,
176 reason: reason.clone(),
177 },
178 None => TierRun::Traced(self.trace.clone()),
179 }
180 }
181}
182
183#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "snake_case")]
186pub enum TierRun {
187 Traced(PageTrace),
188 Refused {
191 statement: usize,
192 reason: String,
193 },
194}
195
196#[derive(Clone, Debug, PartialEq, Eq)]
198pub enum Classified {
199 Drive(Drive),
201 Read,
203 Unsupported(String),
205}
206
207#[derive(Clone, Debug)]
217pub struct Arranged<'a> {
218 pub defs: Vec<Stmt<'a>>,
220 pub body: Vec<Stmt<'a>>,
222 pub authored: Vec<Option<usize>>,
226 pub arrival: usize,
231}
232
233pub fn is_scene(stmt: &Stmt) -> bool {
235 matches!(
236 stmt,
237 Stmt::GivenFetch { .. } | Stmt::ServeFetches | Stmt::GivenFile { .. }
238 )
239}
240
241fn page_component_names(defs: &[Stmt]) -> Vec<logicaffeine_base::Symbol> {
245 defs.iter()
246 .filter_map(|s| match s {
247 Stmt::ComponentDef(d)
248 if matches!(d.kind, crate::ast::component::ComponentKind::Page { .. }) =>
249 {
250 Some(d.name)
251 }
252 _ => None,
253 })
254 .collect()
255}
256
257pub fn defs_auto_mount_a_page(defs: &[Stmt]) -> bool {
263 let pages = page_component_names(defs);
264 defs.iter()
265 .any(|s| matches!(s, Stmt::Ui(UiStmt::Mount { component, selector: None }) if pages.contains(component)))
266}
267
268fn auto_mounted_page(
272 defs: &[Stmt],
273) -> Option<(logicaffeine_base::Symbol, logicaffeine_base::Symbol)> {
274 let paths: std::collections::HashMap<logicaffeine_base::Symbol, logicaffeine_base::Symbol> =
275 defs.iter()
276 .filter_map(|s| match s {
277 Stmt::ComponentDef(d) => match d.kind {
278 crate::ast::component::ComponentKind::Page { path, .. } => Some((d.name, path)),
279 _ => None,
280 },
281 _ => None,
282 })
283 .collect();
284 defs.iter().find_map(|s| match s {
285 Stmt::Ui(UiStmt::Mount {
286 component,
287 selector: None,
288 }) => paths.get(component).map(|p| (*component, *p)),
289 _ => None,
290 })
291}
292
293pub fn auto_mounted_page_path(defs: &[Stmt], interner: &Interner) -> Option<String> {
298 auto_mounted_page(defs).map(|(_, path)| interner.resolve(path).to_string())
299}
300
301pub fn boot_at_declared_route(defs: &[Stmt], interner: &Interner) {
305 if let Some(path) = auto_mounted_page_path(defs, interner) {
306 dom::init_route(&path, "");
307 }
308}
309
310pub fn auto_mounted_page_component(defs: &[Stmt], interner: &Interner) -> Option<String> {
314 auto_mounted_page(defs).map(|(component, _)| interner.resolve(component).to_string())
315}
316
317pub fn arrange<'a>(defs: &[Stmt<'a>], body: &[Stmt<'a>]) -> Arranged<'a> {
319 let pages = page_component_names(defs);
320 let is_page_mount = |s: &Stmt| matches!(s, Stmt::Ui(UiStmt::Mount { component, selector: None }) if pages.contains(component));
321 let mounts: Vec<Stmt<'a>> = defs.iter().filter(|s| is_page_mount(s)).cloned().collect();
322 let program: Vec<Stmt<'a>> = defs.iter().filter(|s| !is_page_mount(s)).cloned().collect();
323 let mut arranged = Vec::with_capacity(body.len() + mounts.len());
324 let mut authored = Vec::with_capacity(body.len() + mounts.len());
325 let scene = body.iter().take_while(|s| is_scene(s)).count();
326 for (i, stmt) in body.iter().enumerate() {
327 if i == scene {
328 arranged.extend(mounts.iter().cloned());
329 authored.extend(mounts.iter().map(|_| None));
330 }
331 arranged.push(stmt.clone());
332 authored.push(Some(i + 1));
333 }
334 if scene == body.len() {
335 arranged.extend(mounts.iter().cloned());
336 authored.extend(mounts.iter().map(|_| None));
337 }
338 let arrival = if mounts.is_empty() {
339 0
340 } else {
341 scene + mounts.len()
342 };
343 Arranged {
344 defs: program,
345 body: arranged,
346 authored,
347 arrival,
348 }
349}
350
351pub struct Scenario<'a> {
353 pub interner: &'a Interner,
354 pub types: &'a TypeRegistry,
355 pub policies: &'a PolicyRegistry,
356 pub arranged: Arranged<'a>,
357 pub world: World,
358}
359
360impl<'a> Scenario<'a> {
361 pub fn new(
362 interner: &'a Interner,
363 types: &'a TypeRegistry,
364 policies: &'a PolicyRegistry,
365 defs: &[Stmt<'a>],
366 body: &[Stmt<'a>],
367 world: World,
368 ) -> Self {
369 Scenario {
370 interner,
371 types,
372 policies,
373 arranged: arrange(defs, body),
374 world,
375 }
376 }
377
378 fn authored_len(&self) -> usize {
380 self.arranged.authored.iter().flatten().count()
381 }
382
383 fn full_program(&self) -> Vec<Stmt<'a>> {
388 self.arranged
389 .defs
390 .iter()
391 .chain(&self.arranged.body)
392 .cloned()
393 .collect()
394 }
395
396 fn route(&self) -> Option<String> {
399 auto_mounted_page_path(&self.full_program(), self.interner)
400 }
401
402 fn step_end(&self, k: usize) -> usize {
405 let next = self
406 .arranged
407 .authored
408 .iter()
409 .position(|a| *a == Some(k + 1))
410 .unwrap_or(self.arranged.body.len());
411 next.max(self.arranged.arrival)
412 }
413
414 fn executes(&self, k: usize, steps: &[Classified]) -> bool {
417 k == 0
418 || self
419 .arranged
420 .authored
421 .iter()
422 .position(|a| *a == Some(k))
423 .is_some_and(|i| matches!(steps[i], Classified::Drive(_)))
424 }
425
426 pub fn reference_trace(&self) -> PageTrace {
429 self.lowered_here_run().trace
430 }
431
432 pub fn lowered_here_trace(&self) -> TierRun {
439 self.lowered_here_run().tier_run()
440 }
441
442 pub fn lowered_here_run(&self) -> HereRun {
445 crate::interpreter::on_session_stack(|| self.lowered_here_run_in_session())
446 }
447
448 fn lowered_here_run_in_session(&self) -> HereRun {
450 let _guard = self.world.enter(self.route().as_deref());
451 let mut interp = crate::interpreter::Interpreter::new(self.interner)
452 .with_type_registry(self.types)
453 .with_policies(self.policies.clone());
454 if !self.world.caps.filesystem {
455 interp = interp.with_vfs(std::sync::Arc::new(logicaffeine_system::fs::MemVfs::new()));
456 }
457 interp.enable_test_mode(self.world.budget);
458 interp.set_gen_seed(self.world.gen_seed());
459 let auto_mounted = auto_mounted_page_component(&self.full_program(), self.interner);
460 let mut trace = PageTrace::default();
461 match interp.host_lowered_page(&self.arranged.defs, auto_mounted) {
462 Ok(()) => {}
463 Err(DriveError::Refused(reason)) => {
464 trace.stopped = Some(Stop {
465 statement: 0,
466 message: reason.clone(),
467 });
468 return HereRun {
469 trace,
470 refused: Some((0, reason)),
471 };
472 }
473 Err(DriveError::Failed(message)) => {
474 trace.steps.push(TraceStep {
475 statement: 0,
476 executed: true,
477 snapshot: DocSnapshot::capture(interp.hosted_page_failure()),
478 });
479 trace.stopped = Some(Stop {
480 statement: 0,
481 message,
482 });
483 return HereRun {
484 trace,
485 refused: None,
486 };
487 }
488 }
489 let mut from = 0;
490 let mut returned = false;
491 for k in 0..=self.authored_len() {
492 let end = self.step_end(k);
493 let step = &self.arranged.body[from..end.max(from)];
494 from = end.max(from);
495 let outcome = if returned {
496 Ok(crate::interpreter::ControlFlow::Continue)
497 } else if crate::interpreter::needs_async(step, self.interner) {
498 futures::executor::block_on(interp.run_statements_async(step))
499 } else {
500 interp.run_statements(step)
501 };
502 let refusal = interp.hosted_page_refusal().map(|(d, why)| match d {
503 Drive::Mount { component, .. } => self
504 .why_not_lowered(component)
505 .unwrap_or_else(|| why.to_string()),
506 _ => why.to_string(),
507 });
508 trace.steps.push(TraceStep {
509 statement: k,
510 executed: true,
511 snapshot: DocSnapshot::capture(interp.hosted_page_failure()),
512 });
513 if let Some(reason) = refusal {
514 trace.stopped = Some(Stop {
515 statement: k,
516 message: reason.clone(),
517 });
518 return HereRun {
519 trace,
520 refused: Some((k, reason)),
521 };
522 }
523 match outcome {
524 Ok(crate::interpreter::ControlFlow::Continue) => {}
525 Ok(_) => returned = true,
526 Err(message) => {
527 trace.stopped = Some(Stop {
528 statement: k,
529 message,
530 });
531 break;
532 }
533 }
534 }
535 HereRun {
536 trace,
537 refused: None,
538 }
539 }
540
541 pub fn lowered_vm_trace(&self) -> TierRun {
543 let steps = match self.classify_body() {
544 Ok(steps) => steps,
545 Err(refused) => return refused,
546 };
547 let program = match crate::vm::Compiler::compile_with_types(
548 &self.arranged.defs,
549 self.interner,
550 Some(self.types),
551 ) {
552 Ok(p) => p,
553 Err(e) => {
554 return TierRun::Refused {
555 statement: 0,
556 reason: format!("the program does not compile for the bytecode VM: {e}"),
557 }
558 }
559 };
560 let _guard = self.world.enter(self.route().as_deref());
561 let vm = crate::vm::Vm::new(&program).with_policy_ctx(self.policies, self.interner);
562 let mut page = LoweredPage::new(crate::vm::session::Session::new(vm, self.interner));
563 let auto_mounted = auto_mounted_page_component(&self.full_program(), self.interner);
564 let started = page.start(auto_mounted, |s| s.run_main());
565 let run = self.walk(&mut PageTier::LoweredVm(page), started, &steps, 0);
566 run
567 }
568
569 pub fn remote_trace(&self, launcher: &dyn RemotePageLauncher, source: &str) -> TierRun {
577 let steps = match self.classify_body() {
578 Ok(steps) => steps,
579 Err(refused) => return refused,
580 };
581 let mut answers = Vec::new();
582 for (i, step) in steps[..self.arranged.arrival].iter().enumerate() {
583 match step {
584 Classified::Drive(Drive::AnswerFetch {
585 method,
586 url,
587 status,
588 body,
589 }) => answers.push(GivenAnswer {
590 method: method.clone(),
591 url: url.clone(),
592 status: *status,
593 body: body.clone(),
594 }),
595 Classified::Drive(Drive::Mount { .. }) if self.arranged.authored[i].is_none() => {}
596 _ => {
597 return TierRun::Refused {
598 statement: self.owner(i),
599 reason: format!(
600 "{} cannot be arranged before a page in another process loads: only \
601 `Given that … answers …` travels with the page to where it runs",
602 stmt_label(&self.arranged.body[i])
603 ),
604 }
605 }
606 }
607 }
608 let mut mounts: Vec<String> = steps[self.arranged.arrival..]
609 .iter()
610 .filter_map(|s| match s {
611 Classified::Drive(Drive::Mount { component, .. }) => Some(component.clone()),
612 _ => None,
613 })
614 .collect();
615 mounts.sort();
616 mounts.dedup();
617 let arrival = Arrival {
618 source,
619 route: self.route().unwrap_or_else(|| "/".to_string()),
620 answers,
621 mounts,
622 };
623 let _guard = self.world.enter(self.route().as_deref());
624 match launcher.launch(&arrival) {
625 Ok(page) => self.walk(
626 &mut PageTier::Browser(page),
627 Ok(()),
628 &steps,
629 self.arranged.arrival,
630 ),
631 Err(e) => TierRun::Refused {
632 statement: 0,
633 reason: format!("the page did not start: {e}"),
634 },
635 }
636 }
637
638 fn owner(&self, i: usize) -> usize {
641 self.arranged.authored[..=i]
642 .iter()
643 .rev()
644 .flatten()
645 .next()
646 .copied()
647 .unwrap_or(0)
648 }
649
650 fn classify_body(&self) -> Result<Vec<Classified>, TierRun> {
653 let mut steps = Vec::with_capacity(self.arranged.body.len());
654 for (i, stmt) in self.arranged.body.iter().enumerate() {
655 match classify(stmt, self.interner) {
656 Classified::Unsupported(reason) => {
657 return Err(TierRun::Refused {
658 statement: self.owner(i),
659 reason,
660 })
661 }
662 c => steps.push(c),
663 }
664 }
665 Ok(steps)
666 }
667
668 fn walk(
673 &self,
674 tier: &mut PageTier<'_>,
675 started: Result<(), DriveError>,
676 steps: &[Classified],
677 from: usize,
678 ) -> TierRun {
679 let page = tier.driven();
680 let mut trace = PageTrace::default();
681 let snap = |page: &mut dyn DrivenPage| {
682 page.snapshot().unwrap_or_else(|e| DocSnapshot {
683 document: format!("(the page could not be read: {e})"),
684 ..DocSnapshot::default()
685 })
686 };
687 match started {
688 Ok(()) => {}
689 Err(DriveError::Refused(reason)) => {
690 return TierRun::Refused {
691 statement: 0,
692 reason,
693 }
694 }
695 Err(DriveError::Failed(message)) => {
696 trace.steps.push(TraceStep {
697 statement: 0,
698 executed: true,
699 snapshot: snap(page),
700 });
701 trace.stopped = Some(Stop {
702 statement: 0,
703 message,
704 });
705 return TierRun::Traced(trace);
706 }
707 }
708 let mut i = from;
709 for k in 0..=self.authored_len() {
710 let executed = self.executes(k, steps);
711 while i < self.step_end(k) {
712 let step = &steps[i];
713 let outcome = match step {
714 Classified::Drive(d) => page.drive(d),
715 Classified::Read => Ok(()),
716 Classified::Unsupported(reason) => {
717 return TierRun::Refused {
718 statement: self.owner(i),
719 reason: reason.clone(),
720 }
721 }
722 };
723 match outcome {
724 Ok(()) => {}
725 Err(DriveError::Refused(reason)) => {
726 let reason = match step {
727 Classified::Drive(Drive::Mount { component, .. }) => {
728 self.why_not_lowered(component).unwrap_or(reason)
729 }
730 _ => reason,
731 };
732 return TierRun::Refused {
733 statement: self.owner(i),
734 reason,
735 };
736 }
737 Err(DriveError::Failed(message)) => {
738 trace.steps.push(TraceStep {
739 statement: k,
740 executed,
741 snapshot: snap(page),
742 });
743 trace.stopped = Some(Stop {
744 statement: k,
745 message,
746 });
747 return TierRun::Traced(trace);
748 }
749 }
750 i += 1;
751 }
752 trace.steps.push(TraceStep {
753 statement: k,
754 executed,
755 snapshot: snap(page),
756 });
757 }
758 TierRun::Traced(trace)
759 }
760
761 fn why_not_lowered(&self, component: &str) -> Option<String> {
763 why_not_lowered(&self.arranged.defs, self.types, self.interner, component)
764 }
765}
766
767fn why_not_lowered(
774 defs: &[Stmt],
775 types: &TypeRegistry,
776 interner: &Interner,
777 component: &str,
778) -> Option<String> {
779 let component_defs: Vec<_> = defs
780 .iter()
781 .filter_map(|s| match s {
782 Stmt::ComponentDef(d) => Some((**d).clone()),
783 _ => None,
784 })
785 .collect();
786 let def = component_defs
787 .iter()
788 .find(|d| interner.resolve(d.name) == component)?;
789 match logicaffeine_language::parser::lgx_lower::lowerable(
790 def,
791 &component_defs,
792 Some(types),
793 interner,
794 ) {
795 Err(why) => Some(format!(
796 "`Mount {component}` cannot run on this tier: {}",
797 why.message
798 )),
799 Ok(()) => None,
800 }
801}
802
803pub fn drive_main<'a>(
811 defs: &[Stmt<'a>],
812 drive_stmts: &[Stmt<'a>],
813 interner: &Interner,
814 types: &TypeRegistry,
815 policies: &PolicyRegistry,
816) -> Result<String, String> {
817 let program = crate::vm::Compiler::compile_with_types(defs, interner, Some(types))
818 .map_err(|e| format!("the VM compiler cannot compile this program: {e}"))?;
819 let vm = crate::vm::Vm::new(&program).with_policy_ctx(policies, interner);
820 let mut page = LoweredPage::new(crate::vm::session::Session::new(vm, interner));
821 let to_message = |e: DriveError| match e {
822 DriveError::Failed(m) | DriveError::Refused(m) => m,
823 };
824 let auto_mounted = auto_mounted_page_component(&[defs, drive_stmts].concat(), interner);
825 page.start(auto_mounted, |s| s.run_main())
826 .map_err(to_message)?;
827 for stmt in drive_stmts {
828 let d = match classify(stmt, interner) {
829 Classified::Drive(d) => d,
830 _ => {
831 return Err(format!(
832 "{} is not a driving statement — the gate that routed this program's `## \
833 Main` here is out of step with `classify`",
834 stmt_label(stmt)
835 ))
836 }
837 };
838 if let Err(e) = page.drive(&d) {
839 let message = match (&d, e) {
845 (Drive::Mount { component, .. }, DriveError::Refused(reason)) => {
846 why_not_lowered(defs, types, interner, component).unwrap_or(reason)
847 }
848 (_, e) => to_message(e),
849 };
850 return Err(message);
851 }
852 }
853 match page.unhandled_failure() {
860 Some(message) => Err(message.to_string()),
861 None => Ok(page.output()),
862 }
863}
864
865pub fn drives_the_page(stmt: &Stmt, interner: &Interner) -> bool {
868 match stmt {
869 Stmt::Ui(ui) => ui.role() != crate::ast::stmt::Role::Action,
870 _ => to_drive(stmt, interner).is_some(),
871 }
872}
873
874pub fn classify(stmt: &Stmt, interner: &Interner) -> Classified {
876 match to_drive(stmt, interner) {
877 Some(Ok(d)) => Classified::Drive(d),
878 Some(Err(why)) => Classified::Unsupported(why),
879 None if reads_only_stmt(stmt) => Classified::Read,
880 None => Classified::Unsupported(format!(
881 "{} is carried out by the tree-walker running the test; a driven tier can carry only \
882 driving statements and statements that read the page",
883 stmt_label(stmt)
884 )),
885 }
886}
887
888fn to_drive(stmt: &Stmt, interner: &Interner) -> Option<Result<Drive, String>> {
890 let t = |e: &Expr| operand_text(e, interner);
891 Some(match stmt {
892 Stmt::Ui(ui) => match ui {
893 UiStmt::Mount {
894 component,
895 selector,
896 } => (|| {
897 Ok(Drive::Mount {
898 component: interner.resolve(*component).to_string(),
899 at: selector.map(t).transpose()?,
900 })
901 })(),
902 UiStmt::Click { selector } => t(selector).map(|selector| Drive::Click { selector }),
903 UiStmt::Submit { selector } => t(selector).map(|selector| Drive::Submit { selector }),
904 UiStmt::Press { key, selector } => (|| {
905 Ok(Drive::Press {
906 key: t(key)?,
907 selector: match selector {
908 Some(s) => t(s)?,
909 None => "body".to_string(),
910 },
911 })
912 })(),
913 UiStmt::TypeInto { text, selector } => (|| {
914 Ok(Drive::Type {
915 text: t(text)?,
916 selector: t(selector)?,
917 })
918 })(),
919 UiStmt::Choose { value, selector } => (|| {
920 Ok(Drive::Choose {
921 value: t(value)?,
922 selector: t(selector)?,
923 })
924 })(),
925 UiStmt::Hover { selector } => t(selector).map(|selector| Drive::Hover { selector }),
926 UiStmt::Focus { selector } => t(selector).map(|selector| Drive::Focus { selector }),
927 UiStmt::Blur { selector } => t(selector).map(|selector| Drive::Blur { selector }),
928 UiStmt::RaiseWindowEvent {
931 event,
932 target,
933 value,
934 } => (|| {
935 let event = t(event)?;
936 let target = t(target)?;
937 if event == "filedropped" {
938 return Ok(Drive::DropFile {
939 target,
940 file: dropped_file(value, interner)?,
941 });
942 }
943 Ok(Drive::Raise {
944 event,
945 target,
946 value: t(value)?,
947 })
948 })(),
949 UiStmt::Navigate { url } => t(url).map(|url| Drive::Navigate { url }),
950 UiStmt::Reload => Ok(Drive::Reload),
951 other => Err(format!(
952 "`{}` in a test is not a driving statement the page host maps",
953 ui_label(other)
954 )),
955 },
956 Stmt::AdvanceTime { millis } => match millis {
957 Expr::Literal(Literal::Number(n)) => Ok(Drive::AdvanceTime {
958 ms: (*n).max(0) as u64,
959 }),
960 Expr::Literal(Literal::Duration(ns)) => Ok(Drive::AdvanceTime {
961 ms: ((*ns).max(0) / 1_000_000) as u64,
962 }),
963 other => Err(computed(other, interner)),
964 },
965 Stmt::GivenFetch {
966 method,
967 url,
968 status,
969 body,
970 } => (|| {
971 let status = match status {
972 Expr::Literal(Literal::Number(n)) => *n,
973 other => return Err(computed(other, interner)),
974 };
975 Ok(Drive::AnswerFetch {
976 method: interner.resolve(*method).to_string(),
977 url: t(url)?,
978 status,
979 body: t(body)?,
980 })
981 })(),
982 Stmt::ServeFetches => Ok(Drive::ServeFetchesWithRoutes),
983 Stmt::Replay { actions } => Ok(Drive::Replay {
984 actions: actions
985 .iter()
986 .map(|a| interner.resolve(*a).to_string())
987 .collect(),
988 }),
989 _ => return None,
990 })
991}
992
993fn dropped_file(value: &Expr, interner: &Interner) -> Result<dom::DroppedFile, String> {
996 let Expr::New {
997 type_name,
998 init_fields,
999 ..
1000 } = value
1001 else {
1002 return Ok(dom::DroppedFile::named(&operand_text(value, interner)?));
1003 };
1004 if interner.resolve(*type_name) != "File" {
1005 return Err(format!(
1006 "a dropped file is a `File` or its name as Text, and this is a new {}",
1007 interner.resolve(*type_name)
1008 ));
1009 }
1010 let field = |name: &str| {
1011 init_fields
1012 .iter()
1013 .find(|(f, _)| interner.resolve(*f) == name)
1014 .map(|(_, e)| *e)
1015 };
1016 let text = |name: &str| field(name).map(|e| operand_text(e, interner)).transpose();
1017 let size = match field("size") {
1018 Some(Expr::Literal(Literal::Number(n))) => Some(*n),
1019 Some(other) => return Err(computed(other, interner)),
1020 None => None,
1021 };
1022 let bytes = match field("bytes") {
1023 Some(Expr::List(items)) => Some(
1024 items
1025 .iter()
1026 .map(|item| match item {
1027 Expr::Literal(Literal::Number(n)) => Ok(*n),
1028 other => Err(computed(other, interner)),
1029 })
1030 .collect::<Result<Vec<i64>, String>>()?,
1031 ),
1032 Some(other) => return Err(computed(other, interner)),
1033 None => None,
1034 };
1035 dom::DroppedFile::declared(
1036 text("name")?.unwrap_or_default(),
1037 size,
1038 text("type")?,
1039 bytes,
1040 )
1041}
1042
1043fn operand_text(e: &Expr, interner: &Interner) -> Result<String, String> {
1045 match e {
1046 Expr::Literal(Literal::Text(s)) => Ok(interner.resolve(*s).to_string()),
1047 Expr::Literal(Literal::Number(n)) => Ok(n.to_string()),
1048 other => Err(computed(other, interner)),
1049 }
1050}
1051
1052fn computed(e: &Expr, interner: &Interner) -> String {
1053 format!(
1054 "its operand `{}` is computed, and evaluating it is the tree-walker's job; a driven tier \
1055 takes literal operands until the interpreter hands it evaluated statements",
1056 logicaffeine_language::printer::render_expr(e, interner)
1057 )
1058}
1059
1060fn reads_only_stmt(stmt: &Stmt) -> bool {
1063 match stmt {
1064 Stmt::Expect { condition } => reads_only(condition),
1065 Stmt::ExpectOutput { .. } => true,
1066 Stmt::Let { value, .. } | Stmt::Set { value, .. } => reads_only(value),
1067 Stmt::Show { object, recipient } => {
1068 reads_only(object) && matches!(recipient, Expr::Identifier(_))
1069 }
1070 Stmt::RuntimeAssert {
1071 condition, reason, ..
1072 } => reads_only(condition) && reason.map_or(true, reads_only),
1073 _ => false,
1074 }
1075}
1076
1077fn reads_only(e: &Expr) -> bool {
1079 match e {
1080 Expr::Literal(_) | Expr::Identifier(_) | Expr::OptionNone => true,
1081 Expr::BinaryOp { left, right, .. }
1082 | Expr::Index {
1083 collection: left,
1084 index: right,
1085 }
1086 | Expr::Contains {
1087 collection: left,
1088 value: right,
1089 }
1090 | Expr::Range {
1091 start: left,
1092 end: right,
1093 }
1094 | Expr::Union { left, right }
1095 | Expr::Intersection { left, right } => reads_only(left) && reads_only(right),
1096 Expr::Not { operand }
1097 | Expr::Length {
1098 collection: operand,
1099 }
1100 | Expr::FieldAccess {
1101 object: operand, ..
1102 }
1103 | Expr::OptionSome { value: operand }
1104 | Expr::Copy { expr: operand } => reads_only(operand),
1105 Expr::Slice {
1106 collection,
1107 start,
1108 end,
1109 } => reads_only(collection) && reads_only(start) && reads_only(end),
1110 Expr::List(items) | Expr::Tuple(items) => items.iter().all(|i| reads_only(i)),
1111 Expr::InterpolatedString(parts) => parts.iter().all(|p| match p {
1112 crate::ast::stmt::StringPart::Literal(_) => true,
1113 crate::ast::stmt::StringPart::Expr { value, .. } => reads_only(value),
1114 }),
1115 Expr::Dom(query) => query.selector().map_or(true, reads_only),
1116 _ => false,
1117 }
1118}
1119
1120fn stmt_label(stmt: &Stmt) -> &'static str {
1121 match stmt {
1122 Stmt::If { .. } => "an `If`",
1123 Stmt::While { .. } => "a `While`",
1124 Stmt::Repeat { .. } => "a `Repeat`",
1125 Stmt::Call { .. } => "a `Call`",
1126 Stmt::ExpectFail { .. } => "an `Expect this to fail:` block",
1127 Stmt::ForAll { .. } => "a `For all` property",
1128 Stmt::Inspect { .. } => "an `Inspect`",
1129 Stmt::GivenFile { .. } => "`Given a file`",
1130 Stmt::Let { .. } => "a `Let` that calls a function",
1131 Stmt::Set { .. } => "a `Set` that calls a function",
1132 Stmt::Expect { .. } => "an `Expect` that calls a function",
1133 Stmt::Show { .. } => "a `Show` that calls a function",
1134 _ => "this statement",
1135 }
1136}
1137
1138fn ui_label(ui: &UiStmt) -> &'static str {
1139 match ui {
1140 UiStmt::ScrollTo { .. } => "Scroll to",
1141 UiStmt::AddClass { .. } => "Add class",
1142 UiStmt::RemoveClass { .. } => "Remove class",
1143 UiStmt::SelectText { .. } => "Select the text in",
1144 UiStmt::CopyToClipboard { .. } => "Copy … to the clipboard",
1145 UiStmt::Download { .. } => "Download",
1146 UiStmt::ReplaceUrl { .. } => "Replace the URL with",
1147 UiStmt::GoBack => "Go back",
1148 UiStmt::SetTitle { .. } => "Set the title to",
1149 UiStmt::Save { .. } => "Save",
1150 UiStmt::Load { .. } => "Load",
1151 UiStmt::Forget { .. } => "Forget",
1152 UiStmt::Notify { .. } => "Notify",
1153 UiStmt::Publish { .. } => "Publish",
1154 UiStmt::Toggle { .. } => "Toggle",
1155 UiStmt::CallStore { .. } => "Call <Store>'s",
1156 UiStmt::StopTimer { .. } => "Stop the timer",
1157 UiStmt::StartTimer { .. } => "Start the timer",
1158 _ => "this verb",
1159 }
1160}
1161
1162#[derive(Clone, Debug, Serialize)]
1164pub struct TestTierComparison {
1165 pub test: String,
1166 pub reference: PageTrace,
1168 pub candidates: Vec<(PageTierKind, TierRun)>,
1171}
1172
1173impl TestTierComparison {
1174 pub fn divergence(&self, kind: PageTierKind) -> Option<Divergence> {
1176 self.candidates
1177 .iter()
1178 .find(|(k, _)| *k == kind)
1179 .and_then(|(_, run)| match run {
1180 TierRun::Traced(t) => Some(self.reference.compare(t)),
1181 TierRun::Refused { .. } => None,
1182 })
1183 }
1184}
1185
1186pub fn compare_tests(
1191 source: &str,
1192 candidates: &[PageTierKind],
1193 suite_seed: u64,
1194 budget: u64,
1195 launcher: Option<&dyn RemotePageLauncher>,
1196) -> Result<Vec<TestTierComparison>, String> {
1197 let stripped = crate::analysis::assemble::strip_main_block(source);
1198 crate::ui_bridge::with_parsed_program(&stripped, |parsed, interner| {
1199 let (stmts, types, policies) = parsed?;
1200 let defs: Vec<Stmt> = stmts
1201 .iter()
1202 .filter(|s| !matches!(s, Stmt::TestDef { .. }))
1203 .cloned()
1204 .collect();
1205 let mut out = Vec::new();
1206 for s in stmts.iter() {
1207 let Stmt::TestDef {
1208 name,
1209 body,
1210 cases: None,
1211 skip: None,
1212 capabilities,
1213 budget: b,
1214 } = s
1215 else {
1216 continue;
1217 };
1218 if !works_a_page(&defs, body) {
1219 continue;
1220 }
1221 let caps = logicaffeine_system::testworld::Capabilities {
1222 filesystem: capabilities.filesystem,
1223 network: capabilities.network,
1224 environment: capabilities.environment,
1225 clock: capabilities.clock,
1226 };
1227 let test = interner.resolve(*name).to_string();
1228 let scenario = Scenario::new(
1229 interner,
1230 types,
1231 &policies,
1232 &defs,
1233 body,
1234 World::for_test(&test, suite_seed, caps, b.unwrap_or(budget)),
1235 );
1236 let here = scenario.lowered_here_run();
1237 let candidates = candidates
1238 .iter()
1239 .map(|k| match k {
1240 PageTierKind::LoweredHere => (*k, here.tier_run()),
1241 _ => (*k, scenario.run(*k, launcher, &stripped)),
1242 })
1243 .collect();
1244 out.push(TestTierComparison {
1245 test,
1246 reference: here.trace,
1247 candidates,
1248 });
1249 }
1250 Ok(out)
1251 })
1252}
1253
1254pub fn assemble_stable(entry: &Path) -> Result<String, String> {
1266 let mut last_ok: Option<String> = None;
1267 let mut last_err: Option<String> = None;
1268 for attempt in 0..20 {
1269 if attempt > 0 {
1270 std::thread::sleep(Duration::from_millis(5));
1271 }
1272 match crate::analysis::assemble_project(entry) {
1273 Ok(assembled) if last_ok.as_deref() == Some(assembled.source.as_str()) => {
1274 return Ok(assembled.source);
1275 }
1276 Ok(assembled) => last_ok = Some(assembled.source),
1277 Err(e) => last_err = Some(e),
1278 }
1279 }
1280 last_ok.ok_or_else(|| {
1281 last_err.unwrap_or_else(|| format!("could not stably assemble {}", entry.display()))
1282 })
1283}
1284
1285impl<'a> Scenario<'a> {
1286 pub fn run(
1288 &self,
1289 kind: PageTierKind,
1290 launcher: Option<&dyn RemotePageLauncher>,
1291 source: &str,
1292 ) -> TierRun {
1293 match kind {
1294 PageTierKind::LoweredHere => self.lowered_here_trace(),
1295 PageTierKind::LoweredVm => self.lowered_vm_trace(),
1296 PageTierKind::Browser => match launcher {
1297 Some(l) => self.remote_trace(l, source),
1298 None => TierRun::Refused {
1299 statement: 0,
1300 reason: "no browser is attached to this run".to_string(),
1301 },
1302 },
1303 }
1304 }
1305}
1306
1307pub fn works_a_page(defs: &[Stmt], body: &[Stmt]) -> bool {
1309 defs.iter()
1310 .chain(body)
1311 .any(|s| matches!(s, Stmt::Ui(UiStmt::Mount { .. })))
1312}