//! Compile complete source in a fresh VM or retain a worker-owned Console VM. //! Console retains mounted native references and publishes immutable frames after successful inputs. use anyhow::{Result, bail}; use endor_core::{ExecutionLimits, ObjectRef, Value, Vm, VmError}; use frontend::{ EndorReader, SourceUnit, Span, ast::{AstRoot, Decl, DeclKind, Expr, ExprKind, Spanned, Stmt, StmtKind, visitor::Visitor}, }; use std::collections::BTreeSet; use crate::{ document::{CodeNode, RenderDocument}, endor_script::{self, SourcePlan}, layout, ui_bridge, }; pub const MAX_SOURCE_BYTES: usize = 1_048_576; const LIMITS: ExecutionLimits = ExecutionLimits { max_frames: 128, max_instructions: 1_000_000, }; const MAX_AST_NODES: usize = 10_000; const MAX_AST_DEPTH: usize = 64; pub fn compile(source: &str, namespace: &str, submission: u64) -> Result { if source.len() > MAX_SOURCE_BYTES { bail!("source exceeds the 1 MiB limit"); } let source = EndorReader::from_text(format!("{namespace} [submission {submission}]"), source); let plan = endor_script::parse(&source)?; let mut budget = AstBudget::default(); for ast in plan.roots() { budget.visit_ast_root(ast)?; } let compiled = compile_plan(&source, &plan)?; let root = architecture(&source, &plan, namespace, submission); let mut candidate = Candidate::new()?; let result: Result = (|| { let globals = candidate.vm.new_dict()?; candidate.globals = Some(globals); candidate.roots.push(globals); let bridge = ui_bridge::install(&mut candidate.vm, &mut candidate.roots)?; bridge.install_globals(&mut candidate.vm, globals)?; let limits = match &plan { SourcePlan::Module(_) => LIMITS, SourcePlan::Script(_) => ExecutionLimits { max_instructions: LIMITS.max_instructions / compiled.len().max(1), ..LIMITS }, }; for template in &compiled { let code = template.load(&mut candidate.vm)?; candidate.roots.push(code); let value = candidate .vm .execute_with_limits(code, globals, limits) .map_err(|error| runtime_error(&candidate.vm, error))?; candidate.vm.release_value(value)?; } let ui_root = match &plan { SourcePlan::Script(_) => bridge .rendered(&candidate.vm)? .ok_or_else(|| anyhow::anyhow!("script must call ui.page().render(root)"))?, SourcePlan::Module(_) => { let entry = candidate .vm .dict_get(globals, &Value::Text("main".into()))? .ok_or_else(|| { anyhow::anyhow!("declare main or render a page from a top-level script") })?; let Value::Object(entry) = entry else { bail!("main must be a callable function"); }; let value = candidate .vm .call_with_limits(entry, &[], LIMITS) .map_err(|error| runtime_error(&candidate.vm, error))?; let snapshot = bridge .rendered(&candidate.vm) .and_then(|rendered| match rendered { Some(root) => Ok(root), None => ui_bridge::snapshot(&candidate.vm, &value), }); candidate.vm.release_value(value)?; snapshot? } }; let document = RenderDocument { source: source.text().to_owned(), root: Some(root), frame: layout::frame(&ui_root)?, ui_root: Some(ui_root), }; document.validate()?; Ok(document) })(); let cleanup = candidate.close(); match (result, cleanup) { (Ok(document), Ok(())) => Ok(document), (Err(error), Ok(())) | (Ok(_), Err(error)) => Err(error), (Err(error), Err(cleanup)) => Err(error.context(format!("candidate cleanup: {cleanup}"))), } } /// Evaluate exactly one expression in a disposable VM; never touch the published page. pub fn evaluate(source: &str) -> Result { if source.len() > MAX_SOURCE_BYTES { bail!("expression exceeds the 1 MiB limit"); } let source = EndorReader::from_text("console expression", source); let ast = frontend::parse_source_ast(&source, frontend::ParseMode::Expression)?; AstBudget::default().visit_ast_root(&ast)?; let compiled = compiler::compile_ast(&ast, &source, &compiler::CompileOptions::default()) .map_err(|error| source.annotate(error))?; let mut candidate = Candidate::new()?; let result = (|| { let globals = candidate.vm.new_dict()?; candidate.globals = Some(globals); candidate.roots.push(globals); let code = compiled.load(&mut candidate.vm)?; candidate.roots.push(code); let value = candidate .vm .execute_with_limits(code, globals, LIMITS) .map_err(|error| runtime_error(&candidate.vm, error))?; let rendered = match &value { Value::Nil => "nil".to_owned(), Value::Bool(v) => v.to_string(), Value::Text(v) => format!("{v:?}"), Value::Number(v) => v.to_string(), Value::Object(v) => candidate.vm.repr(*v)?, }; candidate.vm.release_value(value)?; Ok(rendered) })(); let cleanup = candidate.close(); match (result, cleanup) { (Ok(value), Ok(())) => Ok(value), (Err(e), _) | (_, Err(e)) => Err(e), } } fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result> { compile_plan_with_globals( source, plan, std::iter::once("ui") .chain(ui_bridge::TYPE_NAMES.iter().copied()) .chain(ui_bridge::QUERY_NAMES.iter().copied()) .map(str::to_owned) .collect(), ) } fn compile_plan_with_globals( source: &SourceUnit, plan: &SourcePlan, mut globals: BTreeSet, ) -> Result> { // Match Aster module behavior for function forward references. Bindings are // added in source order; every executable statement still uses Aster's own // single-input Interactive compiler and the candidate's shared globals. for root in plan.roots() { if let AstRoot::Interactive(input) = root { for statement in &input.statements { if let StmtKind::Declaration(decl) = &statement.kind { let name = match &decl.kind { DeclKind::Function(function) => &function.name, DeclKind::Class(class) => &class.name, }; globals.insert(name.text.clone()); } } } } let mut compiled = Vec::with_capacity(plan.roots().len()); for root in plan.roots() { let options = compiler::CompileOptions { globals: globals.iter().cloned().collect(), }; compiled.push( compiler::compile_ast(root, source, &options) .map_err(|error| source.annotate(error))?, ); if let AstRoot::Interactive(input) = root { for statement in &input.statements { if let StmtKind::Binding(binding) = &statement.kind { globals.insert(binding.name.text.clone()); } else if let StmtKind::Assignment(assignment) = &statement.kind { globals.insert(assignment.name.text.clone()); } } } } Ok(compiled) } /// One real VM, owned only by the existing execution worker. No history replay. /// The UI bridge shares the same managed object heap and publishes atomic frames. pub struct ReplSession { candidate: Candidate, bridge: ui_bridge::UiBridge, pub id: u64, inputs: usize, ast_nodes: usize, source: String, nodes: Vec, faulted: bool, program_root: Option, } pub struct ReplResult { pub value: String, pub document: Option, pub bindings: usize, pub input: usize, } impl ReplSession { /// Compile once into an isolated candidate, then keep its actual heap and /// callback environments alive after the first successful publication. pub fn load( source: &str, namespace: &str, submission: u64, id: u64, ) -> Result<(Self, RenderDocument)> { Self::load_with_viewport(source, namespace, submission, id, None) } pub fn load_with_viewport( source: &str, namespace: &str, submission: u64, id: u64, viewport: Option, ) -> Result<(Self, RenderDocument)> { if source.len() > MAX_SOURCE_BYTES { bail!("source exceeds the 1 MiB limit"); } let unit = EndorReader::from_text(format!("{namespace} [submission {submission}]"), source); let plan = endor_script::parse(&unit)?; let mut budget = AstBudget::default(); for ast in plan.roots() { budget.visit_ast_root(ast)?; } let compiled = compile_plan(&unit, &plan)?; let mut runtime = Self::new(id)?; runtime .bridge .set_viewport(&mut runtime.candidate.vm, viewport)?; let globals = runtime.candidate.globals.expect("program globals"); let limits = if matches!(plan, SourcePlan::Script(_)) { ExecutionLimits { max_instructions: LIMITS.max_instructions / compiled.len().max(1), ..LIMITS } } else { LIMITS }; for template in &compiled { let code = template.load(&mut runtime.candidate.vm)?; let result = runtime .candidate .vm .execute_with_limits(code, globals, limits) .map_err(|error| runtime_error(&runtime.candidate.vm, error)); runtime.candidate.vm.release(code)?; runtime.candidate.vm.release_value(result?)?; } if matches!(plan, SourcePlan::Module(_)) { let main = runtime .candidate .vm .dict_get(globals, &Value::Text("main".into()))? .ok_or_else(|| anyhow::anyhow!("declare a main function"))?; let Value::Object(main) = main else { bail!("main must be callable"); }; let value = runtime .candidate .vm .call_with_limits(main, &[], LIMITS) .map_err(|error| runtime_error(&runtime.candidate.vm, error))?; let opened = if runtime.bridge.has_page()? { Ok(()) } else { runtime.bridge.open_value(&mut runtime.candidate.vm, &value) }; runtime.candidate.vm.release_value(value)?; opened?; } if !runtime.bridge.has_page()? { bail!("script must call page.render(root) or ui.open(value)"); } runtime.source = source.into(); runtime.ast_nodes = budget.nodes; runtime.program_root = Some(architecture(&unit, &plan, namespace, submission)); let document = runtime .refresh_document()? .ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?; runtime .bridge .reclaim(&mut runtime.candidate.vm, &mut runtime.candidate.roots)?; Ok((runtime, document)) } pub fn new_with_viewport(id: u64, viewport: Option) -> Result { let mut runtime = Self::new(id)?; runtime .bridge .set_viewport(&mut runtime.candidate.vm, viewport)?; Ok(runtime) } pub fn new(id: u64) -> Result { let mut candidate = Candidate::new()?; let globals = candidate.vm.new_dict()?; candidate.globals = Some(globals); candidate.roots.push(globals); let bridge = ui_bridge::install(&mut candidate.vm, &mut candidate.roots)?; bridge.install_globals(&mut candidate.vm, globals)?; Ok(Self { candidate, bridge, id, inputs: 0, ast_nodes: 0, source: String::new(), nodes: Vec::new(), faulted: false, program_root: None, }) } pub fn execute(&mut self, source: &str, namespace: &str) -> Result { if self.faulted { bail!( "runtime stopped after a callback or rendering failure; reset the session or replace its program" ); } if source.len() > MAX_SOURCE_BYTES { bail!("input exceeds 1 MiB"); } if self.inputs >= 256 || self.source.len() + source.len() + 80 > MAX_SOURCE_BYTES { bail!("console session exceeds 256 inputs or 1 MiB; reset the session to continue"); } let source = EndorReader::from_text( format!("console session {} input {}", self.id, self.inputs + 1), source, ); let plan = endor_script::parse(&source)?; let mut budget = AstBudget::default(); for ast in plan.roots() { budget.visit_ast_root(ast)?; } if self.ast_nodes + budget.nodes > MAX_AST_NODES { bail!("console session exceeds 10000 AST nodes; reset the session to continue"); } let globals = self.candidate.globals.expect("live console globals"); let names = self .candidate .vm .dict_items(globals)? .iter() .filter_map(|(key, _)| { if let Value::Text(name) = key { Some(name.clone()) } else { None } }) .collect(); let compiled = compile_plan_with_globals(&source, &plan, names)?; // Parsing/compilation finish before any new statement runs. Successful earlier // statements in a runtime-failed input remain live, as in an ordinary REPL. self.bridge.discard_pending(&mut self.candidate.vm)?; self.bridge .reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?; self.inputs += 1; self.ast_nodes += budget.nodes; if let Some(root) = self.program_root.take() { self.nodes.push(root); } if !self.source.is_empty() && !self.source.ends_with('\n') { self.source.push('\n'); } let offset = line_starts(&self.source).len(); self.source .push_str(&format!("// Console input {}\n", self.inputs)); self.source.push_str(source.text()); if !self.source.ends_with('\n') { self.source.push('\n'); } let mut node = architecture( &source, &plan, &format!("{namespace}:console-{}", self.id), self.inputs as u64, ); node.name = format!("Input {}", self.inputs); shift_node_lines(&mut node, offset); self.nodes.push(node); let result: Result = (|| { let limits = ExecutionLimits { max_instructions: LIMITS.max_instructions / compiled.len().max(1), ..LIMITS }; let mut rendered_value = "nil".to_owned(); for (ast, template) in plan.roots().iter().zip(&compiled) { let code = template.load(&mut self.candidate.vm)?; let executed = self .candidate .vm .execute_with_limits(code, globals, limits) .map_err(|error| runtime_error(&self.candidate.vm, error)); self.candidate.vm.release(code)?; let value = executed?; // Keep the actual last result accessible as `_`, including unnamed UI objects. let display = if let AstRoot::Interactive(input) = ast { match &input.statements[0].kind { StmtKind::Binding(binding) => self .candidate .vm .dict_get(globals, &Value::Text(binding.name.text.clone()))? .unwrap_or_else(|| value.clone()), StmtKind::Assignment(assignment) => self .candidate .vm .dict_get(globals, &Value::Text(assignment.name.text.clone()))? .unwrap_or_else(|| value.clone()), _ => value.clone(), } } else { value.clone() }; let repr = value_repr(&self.candidate.vm, &display); let retained = self.candidate .vm .dict_set(globals, Value::Text("_".into()), display); self.candidate.vm.release_value(value)?; retained?; rendered_value = repr?; } let document = self.refresh_document()?; Ok(ReplResult { value: rendered_value, document, bindings: self .candidate .vm .dict_items(globals)? .len() .saturating_sub(1), input: self.inputs, }) })(); if result.is_err() { self.take_window_commands()?; self.bridge.discard_pending(&mut self.candidate.vm)?; if let Some(node) = self.nodes.last_mut() { node.name .push_str(" (error; earlier statements may remain live)"); } } self.bridge .reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?; result } fn refresh_document(&mut self) -> Result> { let mut last = None; let mut work = 0; let mut mutated_mounts = false; let result = (|| loop { let Some(root) = self.bridge.prepare_frame(&mut self.candidate.vm)? else { return Ok(last); }; mutated_mounts = true; let frame = self .bridge .layout_frame(&mut self.candidate.vm, root.clone())?; let mut document = RenderDocument { source: self.source.clone(), root: Some(self.program_root.clone().unwrap_or_else(|| CodeNode { id: format!("console-session-{}", self.id), name: format!("Console session {}", self.id), kind: "repl-session".into(), start_line: 1, end_line: self.source.lines().count(), children: self.nodes.clone(), })), frame, ui_root: root.clone(), }; document.frame.session = self.id; document.validate()?; work += document.frame.mounts.len().max(1); if work > 200_000 { bail!("layout dependencies did not stabilize within the rendering work budget"); } self.bridge.paint_frame( &mut self.candidate.vm, root.as_deref(), &mut document.frame, )?; document.validate()?; last = Some(document); })(); if result.is_err() && mutated_mounts { self.faulted = true; } result } pub fn take_window_commands(&self) -> Result> { self.bridge.take_window_commands(&self.candidate.vm) } pub fn resize( &mut self, viewport: Option, ) -> Result> { if self.faulted { bail!("runtime stopped after a callback or rendering failure"); } if !self.bridge.set_viewport(&mut self.candidate.vm, viewport)? { return Ok(None); } let result = self.refresh_document(); if result.is_err() { self.take_window_commands()?; self.bridge.discard_pending(&mut self.candidate.vm)?; } self.bridge .reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?; result } pub fn is_faulted(&self) -> bool { self.faulted } pub fn dispatch(&mut self, input: &crate::events::Input) -> Result { if self.faulted { bail!("runtime stopped after a callback or rendering failure"); } if input.session != self.id { bail!("event belongs to another runtime session"); } let result = (|| { self.bridge.dispatch(&mut self.candidate.vm, input)?; let document = self.refresh_document()?; Ok(ReplResult { value: "nil".into(), document, bindings: self .candidate .vm .dict_items(self.candidate.globals.expect("live globals"))? .len() .saturating_sub(1), input: self.inputs, }) })(); if result.is_err() { self.take_window_commands()?; self.faulted = true; self.bridge.discard_pending(&mut self.candidate.vm)?; } self.bridge .reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?; result } } fn shift_node_lines(node: &mut CodeNode, offset: usize) { node.start_line += offset; node.end_line += offset; for child in &mut node.children { shift_node_lines(child, offset); } } fn value_repr(vm: &Vm, value: &Value) -> Result { Ok(match value { Value::Nil => "nil".into(), Value::Bool(v) => v.to_string(), Value::Text(v) => format!("{v:?}"), Value::Number(v) => v.to_string(), Value::Object(v) => vm.repr(*v)?, }) } struct Candidate { vm: Vm, roots: Vec, globals: Option, } impl Candidate { fn new() -> Result { Ok(Self { vm: Vm::new()?, roots: Vec::new(), globals: None, }) } fn close(&mut self) -> Result<()> { if let Some(globals) = self.globals.take() { self.vm.dict_clear(globals)?; } while let Some(root) = self.roots.pop() { self.vm.release(root)?; } self.vm.collect_cycles(); if self.vm.object_count() != 0 { bail!("candidate VM retained objects after cleanup"); } Ok(()) } } impl Drop for Candidate { fn drop(&mut self) { // Explicit close reports accounting errors; dropping the fresh VM is // also the final cleanup boundary on every early return or unwind. let _ = self.close(); } } pub(crate) fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error { let locations = vm .last_traceback() .iter() .map(|frame| { format!( "{}:{} in {} (instruction {})", frame.filename, frame.first_line, frame.function, frame.instruction_offset ) }) .collect::>() .join("\n"); if locations.is_empty() { error.into() } else { anyhow::anyhow!("{error}\n{locations}") } } #[derive(Default)] struct AstBudget { depth: usize, nodes: usize, } impl Visitor for AstBudget { type Error = anyhow::Error; fn enter_node(&mut self, _span: Span) -> Result<()> { self.depth += 1; self.nodes += 1; if self.depth > MAX_AST_DEPTH || self.nodes > MAX_AST_NODES { bail!("Aster AST exceeds the 64-level or 10000-node host budget"); } Ok(()) } fn leave_node(&mut self) { self.depth -= 1; } } fn architecture( source: &SourceUnit, plan: &SourcePlan, namespace: &str, submission: u64, ) -> CodeNode { let mut tree = Architecture { line_starts: line_starts(source.text()), next_id: 0, identity: format!("{namespace}@{submission}"), }; let mut children = Vec::new(); for root in plan.roots() { match root { AstRoot::Module(program) => children.extend( program .declarations .iter() .map(|decl| tree.declaration(decl)), ), AstRoot::Interactive(input) => { children.extend(input.statements.iter().map(|stmt| tree.statement(stmt))) } AstRoot::Expression(expression) => children.push(tree.expression(expression)), } } let roots = plan.roots(); let span = Span::new( roots.first().map_or(0, |root| root.span().start), roots.last().map_or(0, |root| root.span().end), ); let kind = if matches!(plan, SourcePlan::Module(_)) { "module" } else { "script" }; tree.node(namespace.to_owned(), kind, span, children) } fn line_starts(source: &str) -> Vec { let bytes = source.as_bytes(); let mut starts = vec![0]; for (offset, byte) in bytes.iter().enumerate() { if *byte == b'\n' || (*byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n')) { starts.push(offset + 1); } } starts } struct Architecture { line_starts: Vec, next_id: usize, identity: String, } impl Architecture { fn node(&mut self, name: String, kind: &str, span: Span, children: Vec) -> CodeNode { let id = self.next_id; self.next_id += 1; let line = |offset| self.line_starts.partition_point(|start| *start <= offset); CodeNode { id: format!("{}:ast-{id}", self.identity), name, kind: kind.into(), start_line: line(span.start), end_line: line(span.end.saturating_sub(1).max(span.start)), children, } } fn declaration(&mut self, declaration: &Decl) -> CodeNode { match &declaration.kind { DeclKind::Function(function) => self.function(function), DeclKind::Class(class) => { let mut children: Vec<_> = class .fields .iter() .map(|field| self.node(field.name.text.clone(), "field", field.span, vec![])) .collect(); children.extend(class.methods.iter().map(|method| self.function(method))); self.node(class.name.text.clone(), "class", class.span, children) } } } fn function(&mut self, function: &frontend::ast::Function) -> CodeNode { let children = function .body .statements .iter() .map(|stmt| self.statement(stmt)) .collect(); self.node( function.name.text.clone(), "function", function.span, children, ) } fn statement(&mut self, statement: &Stmt) -> CodeNode { match &statement.kind { StmtKind::MemberAssignment(assignment) => { let object = self.expression(&assignment.object); let value = self.expression(&assignment.value); self.node( assignment.name.text.clone(), "member-assign", statement.span, vec![object, value], ) } StmtKind::Binding(binding) => { let child = self.expression(&binding.value); self.node( binding.name.text.clone(), "let", statement.span, vec![child], ) } StmtKind::Assignment(assignment) => { let child = self.expression(&assignment.value); self.node( assignment.name.text.clone(), "assign", statement.span, vec![child], ) } StmtKind::Return(returned) => { let children = returned .value .iter() .map(|expr| self.expression(expr)) .collect(); self.node("return".into(), "return", statement.span, children) } StmtKind::Expression(expr) => self.expression(expr), StmtKind::Declaration(decl) => self.declaration(decl), } } fn expression(&mut self, expr: &Expr) -> CodeNode { let (name, kind, children) = match &expr.kind { ExprKind::Name(name) => (name.clone(), "name", vec![]), ExprKind::Number(value) => (value.clone(), "number", vec![]), ExprKind::String(value) => (value.chars().take(40).collect(), "string", vec![]), ExprKind::Symbol(name) => (name.text.clone(), "symbol", vec![]), ExprKind::Unary { operand, .. } => { ("negate".into(), "unary", vec![self.expression(operand)]) } ExprKind::Binary { left, right, .. } => ( "binary".into(), "binary", vec![self.expression(left), self.expression(right)], ), ExprKind::Member { object, name } => { (name.text.clone(), "member", vec![self.expression(object)]) } ExprKind::Call { callee, arguments } => { let mut children = vec![self.expression(callee)]; children.extend(arguments.iter().map(|expr| self.expression(expr))); ("call".into(), "call", children) } }; self.node(name, kind, expr.span, children) } } #[cfg(test)] mod tests { use super::*; use crate::document::{NamespaceState, RenderCommand}; const EXAMPLE: &str = include_str!("../examples/hello.endor"); const PAGE: &str = include_str!("../examples/page.endor"); #[test] fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_requests() { let mut session = ReplSession::new(111).unwrap(); session .execute( r#"window = ui.window() window.resize(900,600) window.resize(1000,700) window.setTitle("Endor") window.focus()"#, "app.main", ) .unwrap(); let requests = session.take_window_commands().unwrap(); assert_eq!(requests.len(), 3); assert!(matches!( requests[0].action, crate::window::Action::Resize { width: 1000.0, height: 700.0 } )); assert!( matches!(&requests[1].action,crate::window::Action::SetTitle(title) if title=="Endor") ); assert!(session.take_window_commands().unwrap().is_empty()); assert!( session .execute("window.resize(1400,900)\nwindow.resize(0,600)", "app.main") .is_err() ); assert!(session.take_window_commands().unwrap().is_empty()); assert!(session.execute("window.resize(0,600)", "app.main").is_err()); assert!(session.take_window_commands().unwrap().is_empty()); } #[test] fn window_page_roots_follow_open_close_and_creation_order() { let source = r#"first = Page() second = Page() first.render(TextBlock("first")) second.render(TextBlock("second")) window = ui.window()"#; let (mut session, _) = ReplSession::load(source, "app.main", 112, 1).unwrap(); assert_eq!( session .execute("window.pages().count", "app.main") .unwrap() .value, "2" ); let first = session.execute("first.window()", "app.main").unwrap().value; assert_eq!( first, session .execute("second.window()", "app.main") .unwrap() .value ); session.execute("first.close()", "app.main").unwrap(); assert_eq!( session.execute("first.window()", "app.main").unwrap().value, "nil" ); assert_eq!( session .execute("window.pages().count", "app.main") .unwrap() .value, "1" ); session.execute("first.open(window)", "app.main").unwrap(); assert_eq!( session .execute("window.pages().count", "app.main") .unwrap() .value, "2" ); assert_eq!( session .execute("window.pages().at(0)", "app.main") .unwrap() .value, session.execute("second", "app.main").unwrap().value ); assert!(session.execute("first.open(Object())", "app.main").is_err()); } #[test] fn closed_page_is_not_kept_alive_by_window_page_order() { let (mut session, _) = ReplSession::load( "page = Page()\npage.render(TextBlock(\"page\"))", "app.main", 113, 1, ) .unwrap(); let globals = session.candidate.globals.unwrap(); let Value::Object(page) = session .candidate .vm .dict_get(globals, &Value::Text("page".into())) .unwrap() .unwrap() else { panic!("Page") }; let weak = session.candidate.vm.downgrade(page).unwrap(); session.execute("page.close()", "app.main").unwrap(); session .candidate .vm .dict_delete(globals, &Value::Text("page".into())) .unwrap(); session.candidate.vm.collect_cycles(); assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); } #[test] fn rendering_cannot_enqueue_native_window_side_effects() { let source = r##"class BadRect: RectBlock { function measure(self: BadRect, mount: Mount, constraints: Constraints): MeasureResult { ui.window().resize(900,600) return MeasureResult(80,20) } } page = Page() page.render(BadRect(80,20,0,"#DDEEFF"))"##; let error = ReplSession::load(source, "app.main", 114, 1).err().unwrap(); assert!( error .to_string() .contains("window operations are forbidden") ); } fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 { let Value::Object(function) = session .candidate .vm .get_attr(session.bridge.ui, "stats") .unwrap() else { panic!("stats function") }; let Value::Object(stats) = session.candidate.vm.call(function, &[]).unwrap() else { panic!("stats") }; let Value::Number(value) = session.candidate.vm.get_attr(stats, name).unwrap() else { panic!("stat number") }; session.candidate.vm.release(stats).unwrap(); value.as_i64().unwrap() } fn click_named( session: &mut ReplSession, document: &RenderDocument, name: &str, ) -> RenderDocument { let globals = session.candidate.globals.unwrap(); let Value::Object(block) = session .candidate .vm .dict_get(globals, &Value::Text(name.into())) .unwrap() .unwrap() else { panic!("Block") }; let mount = document .frame .mounts .iter() .find(|mount| mount.block_id == block.to_string()) .unwrap(); session .dispatch(&crate::events::Input { session: session.id, layout_version: document.frame.layout_version, mount: mount.id, kind: "click".into(), point: None, press_point: None, }) .unwrap() .document .unwrap() } #[test] fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes() { let (mut session, initial) = ReplSession::load_with_viewport( include_str!("../examples/layout-cache.endor"), "app.main", 101, 1, Some(crate::layout::Size { width: 800.0, height: 600.0, }), ) .unwrap(); assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 2); let painted = click_named(&mut session, &initial, "blue"); assert!(painted.frame.commands.iter().any( |command| matches!(command,RenderCommand::RoundedRect {color,..} if color=="#526AC7") )); assert_eq!(source_measure_stat(&mut session, "sourceMeasureHits"), 2); assert_eq!(source_measure_stat(&mut session, "measured"), 0); let resized = click_named(&mut session, &painted, "expand"); assert!( resized .frame .mounts .iter() .any(|mount| mount.width == 260.0 && mount.height == 60.0) ); assert_eq!(source_measure_stat(&mut session, "sourceMeasureHits"), 1); assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1); } #[test] fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() { let mut session = ReplSession::new_with_viewport( 104, Some(crate::layout::Size { width: 800.0, height: 600.0, }), ) .unwrap(); assert!( session .execute("42", "app.main") .unwrap() .document .is_none() ); assert!( session .resize(Some(crate::layout::Size { width: 360.0, height: 240.0 })) .unwrap() .is_none() ); let result = session.execute("ui.viewport().width", "app.main").unwrap(); assert_eq!(result.value, "360"); assert!(result.document.is_none()); } #[test] fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { let source = r##"class HeldRect: RectBlock { result: MeasureResult function measure(self: HeldRect, mount: Mount, constraints: Constraints): MeasureResult { return self.result } } result = MeasureResult(80,20) rect = HeldRect(80,20,0,"#DDEEFF",result) page = Page() page.render(rect)"##; let (mut session, initial) = ReplSession::load(source, "app.main", 103, 1).unwrap(); let id = initial .frame .mounts .iter() .find(|mount| mount.width == 80.0) .unwrap() .id; let globals = session.candidate.globals.unwrap(); let Value::Object(result) = session .candidate .vm .dict_get(globals, &Value::Text("result".into())) .unwrap() .unwrap() else { panic!("result") }; session .candidate .vm .set_attr(result, "width", Value::Number(140.into())) .unwrap(); let next = session.refresh_document().unwrap().unwrap(); assert!( next.frame .mounts .iter() .any(|mount| mount.id == id && mount.width == 140.0) ); assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1); assert!(session.refresh_document().unwrap().is_none()); } #[test] fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_source_input() { let (mut session, initial) = ReplSession::load_with_viewport( include_str!("../examples/layout-cache.endor"), "app.main", 102, 1, Some(crate::layout::Size { width: 800.0, height: 600.0, }), ) .unwrap(); let ids: Vec<_> = initial.frame.mounts.iter().map(|mount| mount.id).collect(); let document = session .resize(Some(crate::layout::Size { width: 360.0, height: 240.0, })) .unwrap() .unwrap(); assert_eq!( ids, document .frame .mounts .iter() .map(|mount| mount.id) .collect::>() ); assert!( document .frame .mounts .iter() .any(|mount| mount.width == 312.0 && mount.height == 24.0) ); assert_eq!(session.inputs, 0); assert!( session .resize(Some(crate::layout::Size { width: 360.0, height: 240.0 })) .unwrap() .is_none() ); assert!( session .resize(Some(crate::layout::Size { width: f32::NAN, height: 240.0 })) .is_err() ); assert!(!session.is_faulted()); } const SOURCE_LAYOUT: &str = include_str!("../examples/source-layout.endor"); #[test] fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependencies() { let (mut session, initial) = ReplSession::load(SOURCE_LAYOUT, "app.main", 91, 1).unwrap(); let rect = initial .frame .mounts .iter() .find(|mount| mount.width == 200.0 && mount.height == 60.0) .unwrap(); assert_eq!((rect.x, rect.y), (44.0, 24.0)); let id = rect.id; assert!(initial.frame.commands.iter().any(|command| matches!(command,RenderCommand::Text { value,y,.. } if value.starts_with("尺寸来自") && *y == 100.0))); let globals = session.candidate.globals.unwrap(); let Value::Object(metrics) = session .candidate .vm .dict_get(globals, &Value::Text("metrics".into())) .unwrap() .unwrap() else { panic!("Metrics") }; session .candidate .vm .set_attr(metrics, "width", Value::Number(260.into())) .unwrap(); let next = session.refresh_document().unwrap().unwrap(); let rect = next .frame .mounts .iter() .find(|mount| mount.id == id) .unwrap(); assert_eq!((rect.x, rect.width), (44.0, 260.0)); assert!(session.refresh_document().unwrap().is_none()); } #[test] fn shared_block_source_measurements_belong_to_each_occurrence() { let source = SOURCE_LAYOUT.replace( "page.render(root)", r#"otherMetrics = Metrics(140, 12) other = OffsetColumn(24, 16, rect, otherMetrics) page.render(Row(0, 0, root, other))"#, ); let (mut session, initial) = ReplSession::load(&source, "app.main", 92, 1).unwrap(); let globals = session.candidate.globals.unwrap(); let Value::Object(rect) = session .candidate .vm .dict_get(globals, &Value::Text("rect".into())) .unwrap() .unwrap() else { panic!("Rect") }; let widths: Vec<_> = initial .frame .mounts .iter() .filter(|mount| mount.block_id == rect.to_string()) .map(|mount| mount.width) .collect(); assert_eq!(widths, vec![200.0, 140.0]); assert!(session.refresh_document().unwrap().is_none()); } #[test] fn layout_dependencies_changed_by_mount_hook_are_measured_again_before_publication() { let source = r##"class Metrics { width: Number } class InitialRect: RectBlock { metrics: Metrics function measure(self: InitialRect, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(self.metrics.width, 20) } function onMount(self: InitialRect, mount: Mount): Unit { self.metrics.width = 140 } } metrics = Metrics(80) rect = InitialRect(80,20,0,"#DDEEFF",metrics) page = Page() page.render(rect)"##; let (mut session, document) = ReplSession::load(source, "app.main", 93, 1).unwrap(); assert!( document .frame .mounts .iter() .any(|mount| mount.width == 140.0 && mount.height == 20.0) ); assert!(session.refresh_document().unwrap().is_none()); } #[test] fn source_layout_rejects_premature_geometry_writes_wrong_types_and_foreign_slots() { for (before, after, message) in [ ( "return MeasureResult(parent.block.metrics.width, 60)", "return mount.bounds()", "geometry is unavailable during measurement", ), ( "return MeasureResult(parent.block.metrics.width, 60)", "parent.block.metrics.width = 260\nreturn MeasureResult(260,60)", "read-only", ), ( "return MeasureResult(parent.block.metrics.width, 60)", "return Point(0,0)", "wrong result type", ), ( "positions.children.at(0).x = self.metrics.left", "positions.children.at(0).slot = 999999", "foreign child slot", ), ] { let source = SOURCE_LAYOUT.replace(before, after); let error = ReplSession::load(&source, "app.main", 94, 1).err().unwrap(); assert!(error.to_string().contains(message), "{error}"); } } #[test] fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() { for (press, release, expected_hits, expected_background) in [ ([64.0, 64.0], [64.0, 64.0], "1", "0"), ([144.0, 64.0], [144.0, 64.0], "0", "1"), ([64.0, 64.0], [144.0, 64.0], "0", "0"), ([9999.0, 9999.0], [9999.0, 9999.0], "0", "0"), ] { let (mut session, initial) = ReplSession::load( include_str!("../examples/source-hit.endor"), "app.main", 81, 1, ) .unwrap(); let circle = initial .frame .mounts .iter() .find(|mount| mount.interactive && mount.width == 160.0) .unwrap(); session .dispatch(&crate::events::Input { session: 81, layout_version: initial.frame.layout_version, mount: circle.id, kind: "click".into(), point: Some(release), press_point: Some(press), }) .unwrap(); assert_eq!( session.execute("hits.value", "app.main").unwrap().value, expected_hits ); assert_eq!( session .execute("background.value", "app.main") .unwrap() .value, expected_background ); } } #[test] fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { for (body, message) in [ ("return 1", "hitTest must return Bool"), ( "self.hits.increment()\nreturn point.inCircle(40, 40, 40)", "read-only", ), ] { let source = include_str!("../examples/source-hit.endor") .replace("return point.inCircle(40, 40, 40)", body); let (mut session, initial) = ReplSession::load(&source, "app.main", 82, 1).unwrap(); let circle = initial .frame .mounts .iter() .find(|mount| mount.interactive && mount.width == 160.0) .unwrap(); let error = session .dispatch(&crate::events::Input { session: 82, layout_version: initial.frame.layout_version, mount: circle.id, kind: "click".into(), point: Some([64.0, 64.0]), press_point: Some([64.0, 64.0]), }) .err() .unwrap(); assert!(error.to_string().contains(message), "{error}"); assert!(session.is_faulted()); let globals = session.candidate.globals.unwrap(); let Value::Object(hits) = session .candidate .vm .dict_get(globals, &Value::Text("hits".into())) .unwrap() .unwrap() else { panic!("state") }; assert_eq!( session.candidate.vm.get_attr(hits, "value").unwrap(), Value::Number(0.into()) ); } } #[test] fn ordinary_block_subscription_updates_interactivity_after_disconnect() { let source = r##"hits = ui.state(0) rect = RectBlock(80, 40, 0, "#DDEEFF") token = rect.on("click", hits.increment) page = Page() page.render(rect)"##; let (mut session, initial) = ReplSession::load(source, "app.main", 83, 1).unwrap(); assert!(initial.frame.mounts.iter().any(|mount| mount.interactive)); let result = session.execute("token.disconnect()", "app.main").unwrap(); assert!( result .document .unwrap() .frame .mounts .iter() .all(|mount| !mount.interactive) ); } #[test] fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it() { let source = r##"class ReboundRect: RectBlock { previous: ObjectList function paint(self: ReboundRect, mount: Mount, geometry: Bounds): DisplayList { drawing = self.previous drawing = RectBlock.paint(self, mount, geometry) return drawing } } previous = ObjectList() rect = ReboundRect(80, 40, 0, "#DDEEFF", previous) page = Page() page.render(rect)"##; let (mut session, document) = ReplSession::load(source, "app.main", 75, 1).unwrap(); assert_eq!(document.frame.commands.len(), 1); assert_eq!( session.execute("previous.count", "app.main").unwrap().value, "0" ); } #[test] fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { let (mut session, initial) = ReplSession::load( include_str!("../examples/source-paint.endor"), "app.main", 71, 1, ) .unwrap(); initial.validate().unwrap(); assert_eq!(initial.frame.commands.iter().filter(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#ED6A5A")).count(), 2); assert!(initial.frame.commands.iter().any( |command| matches!(command, RenderCommand::Text { color, .. } if color == "#FFFFFF") )); let globals = session.candidate.globals.unwrap(); let Value::Object(rect) = session .candidate .vm .dict_get(globals, &Value::Text("rect".into())) .unwrap() .unwrap() else { panic!("Block") }; // No Console/global binding writes or focus changes: only the property // read by paint changes, proving the paint dependency is sufficient. session .candidate .vm .set_attr(rect, "tint", Value::Text("#526AC7".into())) .unwrap(); let updated = session.refresh_document().unwrap().unwrap(); assert_eq!(updated.frame.commands.iter().filter(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#526AC7")).count(), 2); assert!(session.refresh_document().unwrap().is_none()); } #[test] fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() { let source = r##"class BadPaint: RectBlock { tint: String function paint(self: BadPaint, mount: Mount, geometry: Bounds): DisplayList { self.tint = "#000000" return RectBlock.paint(self, mount, geometry) } } rect = BadPaint(100, 40, 0, "#DDEEFF", "#FF0000") page = Page() page.render(rect)"##; let mut session = ReplSession::new(72).unwrap(); assert!( session .execute(source, "app.main") .err() .unwrap() .to_string() .contains("read-only") ); assert!(session.is_faulted()); let globals = session.candidate.globals.unwrap(); let Value::Object(rect) = session .candidate .vm .dict_get(globals, &Value::Text("rect".into())) .unwrap() .unwrap() else { panic!("Block") }; assert_eq!( session.candidate.vm.get_attr(rect, "tint").unwrap(), Value::Text("#FF0000".into()) ); session .candidate .vm .set_attr(rect, "tint", Value::Text("#526AC7".into())) .unwrap(); let bad_color = include_str!("../examples/source-paint.endor").replace("#ED6A5A", "invalid color"); assert!(ReplSession::load(&bad_color, "app.main", 73, 1).is_err()); } #[test] fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() { let fixture = include_str!("../examples/source-paint.endor"); let mut source = fixture[..fixture.find("root = Column").unwrap()].to_owned(); source.push_str("root = Column(0, 0"); for _ in 0..1100 { source.push_str(", rect"); } source.push_str(")\npage = Page()\npage.render(root)"); let (mut session, document) = ReplSession::load(&source, "app.main", 74, 1).unwrap(); assert_eq!(document.frame.commands.len(), 1100); assert!(session.refresh_document().unwrap().is_none()); } #[test] fn native_visual_methods_use_managed_values_and_match_real_geometry() { let (mut session, initial) = ReplSession::load( include_str!("../examples/visual-protocols.endor"), "app.main", 61, 1, ) .unwrap(); initial.validate().unwrap(); for (expression, expected) in [ ("results.width", "120"), ("results.height", "60"), ("results.childX", "44"), ("results.childY", "54"), ("results.color", "\"#DDEEFF\""), ] { assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { width: 200.0, height: 60.0, color, .. } if color == "#DDEEFF"))); session .execute("context = rect.mounts().at(0)", "app.main") .unwrap(); assert_eq!( session .execute("rect.hitTest(context, Point(1, 1))", "app.main") .unwrap() .value, "true" ); assert_eq!( session .execute("rect.hitTest(context, Point(-1, 1))", "app.main") .unwrap() .value, "false" ); assert!( session .execute( "rect.measure(context, Constraints(200, 0, 100, 100))", "app.main" ) .is_err() ); assert!( session .execute("root.paint(context, Bounds(0, 0, 10, 10))", "app.main") .is_err() ); session.execute("page.close()", "app.main").unwrap(); assert!( session .execute("rect.paint(context, Bounds(0, 0, 10, 10))", "app.main") .is_err() ); } #[test] fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when_requested() { let (mut session, _) = ReplSession::load( include_str!("../examples/visual-protocols.endor"), "app.main", 62, 1, ) .unwrap(); session .execute("context = rect.mounts().at(0)", "app.main") .unwrap(); let original = session.execute("context", "app.main").unwrap().value; let update = session .execute("rect.invalidate(\"paint\")", "app.main") .unwrap() .document .unwrap(); update.validate().unwrap(); assert_eq!( session .execute("ui.stats().measured", "app.main") .unwrap() .value, "0" ); assert!( session .execute("rect.invalidate(\"layout\")", "app.main") .unwrap() .document .is_some() ); assert_eq!( session .execute("ui.stats().measured", "app.main") .unwrap() .value, "1" ); assert_eq!( session .execute("rect.mounts().at(0)", "app.main") .unwrap() .value, original ); assert!( session .execute("results.width", "app.main") .unwrap() .document .is_none() ); assert!( session .execute("rect.invalidate(\"unknown\")", "app.main") .is_err() ); } #[test] fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { let (mut session, initial) = ReplSession::load( include_str!("../examples/mount-lifecycle.endor"), "app.main", 51, 1, ) .unwrap(); initial.validate().unwrap(); for (expression, expected) in [ ("log.mounted.value", "2"), ("log.parentMounted", "1"), ("log.childMounted", "3"), ] { assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } session .execute( "saved = child.mounts().at(0)\nchild.setColor(\"#237A45\")", "app.main", ) .unwrap(); assert_eq!( session .execute("log.mounted.value", "app.main") .unwrap() .value, "2" ); let closed = session .execute("page.close()", "app.main") .unwrap() .document .unwrap(); assert!(closed.frame.mounts.is_empty()); assert_eq!( session .execute("log.unmounted.value", "app.main") .unwrap() .value, "2" ); assert_eq!( session .execute("log.childUnmounted", "app.main") .unwrap() .value, "5" ); assert_eq!( session .execute("log.parentUnmounted", "app.main") .unwrap() .value, "6" ); assert_eq!( session.execute("saved.mounted", "app.main").unwrap().value, "false" ); assert!(session.execute("saved.bounds()", "app.main").is_err()); session.execute("page.open()", "app.main").unwrap(); assert_eq!( session .execute("log.mounted.value", "app.main") .unwrap() .value, "4" ); assert_eq!( session.execute("saved.mounted", "app.main").unwrap().value, "false" ); } #[test] fn page_switch_replaces_mount_context_even_for_shared_root() { let (mut session, _) = ReplSession::load( include_str!("../examples/mount-lifecycle.endor"), "app.main", 52, 1, ) .unwrap(); session .execute( "saved = child.mounts().at(0)\nsecond = Page()\nsecond.render(root)", "app.main", ) .unwrap(); assert_eq!( session .execute("log.mounted.value", "app.main") .unwrap() .value, "4" ); assert_eq!( session .execute("log.unmounted.value", "app.main") .unwrap() .value, "2" ); assert_eq!( session.execute("saved.mounted", "app.main").unwrap().value, "false" ); assert_eq!( session .execute("child.mounts().at(0).page()", "app.main") .unwrap() .value, session.execute("second", "app.main").unwrap().value ); session.execute("page.activate()", "app.main").unwrap(); assert_eq!( session .execute("log.mounted.value", "app.main") .unwrap() .value, "6" ); } #[test] fn lifecycle_hook_can_edit_container_without_session_lock_and_failure_stops_domain() { let source = r#"class GrowingColumn: Column { function onMount(self: GrowingColumn, mount: Mount): Unit { self.append(TextBlock("from hook")) } } root = GrowingColumn(0, 0) page = Page() page.render(root)"#; let (_, document) = ReplSession::load(source, "app.main", 53, 1).unwrap(); assert!(document.frame.commands.iter().any( |command| matches!(command, RenderCommand::Text { value, .. } if value == "from hook") )); let mut session = ReplSession::new(54).unwrap(); let source = r#"class BrokenText: TextBlock { function onMount(self: BrokenText, mount: Mount): Unit { self.setText(1) } } page = Page() page.render(BrokenText("x"))"#; assert!(session.execute(source, "app.main").is_err()); assert!(session.is_faulted()); } fn event_session(extra: &str) -> (ReplSession, RenderDocument) { let source = format!("{}\n{}", include_str!("../examples/events.endor"), extra); ReplSession::load(&source, "app.main", 41, 1).unwrap() } fn click_document(session: &mut ReplSession, document: &RenderDocument) -> RenderDocument { let globals = session.candidate.globals.unwrap(); let Value::Object(button) = session .candidate .vm .dict_get(globals, &Value::Text("button".into())) .unwrap() .unwrap() else { panic!("Button") }; let mount = document .frame .mounts .iter() .find(|mount| mount.interactive && mount.block_id == button.to_string()) .unwrap(); session .dispatch(&crate::events::Input { session: 41, layout_version: document.frame.layout_version, mount: mount.id, kind: "click".into(), point: None, press_point: None, }) .unwrap() .document .unwrap() } #[test] fn managed_event_route_orders_capture_method_listeners_and_bubble() { let (mut session, initial) = event_session(""); click_document(&mut session, &initial).validate().unwrap(); for (field, expected) in [ ("captureStep", "1"), ("methodStep", "2"), ("targetStep", "3"), ("bubbleStep", "4"), ] { assert_eq!( session .execute(&format!("model.{field}"), "app.main") .unwrap() .value, expected ); } assert_eq!( session.execute("model.phase", "app.main").unwrap().value, "\"bubble\"" ); assert_eq!( session .execute("target.isConnected()", "app.main") .unwrap() .value, "true" ); } #[test] fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_group() { let (mut session, initial) = event_session( "function cancel(event: Event): Unit { target.disconnect() }\nconnection = root.on(\"click\", cancel, \"capture\")", ); click_document(&mut session, &initial); assert_eq!( session .execute("model.targetStep", "app.main") .unwrap() .value, "0" ); assert_eq!( session .execute("model.bubbleStep", "app.main") .unwrap() .value, "3" ); assert_eq!( session .execute("target.isConnected()", "app.main") .unwrap() .value, "false" ); let (mut session, initial) = event_session( "function stop(event: Event): Unit { event.stopPropagation() }\nconnection = button.on(\"click\", stop, \"capture\")", ); click_document(&mut session, &initial); assert_eq!( session .execute("model.targetStep", "app.main") .unwrap() .value, "3" ); assert_eq!( session .execute("model.bubbleStep", "app.main") .unwrap() .value, "0" ); } #[test] fn immediate_stop_skips_target_method_and_code_dispatch_returns_cancelled_event() { let (mut session, initial) = event_session( "function stop(event: Event): Unit { event.stopImmediatePropagation() }\nconnection = button.on(\"click\", stop, \"capture\")", ); click_document(&mut session, &initial); assert_eq!( session .execute("model.count.value", "app.main") .unwrap() .value, "1" ); let (mut session, _) = event_session( "function prevent(event: Event): Unit { event.preventDefault() }\nconnection = button.on(\"custom\", prevent)\nresult = button.dispatch(Event(\"custom\"))", ); assert_eq!( session .execute("result.isDefaultPrevented()", "app.main") .unwrap() .value, "true" ); assert_eq!( session.execute("result.phase", "app.main").unwrap().value, "\"none\"" ); // Reusing a completed event is legal; its previous dispatch has no live guard. session .execute("button.dispatch(result)", "app.main") .unwrap(); } #[test] fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() { let (mut session, _) = event_session(""); session.execute("function fail(event: Event): Unit { page.render(1) }\nfailing = button.on(\"failure\", fail)\nfailedEvent = Event(\"failure\")", "app.main").unwrap(); assert!( session .execute("button.dispatch(failedEvent)", "app.main") .is_err() ); session .execute( "failing.disconnect()\nbutton.dispatch(failedEvent)", "app.main", ) .unwrap(); session .execute( "orphan = Button(\"unmounted\")\ntoken = orphan.on(\"click\", model.target)", "app.main", ) .unwrap(); let globals = session.candidate.globals.unwrap(); let Value::Object(orphan) = session .candidate .vm .dict_get(globals, &Value::Text("orphan".into())) .unwrap() .unwrap() else { panic!("Block expected") }; let weak = session.candidate.vm.downgrade(orphan).unwrap(); session .candidate .vm .dict_delete(globals, &Value::Text("orphan".into())) .unwrap(); session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) .unwrap(); assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); assert_eq!( session .execute("token.isConnected()", "app.main") .unwrap() .value, "false" ); } #[test] fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { let (mut session, initial) = ReplSession::load( include_str!("../examples/native-subclasses.endor"), "app.main", 31, 1, ) .unwrap(); initial.validate().unwrap(); assert_eq!( session .execute("isInstance(label, TextBlock)", "app.main") .unwrap() .value, "true" ); assert_eq!( session .execute("isInstance(page, Page)", "app.main") .unwrap() .value, "true" ); session.execute("alias = label", "app.main").unwrap(); let globals = session.candidate.globals.unwrap(); let Value::Object(label) = session .candidate .vm .dict_get(globals, &Value::Text("label".into())) .unwrap() .unwrap() else { panic!("Block expected") }; let path = session .candidate .vm .object_store() .attribute_path(label) .unwrap(); let previous = session .candidate .vm .downgrade(*path.last().unwrap()) .unwrap(); let updated = session .execute( r##"label = CustomStatus("replacement", 2, "#242422", "#237A45", "new field")"##, "app.main", ) .unwrap() .document .unwrap(); assert!(updated.frame.commands.iter().any( |command| matches!(command, RenderCommand::Text { value, .. } if value == "new field") )); session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) .unwrap(); assert!(session.candidate.vm.upgrade(previous).unwrap().is_none()); assert_eq!( session.execute("alias.message", "app.main").unwrap().value, "\"new field\"" ); let updated = session .execute("button.click()", "app.main") .unwrap() .document .unwrap(); assert_eq!(updated.frame.commands.iter().filter(|command| matches!(command, RenderCommand::Text { value, .. } if value == "new field")).count(), 3); let updated = session .execute("alias.message = \"changed field\"", "app.main") .unwrap() .document .unwrap(); assert!(updated.frame.commands.iter().any(|command| matches!(command, RenderCommand::Text { value, .. } if value == "changed field"))); } #[test] fn loaded_program_console_and_native_events_share_unique_source_ids() { let (mut session, initial) = ReplSession::load( include_str!("../examples/managed-types.endor"), "app.main", 1, 1, ) .unwrap(); initial.validate().unwrap(); assert_eq!( session .execute("isInstance(page, Block)", "app.main") .unwrap() .value, "true" ); let mount = initial .frame .mounts .iter() .find(|mount| mount.interactive) .unwrap(); let event = crate::events::Input { session: 1, layout_version: initial.frame.layout_version, mount: mount.id, kind: "click".into(), point: None, press_point: None, }; let updated = session.dispatch(&event).unwrap().document.unwrap(); updated.validate().unwrap(); assert_eq!( updated .frame .commands .iter() .filter(|command| matches!(command, RenderCommand::Text { value, .. } if value == "10" )) .count(), 2 ); assert!(!session.is_faulted()); } #[test] fn native_method_callbacks_update_two_explicit_bindings() { let mut session = ReplSession::new(21).unwrap(); session.execute("counter = ui.state(9)\nfirst = ui.textOf(counter, \"value\")\nsecond = ui.textOf(counter, \"value\")\nbutton = ui.button(\"increment\")\nfunction increment(): Unit { counter.increment() }\nbutton.onClick(increment)\npage = ui.page()\npage.render(ui.row(0, 8, first, second, button))", "app.main").unwrap(); let updated = session .execute("button.click()", "app.main") .unwrap() .document .unwrap(); let texts: Vec<_> = updated .frame .commands .iter() .filter_map(|command| match command { RenderCommand::Text { value, .. } => Some(value.as_str()), _ => None, }) .collect(); assert_eq!(&texts[..2], &["10", "10"]); } #[test] fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content() { let mut session = ReplSession::new(22).unwrap(); session .execute( "card = ui.rect(10, 20)\npage = ui.page()\npage.render(card)", "app.main", ) .unwrap(); let globals = session.candidate.globals.unwrap(); let Value::Object(page) = session .candidate .vm .dict_get(globals, &Value::Text("page".into())) .unwrap() .unwrap() else { panic!("expected Page") }; let weak = session.candidate.vm.downgrade(page).unwrap(); session.execute("page.close()", "app.main").unwrap(); session .execute("card.setColor(\"#FFEEDD\")", "app.main") .unwrap(); assert!( session .execute("page.open()", "app.main") .unwrap() .document .is_some() ); session.execute("page.close()", "app.main").unwrap(); session .candidate .vm .dict_delete(globals, &Value::Text("page".into())) .unwrap(); session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) .unwrap(); assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); assert!( session .candidate .vm .dict_get(globals, &Value::Text("card".into())) .unwrap() .is_some() ); } #[test] fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { let mut session = ReplSession::new(10).unwrap(); session.execute("card = ui.rect(10, 20)\nother = card\ncontainer = ui.column(0, 0, other)\npage = ui.page()\npage.render(container)", "app.main").unwrap(); let identity = session.execute("other", "app.main").unwrap().value; let updated = session .execute("card = ui.rect(640, 200, 32)", "app.main") .unwrap(); assert_eq!(updated.value, identity); assert_eq!( session.execute("other", "app.main").unwrap().value, identity ); let frame = updated.document.unwrap().frame; assert!(matches!( frame.commands[0], RenderCommand::RoundedRect { width: 640.0, height: 200.0, .. } )); assert!( session .execute("card = ui.column(0, 0, other)", "app.main") .is_err() ); assert!( session .execute("other", "app.main") .unwrap() .document .is_none() ); let unmounted = session .execute("page.unmount()", "app.main") .unwrap() .document .unwrap(); assert!(unmounted.frame.commands.is_empty()); assert!( session .execute("card = ui.rect(80, 50)", "app.main") .unwrap() .document .is_none() ); let remounted = session .execute("page.render(other)", "app.main") .unwrap() .document .unwrap(); assert!(matches!( remounted.frame.commands[0], RenderCommand::RoundedRect { width: 80.0, height: 50.0, .. } )); } #[test] fn live_session_retains_objects_and_updates_only_after_mounting() { let mut session = ReplSession::new(7).unwrap(); let created = session .execute( r##"title = ui.text("你好,Tessera", 2, "#243047")"##, "app.main", ) .unwrap(); assert!(created.document.is_none()); assert_eq!( session.execute("title", "app.main").unwrap().value, created.value ); assert_eq!( session.execute("_", "app.main").unwrap().value, created.value ); let rendered = session .execute("page = ui.page()\npage.render(title)", "app.main") .unwrap() .document .unwrap(); assert!( matches!(&rendered.frame.commands[0], RenderCommand::Text { value, .. } if value == "你好,Tessera") ); assert_eq!(rendered.root.as_ref().unwrap().kind, "repl-session"); assert!( session .execute(r#"title = ui.text("changed")"#, "app.main") .unwrap() .document .is_some() ); let updated = session .execute("page.render(title)", "app.main") .unwrap() .document .unwrap(); assert!( matches!(&updated.frame.commands[0], RenderCommand::Text { value, .. } if value == "changed") ); assert!(updated.source.contains("// Console input 6")); assert!( session .execute("title", "app.main") .unwrap() .document .is_none() ); } #[test] fn live_session_compiles_before_execution_and_discards_failed_render() { let mut session = ReplSession::new(8).unwrap(); session .execute("value = 1\npage = ui.page()", "app.main") .unwrap(); assert!(session.execute("value = 2\nmissing", "app.main").is_err()); assert_eq!(session.execute("value", "app.main").unwrap().value, "1"); assert!( session .execute( "value = 3\npage.render(ui.text(\"discarded\"))\nui.rect(-1, 1, 0)", "app.main" ) .is_err() ); let retained = session.execute("value", "app.main").unwrap(); assert_eq!(retained.value, "3"); assert!(retained.document.is_none()); session.execute("function local(value: Number): Number { result = value result = 42 return result }", "app.main").unwrap(); assert_eq!( session.execute("local(40)", "app.main").unwrap().value, "42" ); } #[test] fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { let document = compile(PAGE, "app.main", 9).unwrap(); assert_eq!(document.source, PAGE); let tree = document.root.as_ref().unwrap(); assert_eq!(tree.kind, "script"); assert_eq!(tree.children.len(), 4); assert_eq!(tree.children[0].name, "heading"); assert_eq!(tree.children[1].name, "rectangle"); assert_eq!(tree.children[2].name, "myui"); assert_eq!(tree.children[2].start_line, 3); assert_eq!(tree.children[3].start_line, 4); assert!(tree.id.contains("app.main@9")); assert_eq!(document.frame.commands.len(), 2); assert!( matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor") ); assert!( matches!(&document.frame.commands[1], RenderCommand::RoundedRect { x: 24.0, y: 76.0, width: 240.0, height: 80.0, radius: 12.0, color } if color == "#DCE8FF") ); assert_eq!(document.display_list(2.0).unwrap().commands().len(), 3); let wrapped = compile(include_str!("../examples/main.endor"), "app.main", 10).unwrap(); assert_eq!( serde_json::to_value(&document.frame.commands).unwrap(), serde_json::to_value(&wrapped.frame.commands).unwrap() ); // Separate VM instances render the same commands with independent // managed object identities and presentation contexts. assert_ne!( document.frame.mounts[0].block_id, wrapped.frame.mounts[0].block_id ); } #[test] fn script_helpers_multiline_bindings_calls_and_member_continuations_use_aster() { let source = r##"let color = "#242422" // comment with ( and ) function greeting(): Text { return "Hello Endor" } function heading(value: Text): View { return ui.text(value, 2, color) } let title = heading(greeting()) let rectangle = ui.rect( 240, 80, 12, "#DCE8FF" ) let page = ui .page() page .render(ui.column(24, 12, title, rectangle)) "##; let document = compile(source, "app.main", 2).unwrap(); assert_eq!(document.frame.commands.len(), 2); let tree = document.root.unwrap(); assert_eq!(tree.children[1].name, "greeting"); assert_eq!(tree.children[1].start_line, 2); assert_eq!(tree.children[2].name, "heading"); assert_eq!(tree.children[2].start_line, 5); assert_eq!(tree.children[3].start_line, 8); assert_eq!(tree.children[3].end_line, 9); assert_eq!(tree.children.last().unwrap().end_line, 17); } #[test] fn script_diagnostics_refer_to_original_unicode_source_and_byte_ranges() { for ending in ["\n", "\r\n", "\r"] { let source = format!( "let title = ui.text(\"中文\"){ending}// comment{ending}let broken = missing{ending}ui.page().render(title)" ); let error = compile(&source, "app.main", 27).unwrap_err(); let diagnostic = error.downcast_ref::().unwrap(); assert_eq!(diagnostic.location.line, 3); assert_eq!(diagnostic.location.column, 14); assert_eq!(diagnostic.diagnostic.span.slice(&source), Some("missing")); assert!(diagnostic.origin.to_string().contains("submission 27")); } let error = compile("let page = ui.page()\npage.render(,)", "app.main", 1).unwrap_err(); let diagnostic = error.downcast_ref::().unwrap(); assert_eq!(diagnostic.location.line, 2); } #[test] fn pages_bind_render_methods_and_last_render_selects_the_snapshot() { let source = r#"let first = ui.page() let second = ui.page() first.render(ui.text("first")) second.render(ui.text("second")) let paint = first.render paint(ui.text("last"))"#; let document = compile(source, "app.main", 1).unwrap(); assert_eq!(document.frame.commands.len(), 1); assert!( matches!(&document.frame.commands[0], RenderCommand::Text { value, .. } if value == "last") ); } #[test] fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() { let mut state = NamespaceState { submission: 1, ..NamespaceState::default() }; state .commit(1, compile(PAGE, "app.main", 1).unwrap()) .unwrap(); for source in [ "ui.page()", "let title = ui.text(\"no render\")", "let page = ui.page(1)\npage.render(ui.text(\"x\"))", "let page = ui.page()\npage.render()", "let page = ui.page()\npage.render(\"not a node\")", "let page = ui.page()\npage.render(ui.text(\"partial\"))\nui.missing()", ] { state.submission = state.next_submission().unwrap(); assert!( compile(source, "app.main", state.submission).is_err(), "{source}" ); assert_eq!(state.document.source, PAGE); assert_eq!(state.revision, 1); } } #[test] fn page_creation_has_a_separate_live_object_budget() { let mut source = String::new(); for index in 0..=ui_bridge::MAX_PAGES { source.push_str(&format!("let page{index} = ui.page()\n")); } source.push_str("page0.render(ui.text(\"x\"))"); let error = compile(&source, "app.main", 1).unwrap_err(); assert!(error.to_string().contains("64 page budget"), "{error:#}"); } #[test] fn script_execution_is_limited_even_without_creating_ui_nodes() { let mut source = "function text0(): Text { return \"Hello Endor\" }\n".to_owned(); for index in 1..=16 { source.push_str(&format!( "function text{index}(): Text {{ let unused = text{}() return text{}() }}\n", index - 1, index - 1 )); } source.push_str("let title = ui.text(text16())\nui.page().render(title)"); let error = compile(&source, "app.main", 1).unwrap_err(); assert!( error .to_string() .contains("execution limit reached: instructions"), "{error:#}" ); } #[test] fn literal_string_return_from_aster_can_be_displayed_by_the_host() { let source = "function main(): Text { return \"Hello Endor\" }\nlet title = ui.text(main())\nui.page().render(title)"; let document = compile(source, "app.main", 1).unwrap(); assert!( matches!(&document.frame.commands[0], RenderCommand::Text { value, .. } if value == "Hello Endor") ); } #[test] fn developer_selection_resets_to_the_new_source_snapshot() { let first = compile(PAGE, "app.main", 1).unwrap(); let second = compile(PAGE, "app.main", 2).unwrap(); let mut tools = crate::developer_tools::DeveloperToolsState::default(); tools.open = true; tools.selected_node_id = Some(first.root.as_ref().unwrap().children[0].id.clone()); tools.document_updated(&second); assert_eq!( tools.selected_node_id.as_deref(), Some(second.root.as_ref().unwrap().id.as_str()) ); } #[test] fn executes_aster_functions_and_renders_after_vm_cleanup() { let document = compile(EXAMPLE, "app.main", 1).unwrap(); assert_eq!(document.source, EXAMPLE); let tree = document.root.as_ref().unwrap(); assert_eq!(tree.kind, "module"); assert_eq!(tree.name, "app.main"); assert_eq!(tree.children[0].name, "heading"); assert_eq!(tree.children[1].name, "main"); assert_eq!(tree.children[1].start_line, 5); assert_eq!(document.frame.commands.len(), 4); assert!( matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor") ); assert!(matches!( &document.frame.commands[1], RenderCommand::RoundedRect { x: 24.0, y: 76.0, width: 240.0, height: 80.0, .. } )); let first_width = crate::text::measure("Aster VM", crate::text::TextStyle::ui(1)).width; assert!(matches!( &document.frame.commands[3], RenderCommand::Text { x, y: 168.0, .. } if (*x - (24.0 + first_width + 12.0 + 8.0 + 12.0)).abs() < 0.01 )); // Candidate::close already proved zero remaining VM objects. Both // redraws must still work from native data at different display scales. assert_eq!(document.display_list(1.0).unwrap().commands().len(), 5); assert_eq!(document.display_list(2.0).unwrap().commands().len(), 5); } #[test] fn borrowed_ui_handles_survive_variables_helpers_and_reused_children() { let source = r#"function wrap(child: View): View { return ui.row(4, 8, child, child) } function main(): View { let child = ui.text("shared") return wrap(child) }"#; let document = compile(source, "arbitrary.namespace", 3).unwrap(); assert_eq!(document.frame.commands.len(), 2); assert!(matches!( &document.frame.commands[0], RenderCommand::Text { x: 4.0, y: 4.0, .. } )); let width = crate::text::measure("shared", crate::text::TextStyle::ui(1)).width; assert!(matches!( &document.frame.commands[1], RenderCommand::Text { x, y: 4.0, .. } if (*x - (4.0 + width + 8.0)).abs() < 0.01 )); } #[test] fn architecture_ranges_follow_crlf_and_cr_source_snapshots() { for ending in ["\r\n", "\r"] { let source = EXAMPLE.replace('\n', ending); let document = compile(&source, "app.main", 1).unwrap(); assert_eq!(document.source, source); assert_eq!(document.root.unwrap().children[1].start_line, 5); } } #[test] fn rejects_invalid_entry_objects_arguments_and_old_syntax() { for source in [ "namespace app.main export scene Main { render { Text(\"old\") } }", "function helper(): View { return ui.text(\"missing main\") }", "function main(value: View): View { return value }", "function main(): View { return \"wrong root\" }", "function main(): View { return main }", "function main(): View { return ui.text(1) }", "function main(): View { return ui.text(\"x\", 1.0) }", "function main(): View { return ui.text(\"x\", 9) }", "function main(): View { return ui.rect(-1, 1) }", "function main(): View { return ui.rect(1000001, 1) }", "function main(): View { return ui.rect(1, 1, 0, \"#中中\") }", "function main(): View { return ui.column(0, 0, ui.text) }", "function main(): View { return unknown(1) }", "function main(): View { return ui.missing() }", ] { assert!( compile(source, "app.main", 1).is_err(), "accepted: {source}" ); } } #[test] fn source_diagnostics_keep_unicode_locations_and_submission_origin() { let error = compile( "function main(): View {\n let title = ui.text(\"中文\")\n return missing\n}", "app.main", 17, ) .unwrap_err(); let error = error.downcast_ref::().unwrap(); assert_eq!(error.location.line, 3); assert!(error.origin.to_string().contains("submission 17")); } #[test] fn recursive_program_stops_at_the_vm_frame_limit() { let error = compile("function main(): View { return main() }", "app.main", 1).unwrap_err(); assert!( error .to_string() .contains("execution limit reached: frames") ); } fn nested_functions(levels: usize, shared: bool) -> String { let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); for index in 1..=levels { let previous = index - 1; if shared { source.push_str(&format!("function node{index}(): View {{ let child = node{previous}() return ui.column(0, 0, child, child) }}\n")); } else { source.push_str(&format!("function node{index}(): View {{ return ui.column(0, 0, node{previous}(), node{previous}()) }}\n")); } } source.push_str(&format!( "function main(): View {{ return node{levels}() }}" )); source } #[test] fn construction_and_expanded_layout_have_separate_budgets() { let error = compile(&nested_functions(12, false), "app.main", 1).unwrap_err(); assert!(error.to_string().contains("4096 node budget"), "{error:#}"); let error = compile(&nested_functions(14, true), "app.main", 2).unwrap_err(); assert!( error.to_string().contains("expanded Block graph"), "{error:#}" ); } #[test] fn managed_ui_depth_is_not_truncated_at_64_levels() { let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); for index in 1..=64 { source.push_str(&format!( "function node{index}(): View {{ return ui.column(0, 0, node{}()) }}\n", index - 1 )); } source.push_str("function main(): View { return node64() }"); let document = compile(&source, "app.main", 1).unwrap(); assert_eq!(document.frame.commands.len(), 1); } #[test] fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() { let mut state = NamespaceState::default(); state.submission = state.next_submission().unwrap(); assert_eq!( state .commit(state.submission, compile(EXAMPLE, "app.main", 1).unwrap()) .unwrap(), Some(1) ); state.submission = state.next_submission().unwrap(); assert!(compile("invalid", "app.main", state.submission).is_err()); assert_eq!(state.revision, 1); assert_eq!(state.document.source, EXAMPLE); let stale = state.submission; let candidate = compile(EXAMPLE, "app.main", stale).unwrap(); state.submission = state.next_submission().unwrap(); assert!(state.commit(stale, candidate.clone()).unwrap().is_none()); let deleted = state.clear().unwrap(); assert!(state.commit(stale, candidate).unwrap().is_none()); assert_eq!(state.revision, deleted); assert!(state.document.source.is_empty()); state.submission = state.next_submission().unwrap(); let candidate = compile(EXAMPLE, "app.main", state.submission).unwrap(); assert_eq!( state.commit(state.submission, candidate).unwrap(), Some(deleted + 1) ); } }