makepad/tools/ci/src/drive.rs

1416 lines
48 KiB
Rust

//! Deterministic Splash host. Natives never use model output to choose input.
use crate::{
process::{ChildLog, Result},
remote::{Failure, Remote},
report::{safe_name, Run, Update},
smoke::{Script, Target},
uihub::Judge,
watch::Config,
};
use makepad_script::*;
use makepad_strict_json::{self as json, Value};
use std::{
fs,
path::{Path, PathBuf},
process::Command,
sync::Arc,
time::{Duration, Instant},
};
struct AppHandle {
child: ChildLog,
remote: Option<Remote>,
interrupted: bool,
closed: bool,
name: String,
}
struct Runtime {
run: Run,
config: Config,
hub: Judge,
apps: Vec<AppHandle>,
images: Vec<PathBuf>,
group: String,
step: Option<usize>,
suppressed: bool,
fatal: bool,
skipped: bool,
validate: bool,
validated_steps: Vec<String>,
host: crate::cargo::Host,
permit: Option<crate::runner::Permit>,
warning: String,
manifest: Option<(String, PathBuf)>,
}
impl Runtime {
fn fail(&mut self, text: &str) {
self.suppressed = true;
if let Some(i) = self.step {
self.run.end(i, "failed", text);
} else {
self.run.fail(&format!("{}: runtime", self.group), text);
}
}
fn allowed(&mut self) -> bool {
if self.suppressed || self.fatal {
return false;
}
if let Err(e) = self.run.control.check() {
self.fatal = true;
self.fail(&e);
return false;
}
true
}
fn request(&mut self, i: usize, route: &str, mutate: bool) -> Result<String> {
if !route.starts_with('/')
|| route
.bytes()
.any(|b| b.is_ascii_whitespace() || b.is_ascii_control())
|| route.contains("if_user_seq")
{
return Err("invalid route (or attempt to replace the pinned user sequence)".into());
}
let app = self.apps.get_mut(i).ok_or("invalid app handle")?;
if app.interrupted {
return Err("human intervention; app is no longer owned".into());
}
if app.closed {
return Err(format!("{} already closed", app.name));
}
let remote = app.remote.as_ref().ok_or("remote unavailable")?;
self.run.log(&format!("{} GET {route}", app.name));
// A grab the bridge could not place, or a grab budget that is full,
// ends in "; retry": ask again. An input is never asked again: the
// app applied it when it took the request, whatever it answered
// about the frame after it, and a second request is a second key.
let mut attempt = 0;
let result = loop {
let result = remote.request(route, mutate);
let again = !applies_input(route)
&& matches!(&result, Err(e) if e.to_string().contains("; retry"));
if !again || attempt == 5 {
break result;
}
attempt += 1;
std::thread::sleep(Duration::from_millis(300));
};
if matches!(result, Err(Failure::Interrupted(_))) {
app.interrupted = true;
self.fatal = true;
self.run.held_pids.push(app.child.child.id());
}
let text = result.map_err(|e| e.to_string())?;
if let Ok(v) = json::parse_depth(text.as_bytes(), 64) {
if let Some(e) = v.get("err") {
return Err(format!("GET {route}: {}", e.to_json()));
}
}
Ok(text)
}
fn request_json(&mut self, i: usize, route: &str, mutate: bool) -> Result<Value> {
json::parse_depth(self.request(i, route, mutate)?.as_bytes(), 64).map_err(str::to_string)
}
fn drain_app(&mut self, i: usize) -> Result<()> {
let run = &mut self.run;
self.apps[i].child.drain(&mut |s| run.log(s))?;
if self.apps[i]
.child
.text
.contains("[makepad-remote] user closed")
|| self.apps[i].child.text.contains("app exit: user closed")
{
self.apps[i].interrupted = true;
self.fatal = true;
let pid = self.apps[i].child.child.id();
if !self.run.held_pids.contains(&pid) {
self.run.held_pids.push(pid);
}
return Err("user closed window; leaving the instance alone".into());
}
Ok(())
}
fn alive(&mut self, i: usize) -> Result<bool> {
self.drain_app(i)?;
let alive = self.apps[i].child.alive()?;
if !alive && !self.apps[i].closed {
self.fatal = true;
}
Ok(alive)
}
fn launch(&mut self, binary: PathBuf, cwd: PathBuf, app_env: &[(String, String)]) -> Result<usize> {
let binary = self.run.root.join(binary);
let name = binary
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !binary.is_file() {
self.fatal = true;
return Err(format!("missing binary: {}", binary.display()));
}
let cwd = inside(&self.run.root, &cwd)?;
let path = self.run.path(&format!("{}-stdout.log", name));
let mut command = Command::new(&binary);
command
.current_dir(cwd)
.arg("--remote")
.env("MAKEPAD_HIDE_WINDOWS", "1")
.env_remove("MAKEPAD_FOCUS")
.env("SANDBOX_MUTE", "1")
.env("CARGO_TARGET_DIR", self.run.root.join("target"))
.envs(app_env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
let child = ChildLog::spawn(&mut command, path)?;
self.run
.log(&format!("owned {} pid={}", name, child.child.id()));
let index = self.apps.len();
self.apps.push(AppHandle {
child,
remote: None,
interrupted: false,
closed: false,
name: name,
});
let deadline = Instant::now() + Duration::from_secs(60);
loop {
self.run.control.check()?;
self.drain_app(index)?;
let app = &mut self.apps[index];
let pid = app.child.child.id();
if let Some(port) = app
.child
.text
.lines()
.find_map(|line| parse_startup(line, pid))
{
app.remote = Some(Remote {
port,
user_seq: None,
});
break;
}
if !app.child.alive()? {
self.fatal = true;
return Err(format!(
"{} exited before remote startup\n{}",
app.name,
crate::process::error_lines(&app.child.text)
));
}
if Instant::now() >= deadline {
return Err("remote startup timed out after 60s".into());
}
self.run.control.sleep(0.1)?;
}
loop {
self.run.control.check()?;
if !self.alive(index)? {
return Err("app died before ready".into());
}
if let Ok(v) = self.request_json(index, "/s", false) {
if v.get("w")
.and_then(Value::as_arr)
.is_some_and(|w| !w.is_empty())
{
break;
}
}
if self.fatal {
return Err("human intervention during startup".into());
}
if Instant::now() >= deadline {
return Err("/s readiness timed out".into());
}
self.run.control.sleep(0.2)?;
}
let activity = self.request_json(index, "/activity", false)?;
let seq = activity
.get("user_seq")
.and_then(Value::as_u64)
.ok_or("missing activity user_seq")?;
self.apps[index].remote.as_mut().unwrap().user_seq = Some(seq);
if activity.get("user_active").and_then(Value::as_bool) == Some(true) {
self.apps[index].interrupted = true;
self.fatal = true;
self.run.held_pids.push(self.apps[index].child.child.id());
return Err("human active at startup; leaving app running".into());
}
let help = self.request(index, "/", false)?;
let path = self.run.path("remote-help.txt");
fs::write(path, help).map_err(|e| e.to_string())?;
Ok(index)
}
fn key(&mut self, i: usize, code: &str, mods: &str) -> Result<()> {
self.run.control.check()?;
self.request_json(
i,
&format!("/k?k=down&c={}{mods}&wait=1", encode(code)),
true,
)?;
// Always release a successfully pressed key, including on Stop.
std::thread::sleep(Duration::from_millis(180));
self.request_json(i, &format!("/k?k=up&c={}{mods}&wait=1", encode(code)), true)?;
self.run.control.check()
}
fn type_text(&mut self, i: usize, text: &str) -> Result<()> {
for c in text.chars() {
let (code, shift) = character_key(c)?;
self.key(i, &code, if shift { "&shift=1" } else { "" })?;
}
Ok(())
}
fn log_text(&mut self, i: usize) -> Result<String> {
self.drain_app(i)?;
let v = self.request_json(i, "/log?n=10000", false)?;
let mut lines = Vec::new();
collect_log(&v, &mut lines);
Ok(lines.join("\n"))
}
fn no_errors(&mut self, i: usize) -> Result<()> {
if !self.alive(i)? {
return Err("app process is dead".into());
}
let log = format!("{}\n{}", self.log_text(i)?, self.apps[i].child.text);
let errors: Vec<_> = log
.lines()
.filter(|line| {
let line = line.trim_start();
let error = line.starts_with("[E]") || line.contains("panicked");
let allowed = line.contains("drag_anywhere")
&& self
.config
.allowed_errors
.iter()
.any(|entry| !entry.is_empty() && line.contains(entry));
error && !allowed
})
.take(40)
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("\n"))
}
}
fn wait_log(&mut self, i: usize, text: &str, secs: f64) -> Result<()> {
if !secs.is_finite() || !(0.0..=3600.0).contains(&secs) {
return Err("invalid log timeout".into());
}
let end = Instant::now() + Duration::from_secs_f64(secs);
loop {
if !self.alive(i)? {
return Err("app died while waiting for log".into());
}
if log_has(&self.log_text(i)?, text) {
return Ok(());
}
if Instant::now() >= end {
return Err(format!(
"{}: log did not contain {text:?} within {secs}s",
self.apps[i].name
));
}
self.run.control.sleep(0.2)?;
}
}
fn grab(&mut self, i: usize, name: &str) -> Result<usize> {
let value = self.request_json(i, "/g", false)?;
let mut paths = Vec::new();
png_paths(&value, &mut paths);
let source = paths.first().ok_or("grab did not return a PNG path")?;
let bytes = fs::read(source).map_err(|e| e.to_string())?;
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Err("grab is not PNG".into());
}
let sub = self.run.dir.join(safe_name(&self.group));
fs::create_dir_all(&sub).map_err(|e| e.to_string())?;
let path = sub.join(format!("{}.png", safe_name(name)));
if path.exists() {
return Err(format!("duplicate grab name: {name}"));
}
fs::write(&path, &bytes).map_err(|e| e.to_string())?;
self.run.grabs.push(path.display().to_string());
(self.run.notify)(Update::Grab(path.clone(), Arc::new(bytes)));
let index = self.images.len();
self.images.push(path);
Ok(index)
}
fn quit(&mut self, i: usize) -> Result<()> {
if self.apps[i].interrupted {
return Err("human intervention; app left running".into());
}
if self.apps[i].closed {
return Ok(());
}
if !self.apps[i].child.alive()? {
self.apps[i].closed = true;
return Ok(());
}
// A failed launch can have an endpoint but not yet have pinned ownership.
if self.apps[i]
.remote
.as_ref()
.is_some_and(|r| r.user_seq.is_none())
{
let a = self.request_json(i, "/activity", false)?;
let seq = a
.get("user_seq")
.and_then(Value::as_u64)
.ok_or("cleanup missing user_seq")?;
self.apps[i].remote.as_mut().unwrap().user_seq = Some(seq);
if a.get("user_active").and_then(Value::as_bool) == Some(true) {
self.apps[i].interrupted = true;
self.run.held_pids.push(self.apps[i].child.child.id());
return Err("human active; app left running".into());
}
}
let mut error = None;
if self.apps[i].remote.is_some() {
if let Err(e) = self.request_json(i, "/gq", true) {
if self.apps[i].interrupted {
return Err(e);
}
self.run.log(&format!("/gq failed: {e}; trying /quit"));
error = Some(e);
if let Err(e) = self.request(i, "/quit", true) {
if self.apps[i].interrupted {
return Err(e);
}
}
}
}
let end = Instant::now() + Duration::from_secs(10);
while self.apps[i].child.alive()? && Instant::now() < end {
self.drain_app(i)?;
std::thread::sleep(Duration::from_millis(100));
}
if self.apps[i].child.alive()? {
if self.apps[i].remote.is_some() {
if let Err(e) = self.request(i, "/activity", false) {
if self.apps[i].interrupted {
return Err(e);
}
}
}
let pid = self.apps[i].child.child.id();
self.apps[i].child.kill_exact()?;
self.run.log(&format!(
"killed exact owned pid={pid} after graceful shutdown timeout"
));
error = Some(format!("pid={pid} required forced cleanup"));
}
self.apps[i].child.child.wait().map_err(|e| e.to_string())?;
let run = &mut self.run;
self.apps[i].child.finish_output(&mut |s| run.log(s))?;
self.apps[i].closed = true;
error.map_or(Ok(()), Err)
}
}
fn parse_startup(line: &str, pid: u32) -> Option<u16> {
if !line.starts_with("[makepad-remote] listening on 127.0.0.1:") {
return None;
}
let fields: Vec<_> = line.split_whitespace().collect();
let actual = fields
.iter()
.find_map(|s| s.strip_prefix("pid="))
.and_then(|s| s.parse::<u32>().ok())?;
if actual != pid {
return None;
}
fields.get(3)?.rsplit(':').next()?.parse().ok()
}
fn character_key(c: char) -> Result<(String, bool)> {
match c {
'a'..='z' | 'A'..='Z' => Ok((
format!("Key{}", c.to_ascii_uppercase()),
c.is_ascii_uppercase(),
)),
'0'..='9' => Ok((format!("Key{c}"), false)),
' ' => Ok(("Space".into(), false)),
_ => Err(format!("type_text does not support character {c:?}")),
}
}
/// The routes that apply an input to the app when it takes the request
/// (keys, pointer, text, a dropped file, the tweaker, the AI pane): a
/// second request is a second input, so these are never asked again.
fn applies_input(route: &str) -> bool {
["/k?", "/m?", "/t?", "/click?", "/drop?", "/tweak?", "/ai?"]
.iter()
.any(|prefix| route.starts_with(prefix))
}
fn encode(s: &str) -> String {
s.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect()
}
fn collect_log(v: &Value, out: &mut Vec<String>) {
match v {
Value::Str(s) => out.push(s.clone()),
Value::Arr(a) => {
for x in a {
collect_log(x, out)
}
}
Value::Obj(a) => {
for (k, v) in a {
if matches!(
k.as_str(),
"l" | "lines" | "log" | "entries" | "text" | "message" | "msg" | "s"
) {
collect_log(v, out);
}
}
}
_ => {}
}
}
fn png_paths(v: &Value, out: &mut Vec<String>) {
match v {
Value::Obj(a) => {
for (k, v) in a {
if k == "png" {
match v {
Value::Str(s) => out.push(s.clone()),
Value::Arr(a) => {
out.extend(a.iter().filter_map(Value::as_str).map(str::to_string))
}
_ => {}
}
} else {
png_paths(v, out)
}
}
}
Value::Arr(a) => {
for v in a {
png_paths(v, out)
}
}
_ => {}
}
}
fn inside(root: &Path, path: &Path) -> Result<PathBuf> {
let root = root.canonicalize().map_err(|e| e.to_string())?;
let path = root.join(path).canonicalize().map_err(|e| e.to_string())?;
if !path.starts_with(&root) {
return Err("cwd must remain inside checkout".into());
}
Ok(path)
}
fn rt<'a>(vm: &'a mut ScriptVm) -> &'a mut Runtime {
vm.host.as_any_mut().downcast_mut::<Runtime>().unwrap()
}
fn value(vm: &ScriptVm, args: ScriptObject, key: LiveId) -> ScriptValue {
vm.bx.heap.value(args, key.into(), NoTrap)
}
fn text(vm: &mut ScriptVm, v: ScriptValue) -> Result<String> {
vm.string_with(v, |_, s| s.to_string())
.ok_or("expected string".into())
}
fn str_arg(vm: &mut ScriptVm, args: ScriptObject, key: LiveId) -> Result<String> {
let v = value(vm, args, key);
text(vm, v)
}
fn num_arg(vm: &ScriptVm, args: ScriptObject, key: LiveId) -> Result<f64> {
value(vm, args, key)
.as_number()
.filter(|v| v.is_finite())
.ok_or_else(|| {
format!(
"expected finite number for {}: {:?}",
if key == id!(image) {
"image"
} else if key == id!(secs) {
"secs"
} else {
"coordinate"
},
value(vm, args, key)
)
})
}
fn object_field(vm: &ScriptVm, v: ScriptValue, key: LiveId) -> ScriptValue {
v.as_object()
.map(|o| value(vm, o, key))
.filter(|v| !v.is_err())
.unwrap_or(NIL)
}
fn json_value(vm: &mut ScriptVm, v: Value) -> ScriptValue {
match v {
Value::Null => NIL,
Value::Bool(v) => v.into(),
Value::Int(v) => (v as f64).into(),
Value::F64(v) => v.into(),
Value::Str(v) => vm.new_string_with(|_, out| out.push_str(&v)),
v => {
let mut parser = makepad_script::json::JsonParserThread::default();
parser.read_json(&v.to_json(), &mut vm.bx.heap)
}
}
}
fn result(vm: &mut ScriptVm, r: Result<Value>) -> ScriptValue {
match r {
Ok(v) => json_value(vm, v),
Err(e) => {
rt(vm).fail(&e);
NIL
}
}
}
fn allow(vm: &mut ScriptVm) -> bool {
if vm
.bx
.captured_errors
.as_ref()
.is_some_and(|a| !a.is_empty())
{
let e = vm.take_errors().join("\n");
vm.bx.captured_errors = Some(Vec::new());
rt(vm).fail(&e);
return false;
}
rt(vm).allowed()
}
#[path = "drive_natives.rs"]
mod natives;
fn register(vm: &mut ScriptVm, target: Option<&Target>) {
let ci = vm.new_module(id!(ci));
natives::register(vm, ci);
let v = if let Some(t) = target {
json_value(
vm,
json::obj(vec![
("package", json::s(&t.package)),
("binary", json::s(&t.binary)),
("cwd", json::s(t.cwd.display().to_string())),
("manifest", json::s(t.manifest.display().to_string())),
]),
)
} else {
NIL
};
vm.bx.heap.set_value_def(ci, id!(target).into(), v);
vm.add_method(
ci,
id_lut!(step),
script_args!(name = NIL, body = NIL),
|vm, args| {
let name = match str_arg(vm, args, id!(name)) {
Ok(v) => v,
Err(e) => {
rt(vm).fail(&e);
return NIL;
}
};
let body = value(vm, args, id!(body));
if rt(vm).step.is_some() {
rt(vm).fail("nested ci.step is not supported");
return NIL;
}
let before = vm.take_errors();
vm.bx.captured_errors = Some(Vec::new());
if !before.is_empty() {
rt(vm).fail(&before.join("\n"));
}
let runtime = rt(vm);
let i = runtime.run.plan(&format!("{} / {name}", runtime.group));
if runtime.fatal || runtime.run.control.stopped() {
runtime
.run
.end(i, "skipped", "app dead, human intervention, or run stopped");
return NIL;
}
runtime.suppressed = false;
runtime.skipped = false;
runtime.warning.clear();
runtime.step = Some(i);
runtime.run.begin(i, "");
runtime.validated_steps.push(name);
let answer = vm.call(body, &[]);
let errors = vm.take_errors();
vm.bx.captured_errors = Some(Vec::new());
if !errors.is_empty() {
rt(vm).fail(&errors.join("\n"));
}
if answer.is_err() && !rt(vm).suppressed {
rt(vm).fail("step callback returned an error");
}
let runtime = rt(vm);
if !runtime.suppressed {
runtime.run.end(
i,
if runtime.skipped || !runtime.warning.is_empty() {
"warning"
} else {
"passed"
},
if runtime.skipped {
"skipped (model not installed)"
} else {
&runtime.warning
},
);
}
runtime.step = None;
runtime.suppressed = runtime.fatal;
NIL
},
);
vm.add_method(
ci,
id_lut!(launch),
script_args!(target = NIL),
|vm, args| {
if !allow(vm) {
// Stopped or already failed: the script still holds an app,
// an inert one, so its later calls are not errors on nil.
return app_object(vm, usize::MAX);
}
let v = value(vm, args, id!(target));
let r = natives::launch(vm, v);
match r {
Ok(i) => app_object(vm, i),
Err(e) => {
rt(vm).fatal = true;
rt(vm).fail(&e);
// A script keeps its shape after a failed launch: the app
// it holds is inert (the run is fatal, so every method
// returns before acting) instead of nil, which would add
// a "method not found on nil" error per later call.
app_object(vm, usize::MAX)
}
}
},
);
vm.add_method(ci, id_lut!(sleep), script_args!(secs = NIL), |vm, args| {
if !allow(vm) {
return NIL;
}
let r = num_arg(vm, args, id!(secs))
.and_then(|n| {
if rt(vm).validate {
Ok(())
} else {
rt(vm).run.control.sleep(n)
}
})
.map(|_| Value::Null);
result(vm, r)
});
vm.add_method(
ci,
id_lut!(check),
script_args!(condition = NIL, message = NIL),
|vm, args| {
if !allow(vm) {
return NIL;
}
if value(vm, args, id!(condition)).as_bool() != Some(true) {
let message = str_arg(vm, args, id!(message)).unwrap_or_else(|e| e);
rt(vm).fail(&message);
}
NIL
},
);
vm.add_method(
ci,
id_lut!(fail),
script_args!(message = NIL),
|vm, args| {
if !allow(vm) {
return NIL;
}
let s = str_arg(vm, args, id!(message)).unwrap_or_else(|e| e);
rt(vm).fail(&s);
NIL
},
);
vm.add_method(ci, id_lut!(log), script_args!(text = NIL), |vm, args| {
if !allow(vm) {
return NIL;
}
let r = str_arg(vm, args, id!(text)).map(|s| {
rt(vm).run.log(&s);
Value::Null
});
result(vm, r)
});
for (name, accept) in [(id_lut!(judge), false), (id_lut!(accept), true)] {
vm.add_method(
ci,
name,
script_args!(image = NIL, text = NIL),
move |vm, args| {
if !allow(vm) {
return NIL;
}
let r = (|| {
let prompt = str_arg(vm, args, id!(text))?;
let index = num_arg(vm, args, id!(image))? as usize;
if rt(vm).validate {
return Ok(json::obj(vec![
("yes", Value::Bool(true)),
("reasons", Value::Arr(vec![])),
("raw", json::s("VERDICT: YES")),
]));
}
let runtime = rt(vm);
let image = runtime
.images
.get(index)
.ok_or("invalid image handle")?
.clone();
let verdict = runtime.hub.judge(
&image,
&prompt,
&runtime.run.control,
&runtime.run.notify,
)?;
runtime
.run
.log(&format!("judge {}: {}", image.display(), verdict.raw));
let record = json::obj(vec![
("group", json::s(&runtime.group)),
(
"step",
json::s(
runtime
.step
.map(|i| runtime.run.stages[i].name.as_str())
.unwrap_or("judge"),
),
),
("prompt", json::s(&prompt)),
("image", json::s(image.display().to_string())),
("verdict", verdict.json()),
(
"peak_rss_bytes",
crate::uihub::peak_rss_bytes()
.map(|n| Value::Int(n as i64))
.unwrap_or(Value::Null),
),
]);
runtime.run.evidence.push(record);
runtime.skipped |= verdict.skipped;
if verdict.skipped && runtime.step.is_none() {
let i = runtime.run.plan(&format!("{} / vision", runtime.group));
runtime.run.end(i, "warning", &verdict.raw);
}
if accept && !verdict.yes {
// The raw answer already contains both reasons and verdict.
return Err(if verdict.parse_error {
format!("vision parse failed\n{}", verdict.raw)
} else {
verdict.raw
});
}
Ok(verdict.json())
})();
result(vm, r)
},
);
}
vm.add_method(ci, id_lut!(ask), script_args!(text = NIL), |vm, args| {
if !allow(vm) {
return NIL;
}
let r = (|| {
let prompt = str_arg(vm, args, id!(text))?;
if rt(vm).validate {
return Ok(json::s("VERDICT: YES"));
}
let r = rt(vm);
let answer = r.hub.ask(&prompt, &r.run.control, &r.run.notify)?;
r.skipped |= answer == "skipped (model not installed)";
if r.skipped && r.step.is_none() {
let i = r.run.plan(&format!("{} / ask", r.group));
r.run.end(i, "warning", &answer);
}
r.run.log(&format!("ask: {answer}"));
r.run.evidence.push(json::obj(vec![
("ask", json::s(prompt)),
("answer", json::s(&answer)),
]));
Ok(json::s(answer))
})();
result(vm, r)
});
vm.add_method(
ci,
id_lut!(run),
script_args!(program = NIL, args = NIL, options = NIL),
|vm, args| {
if !allow(vm) {
return NIL;
}
let r = (|| {
let program = str_arg(vm, args, id!(program))?;
let av = value(vm, args, id!(args));
let array = av.as_array().ok_or("run args must be an array")?;
let mut argv = Vec::new();
for i in 0..vm.bx.heap.array_len(array) {
let v = vm.bx.heap.array_index(array, i, NoTrap);
argv.push(text(vm, v)?);
}
let options = value(vm, args, id!(options));
let cwd = object_field(vm, options, id!(cwd));
let cwd = if cwd.is_nil() {
".".into()
} else {
text(vm, cwd)?
};
let timeout = object_field(vm, options, id!(timeout_secs))
.as_number()
.unwrap_or(600.0);
if !timeout.is_finite() || !(1.0..=86400.0).contains(&timeout) {
return Err("invalid timeout_secs".into());
}
let envobj = object_field(vm, options, id!(env));
let mut env = Vec::new();
if let Some(obj) = envobj.as_object() {
let fields: Vec<_> = vm
.bx
.heap
.map_ref(obj)
.iter()
.map(|(k, v)| (*k, v.value))
.collect();
for (k, v) in fields {
let mut key = String::new();
vm.bx.heap.cast_to_string(k, &mut key);
env.push((key, text(vm, v)?));
}
}
if rt(vm).validate {
return Ok(json::obj(vec![
("code", Value::Int(0)),
("out", json::s("")),
]));
}
let cwd = inside(&rt(vm).run.root, Path::new(&cwd))?;
if program == "cargo" {
env.retain(|(k, _)| k != "CARGO_TARGET_DIR");
env.push((
"CARGO_TARGET_DIR".into(),
rt(vm).run.root.join("target").display().to_string(),
));
}
if let Some(i) = rt(vm).step {
rt(vm).run.stages[i].command = crate::report::display_command(&program, &argv);
rt(vm).run.publish();
}
let output = rt(vm)
.run
.command(&program, &argv, &cwd, &env, timeout as u64)?;
Ok(json::obj(vec![
("code", Value::Int(output.code as i64)),
("out", json::s(output.out)),
]))
})();
result(vm, r)
},
);
}
fn app_object(vm: &mut ScriptVm, index: usize) -> ScriptValue {
let obj = vm.bx.heap.new_object();
vm.add_method(
obj,
id_lut!(key),
script_args!(code = NIL, options = NIL),
move |vm, args| {
if !allow(vm) {
return NIL;
}
let r = (|| {
let code = str_arg(vm, args, id!(code))?;
let options = value(vm, args, id!(options));
let mut mods = String::new();
for (k, s) in [
(id!(cmd), "cmd"),
(id!(shift), "shift"),
(id!(ctrl), "ctrl"),
(id!(alt), "alt"),
] {
if object_field(vm, options, k).as_bool() == Some(true) {
mods.push_str(&format!("&{s}=1"));
}
}
if !rt(vm).validate {
rt(vm).key(index, &code, &mods)?;
}
Ok(Value::Null)
})();
result(vm, r)
},
);
vm.add_method(
obj,
id_lut!(type_text),
script_args!(text = NIL),
move |vm, args| {
if !allow(vm) {
return NIL;
}
let r = str_arg(vm, args, id!(text)).and_then(|s| {
if rt(vm).validate {
for c in s.chars() {
character_key(c)?;
}
} else {
rt(vm).type_text(index, &s)?;
}
Ok(Value::Null)
});
result(vm, r)
},
);
for (method, scroll) in [(id_lut!(click), false), (id_lut!(scroll), true)] {
vm.add_method(
obj,
method,
script_args!(x = NIL, y = NIL, dx = 0.0, dy = 0.0),
move |vm, args| {
if !allow(vm) {
return NIL;
}
let r = (|| {
let x = num_arg(vm, args, id!(x))?;
let y = num_arg(vm, args, id!(y))?;
let route = if scroll {
format!(
"/m?k=scroll&x={x}&y={y}&dx={}&dy={}&wait=1",
num_arg(vm, args, id!(dx))?,
num_arg(vm, args, id!(dy))?
)
} else {
format!("/click?x={x}&y={y}&wait=1")
};
if !rt(vm).validate {
rt(vm).request_json(index, &route, true)?;
}
Ok(Value::Null)
})();
result(vm, r)
},
);
}
vm.add_method(
obj,
id_lut!(get),
script_args!(route = NIL),
move |vm, args| {
if !allow(vm) {
return NIL;
}
let r = str_arg(vm, args, id!(route)).and_then(|s| {
if rt(vm).validate {
Ok(json::s("{}"))
} else {
rt(vm).request(index, &s, true).map(json::s)
}
});
result(vm, r)
},
);
vm.add_method(
obj,
id_lut!(snap),
script_args!(query = NIL),
move |vm, args| {
if !allow(vm) {
return NIL;
}
let r = str_arg(vm, args, id!(query)).and_then(|s| {
if rt(vm).validate {
Ok(Value::Arr(vec![]))
} else {
rt(vm)
.request_json(index, &format!("/snap?q={}", encode(&s)), false)
.map(|v| {
v.get("s")
.or_else(|| v.get("entries"))
.or_else(|| v.get("items"))
.cloned()
.unwrap_or(v)
})
}
});
result(vm, r)
},
);
vm.add_method(
obj,
id_lut!(wait_log),
script_args!(substring = NIL, secs = NIL),
move |vm, args| {
if !allow(vm) {
return NIL;
}
let r = (|| {
let s = str_arg(vm, args, id!(substring))?;
let secs = num_arg(vm, args, id!(secs))?;
if !rt(vm).validate {
rt(vm).wait_log(index, &s, secs)?;
}
Ok(Value::Null)
})();
result(vm, r)
},
);
vm.add_method(obj, id_lut!(log), script_args!(), move |vm, _| {
if !allow(vm) {
return NIL;
}
let r = if rt(vm).validate {
Ok(json::s(""))
} else {
rt(vm).log_text(index).map(json::s)
};
result(vm, r)
});
vm.add_method(obj, id_lut!(no_errors), script_args!(), move |vm, _| {
if !allow(vm) {
return NIL;
}
let r = if rt(vm).validate {
Ok(Value::Null)
} else {
rt(vm).no_errors(index).map(|_| Value::Null)
};
result(vm, r)
});
vm.add_method(obj, id_lut!(alive), script_args!(), move |vm, _| {
if !allow(vm) {
return NIL;
}
let r = if rt(vm).validate {
Ok(Value::Bool(true))
} else {
rt(vm).alive(index).map(Value::Bool)
};
result(vm, r)
});
vm.add_method(
obj,
id_lut!(grab),
script_args!(name = NIL),
move |vm, args| {
if !allow(vm) {
return NIL;
}
let r = str_arg(vm, args, id!(name)).and_then(|s| {
if rt(vm).validate {
Ok(Value::Int(0))
} else {
rt(vm).grab(index, &s).map(|i| Value::Int(i as i64))
}
});
result(vm, r)
},
);
vm.add_method(obj, id_lut!(quit), script_args!(), move |vm, _| {
if !allow(vm) {
return NIL;
}
let r = if rt(vm).validate {
Ok(Value::Null)
} else {
rt(vm).quit(index).map(|_| Value::Null)
};
result(vm, r)
});
obj.into()
}
pub fn run_script(
run: Run,
config: Config,
hub: Judge,
script: &Script,
permit: Option<crate::runner::Permit>,
) -> Run {
execute(run, config, hub, script, false, permit).0
}
fn execute(
run: Run,
config: Config,
hub: Judge,
script: &Script,
validate: bool,
permit: Option<crate::runner::Permit>,
) -> (Run, Vec<String>) {
let host = if validate {
crate::cargo::Host {
target: "host".into(),
targets: vec!["wasm32-unknown-unknown".into()],
nightly: None,
}
} else {
let shared = run.cargo_cache.clone();
let mut cache = shared.lock().unwrap();
let detected = match &cache.host {
Some(host) => Ok(host.clone()),
None => crate::cargo::Host::detect(&run),
};
match detected {
Ok(h) => { cache.host = Some(h.clone()); h },
Err(e) => {
let mut run = run;
run.fail("host discovery", &e);
return (run, Vec::new());
}
}
};
let manifest_path = script
.target
.as_ref()
.map(|t| t.manifest.clone())
.unwrap_or_else(|| {
script
.path
.parent()
.unwrap_or(Path::new("."))
.join("Cargo.toml")
});
let manifest = fs::read_to_string(run.root.join(&manifest_path))
.ok()
.and_then(|text| makepad_toml_parser::parse_toml(&text).ok())
.and_then(|doc| {
doc.get_path(&["package", "name"])
.and_then(|v| v.as_str())
.map(|name| (name.to_string(), manifest_path))
});
let source = fs::read_to_string(run.root.join(&script.path));
let mut host = ScriptVmHost::new(
Runtime {
run,
config,
hub,
apps: Vec::new(),
images: Vec::new(),
group: script.name.clone(),
step: None,
suppressed: false,
fatal: false,
skipped: false,
validate,
validated_steps: Vec::new(),
host,
permit,
warning: String::new(),
manifest,
},
(),
);
match source {
Err(e) => host
.host
.run
.fail(&script.name, &format!("{}: {e}", script.path.display())),
Ok(code) => {
let mut vm = ScriptVm {
host: &mut host,
bx: Box::new(ScriptVmBase::new()),
};
register(&mut vm, script.target.as_ref());
vm.bx.captured_errors = Some(Vec::new());
let answer = vm.with_instruction_limit(5_000_000, |vm| {
vm.eval(ScriptMod {
file: script.path.display().to_string(),
code,
..Default::default()
})
});
let errors = vm.take_errors();
if !errors.is_empty() {
rt(&mut vm).fail(&errors.join("\n"));
}
if answer.is_err() {
rt(&mut vm).fail("script returned an error");
}
}
}
let runtime = &mut host.host;
for i in 0..runtime.apps.len() {
let stage = runtime.run.plan(&format!(
"{} / cleanup {}",
runtime.group, runtime.apps[i].name
));
runtime.run.begin(stage, "GET /gq");
match runtime.quit(i) {
Ok(()) => runtime.run.end(stage, "passed", ""),
Err(e) => runtime.run.end(stage, "failed", &e),
}
}
(host.host.run, host.host.validated_steps)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{process::Control, report::Notify};
#[test]
fn an_input_route_is_never_asked_twice_a_grab_is() {
for route in ["/k?k=down&c=KeyB&wait=1", "/m?k=click&x=1&y=2", "/t?t=browser", "/click?x=1&y=2", "/drop?path=/a&x=1&y=1"] {
assert!(applies_input(route), "{route}");
}
for route in ["/g", "/gseq?n=8", "/log?n=10", "/gq", "/quit", "/activity", "/snap?q=x"] {
assert!(!applies_input(route), "{route}");
}
}
#[test]
fn contract_scripts_execute_on_real_vm_without_io() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap();
let config = Config {
remote: "origin".into(),
branches: vec!["work".into()],
poll_secs: 60,
checkout: root.clone(),
skip_apps: vec![],
model: "qwen3.5-4b-vision".into(),
targets: vec![],
allowed_errors: vec![],
no_vision: true,
parallel: 2,
deep_tests: false,
machines: Default::default(),
};
let judge = crate::uihub::JudgeWorker::start(&config.model, true).unwrap();
// The three contract scripts with their pinned step counts, then every
// app script in the tree: each follows the default shape, two steps.
let mut scripts: Vec<(String, usize)> = vec![
("ci.splash".into(), 3),
("apps/wm/ci.splash".into(), 6),
("tools/ci/default.ci.splash".into(), 2),
];
let mut apps: Vec<_> = fs::read_dir(root.join("apps"))
.unwrap()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|app| app != "wm" && root.join("apps").join(app).join("ci.splash").is_file())
.collect();
apps.sort();
assert!(apps.len() >= 20, "the app scripts are missing: {apps:?}");
// The terminal, and director which embeds one, also build the pty
// helper a terminal starts its shell through.
scripts.extend(apps.into_iter().map(|app| {
let steps = if app == "terminal" || app == "director" { 3 } else { 2 };
(format!("apps/{app}/ci.splash"), steps)
}));
for (path, count) in scripts.iter().map(|(path, count)| (path.as_str(), *count)) {
let notify: Notify = Arc::new(|_| {});
let run = Run::new(
&root,
root.clone(),
"vm-contract",
"test",
Control::default(),
notify,
)
.unwrap();
let dir = run.dir.clone();
let script = Script {
path: path.into(),
name: "contract".into(),
target: Some(Target {
package: "test".into(),
binary: "test".into(),
cwd: ".".into(),
manifest: PathBuf::new(),
name: "test".into(),
}),
};
let mut run = run;
run.app_targets = Arc::new(script.target.iter().cloned().collect());
let (run, steps) = execute(
run,
config.clone(),
judge.judge.clone(),
&script,
true,
None,
);
assert!(!run.failed, "{} failed: {:?}", path, run.stages);
assert_eq!(steps.len(), count);
drop(run);
fs::remove_dir_all(dir).unwrap();
}
judge.finish();
}
#[test]
fn remote_log_envelope_preserves_errors_and_wm_launch_lines() {
let value=json::parse_depth(br#"{"n":7,"pool":"not a log line","l":["[E] shader failed","wm: launched terminal as client 2"]}"#,32).unwrap();
let mut lines = Vec::new();
collect_log(&value, &mut lines);
assert_eq!(
lines,
vec!["[E] shader failed", "wm: launched terminal as client 2"]
);
}
#[test]
fn startup_requires_owned_pid_and_type_text_rejects_unsupported_input() {
assert_eq!(
parse_startup(
"[makepad-remote] listening on 127.0.0.1:1234 pid=7 app=test",
7
),
Some(1234)
);
assert_eq!(
parse_startup("[makepad-remote] listening on 127.0.0.1:1234 pid=8", 7),
None
);
assert!(character_key('é').is_err());
assert_eq!(character_key('A').unwrap(), ("KeyA".into(), true));
}
}
/// What a script's `wait_log` text matches. Plain text is a substring of the
/// log. A `*` is a gap inside ONE line: the parts on either side must appear
/// on the same line in order, which is how a script waits for a line that
/// carries a number it cannot know (`terminal client * first frame`).
fn log_has(log: &str, text: &str) -> bool {
if !text.contains('*') {
return log.contains(text);
}
let parts: Vec<&str> = text.split('*').filter(|part| !part.is_empty()).collect();
log.lines().any(|line| {
let mut rest = line;
parts.iter().all(|part| match rest.find(part) {
Some(at) => {
rest = &rest[at + part.len()..];
true
}
None => false,
})
})
}
#[cfg(test)]
mod log_has_tests {
use super::log_has;
#[test]
fn a_star_is_a_gap_inside_one_line_and_never_across_lines() {
let log = "wm: launched terminal as client 3\nwm: terminal client 3 first frame in 812 ms (cold)\nwm: browser client 7 launched";
assert!(log_has(log, "launched terminal"));
assert!(log_has(log, "terminal client * first frame"));
assert!(!log_has(log, "browser client * first frame"), "the browser has not drawn yet");
assert!(!log_has(log, "launched terminal * first frame"), "the parts are on different lines");
assert!(!log_has(log, "first frame * terminal client"), "order matters");
}
}