use std::{ io::Read, ops::{Deref, DerefMut}, panic::{AssertUnwindSafe, catch_unwind}, sync::Mutex, sync::{ Arc, RwLock, mpsc::{SyncSender, TrySendError, sync_channel}, }, thread, }; use anyhow::{Context as _, Result}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; use winit::event_loop::EventLoopProxy; use crate::{ activity::{Activity, ActivityLog}, app::AppEvent, document::NamespaceState, endor_host, }; pub const API_ADDRESS: &str = "127.0.0.1:4737"; const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64; type Notify = Arc; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RequestKind { Event, Code, Expression, Repl, ResetRepl, } struct BuildRequest { request: ObservedRequest, kind: RequestKind, source: String, namespace: String, submission: u64, } pub fn start( namespace: Arc>, proxy: EventLoopProxy, ) -> Result { let server = Server::http(API_ADDRESS) .map_err(|error| anyhow::anyhow!(error)) .with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?; let notify: Notify = Arc::new(move || { let _ = proxy.send_event(AppEvent::DocumentUpdated); }); let activity = Arc::new(Mutex::new(ActivityLog::default())); start_server_observed( Arc::new(server), namespace, notify.clone(), activity.clone(), notify, )?; Ok(activity) } #[cfg(test)] fn start_server( server: Arc, namespace: Arc>, notify: Notify, ) -> Result> { start_server_observed( server, namespace, notify, Arc::new(Mutex::new(ActivityLog::default())), Arc::new(|| {}), ) } fn start_server_observed( server: Arc, namespace: Arc>, notify: Notify, activity: Activity, observer_notify: Notify, ) -> Result> { let (sender, receiver) = sync_channel::(8); let worker_namespace = namespace.clone(); let worker_notify = notify.clone(); thread::Builder::new() .name("tessera-endor-worker".into()) .spawn(move || { let mut worker = Worker::default(); loop { match receiver.recv_timeout(std::time::Duration::from_millis(100)) { Ok(job) => { let response = worker.run(&job, &worker_namespace, &worker_notify); let _ = job.request.respond(response); } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { if worker.repl.as_ref().is_some_and(|(epoch, _)| { worker_namespace .read() .is_ok_and(|current| current.submission != *epoch) }) { worker.repl = None; } } } } }) .context("start Aster execution worker")?; thread::Builder::new() .name("tessera-agent-api".to_owned()) .spawn(move || { for request in server.incoming_requests() { handle( request, &namespace, ¬ify, &sender, &activity, &observer_notify, ); } }) .context("start Tessera agent API thread") } // Capture only allowlisted headers: credentials and cookies never enter the inspector. struct ObservedRequest { inner: Request, activity: Activity, id: u64, notify: Notify, } impl ObservedRequest { fn new(inner: Request, activity: Activity, notify: Notify) -> Self { let headers = inner .headers() .iter() .filter(|h| h.field.equiv("Content-Type") || h.field.equiv("Content-Length")) .map(ToString::to_string) .collect::>() .join("\n"); let origin = if inner .headers() .iter() .any(|h| h.field.equiv("X-Tessera-Origin") && h.value.as_str() == "console") { "Console" } else { "HTTP" } .to_owned(); let id = activity.lock().unwrap().begin( inner.method().to_string(), inner.url().to_owned(), origin, headers, ); notify(); Self { inner, activity, id, notify, } } fn record_body(&self, body: &str) { self.activity.lock().unwrap().body(self.id, body); (self.notify)(); } fn respond(self, response: Response>>) -> std::io::Result<()> { let status = response.status_code(); let headers = response.headers().to_vec(); let length = response.data_length(); let reader = response.into_reader(); self.activity.lock().unwrap().finish( self.id, status.0, &String::from_utf8_lossy(reader.get_ref()), headers .iter() .map(ToString::to_string) .collect::>() .join("\n"), ); (self.notify)(); self.inner .respond(Response::new(status, headers, reader, length, None)) } } impl Deref for ObservedRequest { type Target = Request; fn deref(&self) -> &Request { &self.inner } } impl DerefMut for ObservedRequest { fn deref_mut(&mut self) -> &mut Request { &mut self.inner } } fn handle( request: Request, namespace: &Arc>, notify: &Notify, sender: &SyncSender, activity: &Activity, observer_notify: &Notify, ) { let request = ObservedRequest::new(request, activity.clone(), observer_notify.clone()); let method = request.method().clone(); let path = request .url() .split('?') .next() .unwrap_or(request.url()) .to_owned(); let evaluation = namespace_from_route(&path, "/evaluate"); let repl = namespace_from_route(&path, "/repl"); let event = namespace_from_route(&path, "/events"); let route = if method == Method::Put { namespace_from_path(&path).map(|name| (name, RequestKind::Code)) } else if method == Method::Post { event .map(|name| (name, RequestKind::Event)) .or_else(|| repl.map(|name| (name, RequestKind::Repl))) .or(evaluation.map(|name| (name, RequestKind::Expression))) } else if method == Method::Delete { repl.map(|name| (name, RequestKind::ResetRepl)) } else { None }; if let Some((addressed, kind)) = route { submit(request, addressed, namespace, sender, kind); return; } let response = if method == Method::Get && path == "/agent" { match namespace.read() { Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)), Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"), } } else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) { code_request(method, &addressed_namespace, namespace, notify) } else if method == Method::Options { text(StatusCode(204), "", "text/plain") } else { error_response(StatusCode(404), "route not found; start with GET /agent") }; let _ = request.respond(response); } fn code_request( method: Method, addressed_namespace: &str, namespace: &Arc>, notify: &Notify, ) -> Response>> { let Ok(current) = namespace.read() else { return error_response(StatusCode(500), "namespace lock is poisoned"); }; if addressed_namespace != current.name { return error_response( StatusCode(404), format!( "namespace `{addressed_namespace}` is not active; current namespace is `{}`", current.name ), ); } if method == Method::Get { let mut response = text( StatusCode(200), ¤t.document.source, "text/plain; charset=utf-8", ); add_header( &mut response, "X-Tessera-Revision", ¤t.revision.to_string(), ); add_header( &mut response, "X-Tessera-Session", ¤t.document.frame.session.to_string(), ); add_header( &mut response, "X-Tessera-Layout-Version", ¤t.document.frame.layout_version.to_string(), ); add_header( &mut response, "X-Tessera-Source-Kind", if current .document .root .as_ref() .is_some_and(|root| root.kind == "repl-session") { "repl-transcript" } else { "source-snapshot" }, ); return response; } drop(current); match method { Method::Delete => match namespace.write() { Ok(mut current) => { let revision = match current.clear() { Ok(revision) => revision, Err(error) => return error_response(StatusCode(500), error.to_string()), }; drop(current); notify(); json( StatusCode(200), serde_json::json!({ "ok": true, "namespace": addressed_namespace, "revision": revision }), ) } Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"), }, Method::Options => text(StatusCode(204), "", "text/plain"), _ => error_response(StatusCode(405), "code supports GET, PUT, and DELETE"), } } fn submit( mut request: ObservedRequest, addressed: &str, namespace: &Arc>, sender: &SyncSender, kind: RequestKind, ) { let source = match read_code(&mut request) { Ok(source) => source, Err(error) => { let _ = request.respond(error_response(StatusCode(400), error.to_string())); return; } }; request.record_body(&source); let Ok(mut current) = namespace.write() else { let _ = request.respond(error_response( StatusCode(500), "namespace lock is poisoned", )); return; }; if current.name != addressed { let _ = request.respond(error_response(StatusCode(404), "namespace is not active")); return; } let submission = match if kind == RequestKind::Code { current.next_submission() } else { Ok(current.submission) } { Ok(id) => id, Err(error) => { let _ = request.respond(error_response(StatusCode(500), error.to_string())); return; } }; let job = BuildRequest { request, kind, source, namespace: addressed.into(), submission, }; match sender.try_send(job) { // A rejected queue entry never invalidates accepted work. The worker // uses this same lock when committing, so publication is atomic. Ok(()) => { if kind == RequestKind::Code { current.submission = submission; } } Err(TrySendError::Full(job)) => { let _ = job .request .respond(error_response(StatusCode(503), "build queue is full")); } Err(TrySendError::Disconnected(job)) => { let _ = job.request.respond(error_response( StatusCode(503), "execution worker is unavailable", )); } } } #[derive(Default)] struct Worker { repl: Option<(u64, endor_host::ReplSession)>, next_session: u64, } impl Worker { fn run( &mut self, job: &BuildRequest, namespace: &Arc>, notify: &Notify, ) -> Response>> { if job.kind == RequestKind::Code { return self.replace_program(job, namespace, notify); } if job.kind == RequestKind::Expression { return build(job, namespace, notify); } let epoch = match namespace.read() { Ok(current) => current.submission, Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"), }; if epoch != job.submission { return superseded(job.submission); } if job.kind == RequestKind::ResetRepl { self.repl = None; if let Ok(mut current) = namespace.write() { current.document.frame.session = 0; current.document.frame.focus = None; } notify(); return json( StatusCode(200), serde_json::json!({"ok":true,"execution":"repl-reset","persistent":true,"session_reset":true,"message":"Console memory cleared; displayed page preserved"}), ); } let event = if job.kind == RequestKind::Event { let input: crate::events::Input = match serde_json::from_str(&job.source) { Ok(input) => input, Err(error) => return error_response(StatusCode(400), error.to_string()), }; if !matches!(input.kind.as_str(), "click" | "focus") { return error_response(StatusCode(400), "unsupported event kind"); } if let Err(message) = input.validate() { return error_response(StatusCode(400), message); } let current = match namespace.read() { Ok(current) => current, Err(_) => return error_response(StatusCode(500), "namespace lock poisoned"), }; let frame = ¤t.document.frame; if frame.session != input.session || frame.layout_version != input.layout_version || frame.fault.is_some() || !frame .mounts .iter() .any(|mount| mount.id == input.mount && mount.interactive) || self.repl.as_ref().is_none_or(|(context, runtime)| { *context != epoch || runtime.id != input.session }) { return error_response( StatusCode(409), "event targets an obsolete or inactive frame", ); } Some(input) } else { None }; if self .repl .as_ref() .is_none_or(|(current, _)| *current != epoch) { self.next_session += 1; match endor_host::ReplSession::new(self.next_session) { Ok(session) => self.repl = Some((epoch, session)), Err(error) => return error_response(StatusCode(500), error.to_string()), } } let session = &mut self.repl.as_mut().expect("initialized REPL").1; let id = session.id; let result = catch_unwind(AssertUnwindSafe(|| { if let Some(event) = &event { session.dispatch(event) } else { session.execute(&job.source, &job.namespace) } })); let faulted = session.is_faulted(); let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock is poisoned"); }; if current.submission != epoch { drop(current); self.repl = None; return superseded(job.submission); } match result { Ok(Ok(result)) => { let rendered = result.document.is_some(); if let Some(document) = result.document { match current.commit(epoch, document) { Ok(Some(_)) => {} Ok(None) => return superseded(job.submission), Err(error) => return error_response(StatusCode(500), error.to_string()), } } let revision = current.revision; drop(current); if rendered { notify(); } json( StatusCode(200), serde_json::json!({"ok":true,"execution":if event.is_some() { "event" } else { "repl" },"persistent":true,"session":id,"input":result.input,"value":result.value,"bindings":result.bindings,"rendered":rendered,"revision":revision}), ) } Ok(Err(error)) => { if event.is_some() || faulted { let mut document = current.document.clone(); document.frame.fault = Some(format!("{error:#}").chars().take(1024).collect()); let _ = current.commit(epoch, document); drop(current); notify(); } compile_error(error) } Err(_) => { let mut document = current.document.clone(); document.frame.fault = Some("Runtime discarded after an internal execution failure".into()); let _ = current.commit(epoch, document); drop(current); self.repl = None; notify(); json( StatusCode(500), serde_json::json!({"ok":false,"session_reset":true,"error":"Console execution failed internally; session discarded, displayed page preserved"}), ) } } } fn replace_program( &mut self, job: &BuildRequest, namespace: &Arc>, notify: &Notify, ) -> Response>> { if namespace .read() .is_ok_and(|current| current.submission != job.submission) { return superseded(job.submission); } self.next_session = match self.next_session.checked_add(1) { Some(id) => id, None => return error_response(StatusCode(500), "session identities exhausted"), }; let id = self.next_session; let result = catch_unwind(AssertUnwindSafe(|| { endor_host::ReplSession::load(&job.source, &job.namespace, job.submission, id) })); let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock poisoned"); }; if current.submission != job.submission { return superseded(job.submission); } match result { Ok(Ok((runtime, document))) => match current.commit(job.submission, document) { Ok(Some(revision)) => { self.repl = Some((job.submission, runtime)); drop(current); notify(); json( StatusCode(200), serde_json::json!({"ok":true,"namespace":job.namespace,"submission":job.submission,"revision":revision,"diagnostics":[],"session":id,"persistent":true}), ) } Ok(None) => superseded(job.submission), Err(error) => error_response(StatusCode(500), error.to_string()), }, failed => { // The candidate never mutated the displayed program's heap. // Restore that program's event epoch when a replacement fails. if let Some((epoch, runtime)) = &mut self.repl && current.document.frame.session == runtime.id { *epoch = job.submission; } drop(current); match failed { Ok(Err(error)) => compile_error(error), _ => error_response( StatusCode(500), "candidate failed internally; previous runtime preserved", ), } } } } } fn build( job: &BuildRequest, namespace: &Arc>, notify: &Notify, ) -> Response>> { if job.kind == RequestKind::Expression { return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) { Ok(Ok(value)) => json( StatusCode(200), serde_json::json!({"ok":true,"value":value,"execution":"isolated-expression","persistent":false}), ), Ok(Err(error)) => compile_error(error), Err(_) => error_response(StatusCode(500), "expression execution failed internally"), }; } match namespace.read() { Ok(current) if current.submission != job.submission => return superseded(job.submission), Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"), _ => {} } // Only an independent native snapshot crosses into the published state. // A fresh VM is discarded on success, error and unwind alike. let result = catch_unwind(AssertUnwindSafe(|| { endor_host::compile(&job.source, &job.namespace, job.submission) })); let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock is poisoned"); }; if current.submission != job.submission { return superseded(job.submission); } match result { Ok(Ok(document)) => match current.commit(job.submission, document) { Ok(Some(revision)) => { drop(current); notify(); json( StatusCode(200), serde_json::json!({ "ok": true, "namespace": job.namespace, "submission": job.submission, "revision": revision, "diagnostics": [] }), ) } Ok(None) => superseded(job.submission), Err(error) => error_response(StatusCode(500), error.to_string()), }, Ok(Err(error)) => compile_error(error), Err(_) => error_response( StatusCode(500), "candidate execution failed internally; previous document preserved", ), } } fn superseded(submission: u64) -> Response>> { json( StatusCode(409), serde_json::json!({ "ok": false, "submission": submission, "error": "submission superseded by a newer PUT or DELETE" }), ) } fn namespace_from_route<'a>(path: &'a str, suffix: &str) -> Option<&'a str> { path.strip_prefix("/v1/namespaces/")? .strip_suffix(suffix) .filter(|namespace| !namespace.is_empty() && !namespace.contains('/')) } fn namespace_from_path(path: &str) -> Option<&str> { namespace_from_route(path, "/code") } fn read_code(request: &mut Request) -> Result { if request .body_length() .is_some_and(|length| length as u64 > MAX_CODE_SIZE) { anyhow::bail!("code exceeds the 1 MiB limit"); } let mut code = String::new(); request .as_reader() .take(MAX_CODE_SIZE + 1) .read_to_string(&mut code) .context("read code")?; if code.len() as u64 > MAX_CODE_SIZE { anyhow::bail!("code exceeds the 1 MiB limit"); } Ok(code) } fn agent_guide(namespace: &NamespaceState) -> String { format!( r##"# Tessera Agent API Current namespace: `{name}` Current revision: `{revision}` Submit an ordinary Endor script. Tessera embeds Aster's frontend, compiler and VM. The namespace comes from the request path. Top-level bindings share the candidate VM's globals; `ui.page().render(root)` selects the visible UI. No main function is required. Typed helper functions and the older main-returning-View form also work. ## Code CRUD - Read: `GET http://{address}/v1/namespaces/{name}/code` - Create or update: `PUT http://{address}/v1/namespaces/{name}/code` - Delete: `DELETE http://{address}/v1/namespaces/{name}/code` Send PUT bodies as `text/plain; charset=utf-8`. ```endor {example}``` ## Live Console session `POST http://{address}/v1/namespaces/{name}/repl` accepts statements, function definitions and expressions as text/plain. Consecutive requests use one real VM and globals dictionary on the execution worker. `title = ui.text("Hello")` creates an object without displaying it. A later `page.render(root)` mounts its page. UI references share replacement state: after mounting, assigning a new UI value to a reference updates its aliases and mounted positions. `page.unmount()` releases the mount. `let title = ...` is also supported. `_` retains the actual last value. No input history is replayed. Responses include session/input IDs, value, binding count, rendered flag and revision. Before the first mount the page/revision stay unchanged; afterwards successful inputs publish changed mounted references automatically. `DELETE http://{address}/v1/namespaces/{name}/repl` resets Console objects and bindings, leaving the displayed native snapshot intact. Parsing/compilation happen before execution. On runtime error earlier statements in that input may remain live, but its pending render is discarded; previous displayed page is preserved. Session limits: 256 executed inputs, 1 MiB transcript, 10000 cumulative AST nodes, 4096 retained visual objects and 64 retained pages. Aster collects unreachable objects/cycles between inputs; there is no fixed UI pool. No VM handles cross to the window. A newer accepted complete-source PUT or code DELETE invalidates queued Console inputs. A successful PUT retains its fresh VM as the live program for Console and GUI events; a failed PUT preserves the previous program. DELETE releases the live context. GET /code returns the source of the displayed snapshot: exact submitted source for PUT, an annotated Console transcript after a REPL render. X-Tessera-Source-Kind identifies source-snapshot versus repl-transcript; a transcript is not a replay log. The compatibility `POST http://{address}/v1/namespaces/{name}/evaluate` endpoint still accepts one isolated expression in a fresh VM without ui or previous bindings. It does not publish a page or change revision. Console does not use this endpoint. All execution endpoints share the eight-entry queue. These are in-process bounded host executions, not a complete untrusted-code sandbox or a process-isolation design. ## Managed visual types `Object`, `Type`, `Function`, `Block`, `ContainerBlock`, `TextBlock`, `RectBlock`, `Spacer`, `Row`, `Column`, `Button`, `Page`, `Mount`, and `Event` are managed nominal Type objects. Use `page = Page()` and `label = TextBlock("Hello")` in Endor source. The existing ui constructors return these same typed objects. `page.type`, `Page.base`, `Page.name`, `isInstance(page, Block)` and `isSubtype(Page, Block)` expose actual type relations. Types, bases, instances and native constructors live in the same GC graph. Ordinary source classes support `class Child: Base`, typed field declarations, explicit first `self` parameters, positional field construction and member assignment. Inherited methods bind the actual receiver; overridden methods are dynamically selected through self. Concrete native visual subclasses reuse real native allocators; abstract bases remain unconstructible. Traits and annotation checking remain pending. Native Block/Page methods use the same type method tables and binding path. Source paint overrides execute in the actual frame pipeline under a read-only heap policy. Source hitTest overrides similarly refine native pointer targets in VM paint order; Point.inside(bounds) and Point.inCircle(cx, cy, radius) provide primitive shape queries. Managed DisplayList/ObjectList/PaintCommand and Constraints/Bounds/Point results are ordinary runtime objects. Native input POST /events is a transport envelope, not Endor source. The window sends current session, layout_version and Mount identity; stale positions fail without calling a callback. Pointer clicks also include point and press_point [x,y] in logical page coordinates. Both targets are resolved through read-only hitTest, with fallback behind a rejected candidate; a click is dispatched only when both resolve to the same Mount. Keyboard/code clicks omit coordinates and remain semantic activation. GET /code also exposes X-Tessera-Session and X-Tessera-Layout-Version headers for transport clients. Native mouse, Tab, Enter and Space delegate to the same managed click/focus methods used by code. Events appear in Network, not Console source history. ## UI host object Positional arguments only: - `ui.text(value, size = 1, color = "#242422")`; integer size 1..8. - `ui.rect(width, height, radius = 0, color = "#DCE8FF")`. - `ui.column(padding, gap, child...)` and `ui.row(padding, gap, child...)`. - `ui.spacer(size)`. - `ui.page()` creates a page; `page.render(root)` mounts its root; `page.unmount()` releases the mount. The last render in a successful submission selects the page shown in this namespace. Defaults above describe the host API; do not write keyword arguments in Endor. Colors use #RRGGBB or #RRGGBBAA. Dimensions are finite numbers in 0..1000000. Use newlines between complete top-level statements. Multiline calls, function bodies and expression continuations are passed to Aster as complete inputs. Limits: 1 MiB source, 4096 constructed nodes, 64 pages, 64 UI levels, 10000 expanded layout nodes, 16 KiB per text, 128 VM frames. Script statements each receive an equal share of a total 1000000-instruction allowance. Older module initialization and main execution each retain their own 1000000-instruction allowance. PUT waits for its worker result. Errors return 422 with diagnostics and preserve the last successful revision. A newer accepted PUT or DELETE supersedes older work (409); a full eight-entry queue returns 503. DELETE immediately clears the document. GET remains available while Aster executes. The window redraws from an owned snapshot; resize does not rerun Endor. Text uses system fonts, antialiasing and font fallback; Chinese needs an installed CJK font. Size 1 is 14 logical pixels with 20-pixel line height; larger sizes multiply both. Native Block methods include setText/setColor, container append/remove/replace/move, Page open/close/navigation, and code-driven button.onClick/click. ui.state and ui.textOf provide explicit text binding. Mouse routing, tab chrome/multiple windows and incremental layout remain in progress; process isolation is future work. Console retains objects only in its explicitly resettable in-memory VM. It is not an untrusted-code sandbox. "##, name = namespace.name, revision = namespace.revision, address = API_ADDRESS, example = include_str!("../examples/page.endor"), ) } fn compile_error(error: anyhow::Error) -> Response>> { let mut diagnostic = serde_json::json!({ "severity": "error", "message": format!("{error:#}") }); if let Some(source) = error.downcast_ref::() { diagnostic["line"] = source.location.line.into(); diagnostic["column"] = source.location.column.into(); diagnostic["start_byte"] = source.diagnostic.span.start.into(); diagnostic["end_byte"] = source.diagnostic.span.end.into(); } json( StatusCode(422), serde_json::json!({ "ok": false, "diagnostics": [diagnostic] }), ) } fn error_response( status: StatusCode, message: impl Into, ) -> Response>> { json(status, serde_json::json!({ "error": message.into() })) } fn json(status: StatusCode, value: serde_json::Value) -> Response>> { text( status, &value.to_string(), "application/json; charset=utf-8", ) } fn markdown(status: StatusCode, value: String) -> Response>> { text(status, &value, "text/markdown; charset=utf-8") } fn text(status: StatusCode, value: &str, content_type: &str) -> Response>> { let mut response = Response::from_string(value).with_status_code(status); add_header(&mut response, "Content-Type", content_type); add_header( &mut response, "Access-Control-Allow-Origin", "http://127.0.0.1", ); add_header( &mut response, "Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS", ); add_header( &mut response, "Access-Control-Allow-Headers", "Content-Type, X-Tessera-Origin", ); response } fn add_header(response: &mut Response>>, name: &str, value: &str) { if let Ok(header) = Header::from_bytes(name, value) { response.add_header(header); } } #[cfg(test)] mod tests { use super::*; use std::{ io::Write, net::{SocketAddr, TcpStream}, sync::atomic::{AtomicUsize, Ordering}, time::Duration, }; struct TestServer { server: Arc, address: SocketAddr, api: Option>, } impl TestServer { fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) { let mut stream = TcpStream::connect(self.address).unwrap(); stream .set_read_timeout(Some(Duration::from_secs(10))) .unwrap(); stream .set_write_timeout(Some(Duration::from_secs(10))) .unwrap(); write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap(); let mut response = String::new(); stream.read_to_string(&mut response).unwrap(); let (headers, body) = response.split_once("\r\n\r\n").unwrap(); let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap(); (status, headers.to_owned(), body.to_owned()) } } impl Drop for TestServer { fn drop(&mut self) { self.server.unblock(); if let Some(api) = self.api.take() { let _ = api.join(); } } } #[test] fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() { let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); let namespace = Arc::new(RwLock::new(NamespaceState::default())); let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; let repl = "/v1/namespaces/app.main/repl"; let code = "/v1/namespaces/app.main/code"; let created = client.request("POST", repl, "title = ui.text(\"中文\")"); assert_eq!(created.0, 200, "{}", created.2); let created: serde_json::Value = serde_json::from_str(&created.2).unwrap(); assert_eq!(created["rendered"], false); assert_eq!(created["revision"], 0); let read: serde_json::Value = serde_json::from_str(&client.request("POST", repl, "title").2).unwrap(); assert_eq!(read["value"], created["value"]); assert_eq!( client.request("POST", repl, "ui.page().render(title)").0, 200 ); let snapshot = client.request("GET", code, ""); assert!( snapshot .1 .to_lowercase() .contains("x-tessera-source-kind: repl-transcript") ); assert_eq!(namespace.read().unwrap().revision, 1); assert_eq!(client.request("DELETE", repl, "").0, 200); assert_eq!(client.request("GET", code, "").2, snapshot.2); assert_eq!(client.request("POST", repl, "title").0, 422); assert_eq!(namespace.read().unwrap().revision, 1); assert_eq!(client.request("POST", repl, "retained = 42").0, 200); assert_eq!( client .request("PUT", code, include_str!("../examples/page.endor")) .0, 200 ); assert_eq!(client.request("POST", repl, "retained").0, 422); assert_eq!(namespace.read().unwrap().revision, 2); } #[test] fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); let namespace = Arc::new(RwLock::new(NamespaceState::default())); let notifications = Arc::new(AtomicUsize::new(0)); let counter = notifications.clone(); let notify: Notify = Arc::new(move || { counter.fetch_add(1, Ordering::SeqCst); }); let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; let route = "/v1/namespaces/app.main/code"; let example = include_str!("../examples/page.endor"); let (status, _, guide) = client.request("GET", "/agent", ""); assert_eq!(status, 200); assert!(guide.contains("let myui = ui.page()")); let (status, _, body) = client.request("PUT", route, example); assert_eq!(status, 200, "{body}"); assert_eq!( serde_json::from_str::(&body).unwrap()["revision"], 1 ); let (status, headers, source) = client.request("GET", route, ""); assert_eq!(status, 200); assert!(headers.to_lowercase().contains("x-tessera-revision: 1")); assert_eq!(source, example); let (status, _, body) = client.request("PUT", route, "function main(): View { return missing }"); assert_eq!(status, 422, "{body}"); assert_eq!( serde_json::from_str::(&body).unwrap()["diagnostics"][0]["line"], 1 ); assert_eq!(client.request("GET", route, "").2, example); let second = example.replace("Hello Endor", "Second"); assert_eq!(client.request("PUT", route, &second).0, 200); assert_eq!(namespace.read().unwrap().revision, 2); assert_eq!(client.request("DELETE", route, "").0, 200); assert_eq!(client.request("GET", route, "").2, ""); assert!(namespace.read().unwrap().document.frame.commands.is_empty()); assert_eq!(client.request("PUT", route, example).0, 200); assert_eq!(namespace.read().unwrap().revision, 4); assert_eq!( client .request("PUT", "/v1/namespaces/other/code", example) .0, 404 ); assert_eq!(notifications.load(Ordering::SeqCst), 4); } #[test] fn http_delete_remains_available_and_invalidates_queued_put() { use std::{ sync::{Mutex, mpsc}, time::Instant, }; let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); let namespace = Arc::new(RwLock::new(NamespaceState::default())); let (entered_tx, entered_rx) = mpsc::channel(); let (release_tx, release_rx) = mpsc::channel(); let release_rx = Mutex::new(release_rx); let calls = AtomicUsize::new(0); let notify: Notify = Arc::new(move || { if calls.fetch_add(1, Ordering::SeqCst) == 0 { entered_tx.send(()).unwrap(); release_rx.lock().unwrap().recv().unwrap(); } }); let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; // Unblock even if a test assertion fails, before scoped threads join. struct Release(mpsc::Sender<()>); impl Drop for Release { fn drop(&mut self) { let _ = self.0.send(()); } } thread::scope(|scope| { let release = Release(release_tx); let client = &client; let route = "/v1/namespaces/app.main/code"; let example = include_str!("../examples/page.endor"); let first = scope.spawn(move || client.request("PUT", route, example)); entered_rx.recv_timeout(Duration::from_secs(10)).unwrap(); let pending = scope.spawn(move || client.request("PUT", route, example)); let deadline = Instant::now() + Duration::from_secs(10); while namespace.read().unwrap().submission < 2 { assert!(Instant::now() < deadline, "second PUT was not admitted"); thread::sleep(Duration::from_millis(5)); } assert_eq!(client.request("GET", route, "").2, example); assert_eq!(client.request("DELETE", route, "").0, 200); drop(release); assert_eq!(first.join().unwrap().0, 200); assert_eq!(pending.join().unwrap().0, 409); assert_eq!(client.request("GET", route, "").2, ""); assert_eq!(namespace.read().unwrap().revision, 2); }); } #[test] fn extracts_namespace_only_from_code_routes() { assert_eq!( namespace_from_path("/v1/namespaces/app.main/code"), Some("app.main") ); assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None); assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None); } #[test] fn guide_contains_only_code_crud_routes() { let guide = agent_guide(&NamespaceState::default()); assert!(guide.contains("Code CRUD")); assert!(guide.contains("let myui = ui.page()")); assert!(!guide.contains("export scene")); assert!(guide.contains("PUT http://127.0.0.1:4737")); assert!(!guide.contains("display_list")); } }