From: Ian Jackson Date: Sun, 21 Feb 2021 20:44:26 +0000 (+0000) Subject: Move much code from wdriver to apitest X-Git-Tag: otter-0.4.0~396 X-Git-Url: https://www.chiark.greenend.org.uk/ucgi/~ianmdlvl/git?a=commitdiff_plain;h=24d4a0ccf61e0881bbac5bf8affa81206bee524e;p=otter.git Move much code from wdriver to apitest Signed-off-by: Ian Jackson --- diff --git a/apitest.rs b/apitest.rs index 94a5ee1e..f7712db3 100644 --- a/apitest.rs +++ b/apitest.rs @@ -8,3 +8,644 @@ pub mod imports { pub use humantime; } + +pub use imports::*; + +pub use anyhow::{anyhow, ensure, Context}; + +pub use boolinator::Boolinator; +pub use fehler::{throw, throws}; +pub use if_chain::if_chain; +pub use log::{debug, error, info, trace, warn}; +pub use log::{log, log_enabled}; +pub use nix::unistd::LinkatFlags; +pub use num_traits::NumCast; +pub use num_derive::FromPrimitive; +pub use parking_lot::{Mutex, MutexGuard}; +pub use regex::{Captures, Regex}; +pub use serde::{Serialize, Deserialize}; +pub use structopt::StructOpt; +pub use strum::{EnumIter, EnumProperty, IntoEnumIterator, IntoStaticStr}; +pub use void::Void; + +pub use std::env; +pub use std::fmt::{self, Debug}; +pub use std::collections::hash_map::HashMap; +pub use std::collections::btree_set::BTreeSet; +pub use std::convert::TryInto; +pub use std::fs; +pub use std::io::{self, BufRead, BufReader, ErrorKind, Write}; +pub use std::iter; +pub use std::mem; +pub use std::net::TcpStream; +pub use std::ops::Deref; +pub use std::os::unix::process::CommandExt; +pub use std::os::unix::fs::DirBuilderExt; +pub use std::os::linux::fs::MetadataExt; // todo why linux for st_mode?? +pub use std::path; +pub use std::process::{self, Command, Stdio}; +pub use std::thread::{self, sleep}; +pub use std::time::{self, Duration}; + +pub use otter_base::misc::default; + +pub use otter::ensure_eq; +pub use otter::commands::{MgmtCommand, MgmtResponse}; +pub use otter::commands::{MgmtGameInstruction, MgmtGameResponse}; +pub use otter::commands::{MgmtGameUpdateMode}; +pub use otter::gamestate::{self, Generation, PlayerId}; +pub use otter::global::InstanceName; +pub use otter::mgmtchannel::MgmtChannel; +pub use otter::slotmap_slot_idx::KeyDataExt; +pub use otter::spec::{Coord, GameSpec, Pos, PosC}; +pub use otter::toml_de; +pub use otter::ui::{AbbrevPresentationLayout, PresentationLayout}; +pub use otter::ui::player_num_dasharray; + +pub const MS: time::Duration = time::Duration::from_millis(1); +pub type AE = anyhow::Error; + +pub const URL: &str = "http://localhost:8000"; + +use otter::config::DAEMON_STARTUP_REPORT; + +pub const TABLE: &str = "server::dummy"; +pub const CONFIG: &str = "server-config.toml"; + +#[derive(Copy,Clone,Debug,Eq,PartialEq,Ord,PartialOrd)] +#[derive(FromPrimitive,EnumIter,IntoStaticStr,EnumProperty)] +#[strum(serialize_all = "snake_case")] +pub enum StaticUser { + #[strum(props(Token="kmqAKPwK4TfReFjMor8MJhdRPBcwIBpe"))] Alice, + #[strum(props(Token="ccg9kzoTh758QrVE1xMY7BQWB36dNJTx"))] Bob, +} + +pub trait AlwaysContext { + fn always_context(self, msg: &'static str) -> anyhow::Result; +} + +impl AlwaysContext for Result +where Self: anyhow::Context, +{ + fn always_context(self, msg: &'static str) -> anyhow::Result { + let x = self.context(msg); + if x.is_ok() { info!("completed {}.", msg) }; + x + } +} + +pub trait JustWarn { + fn just_warn(self) -> Option; +} + +impl JustWarn for Result { + fn just_warn(self) -> Option { + match self { + Ok(x) => Some(x), + Err(e) => { + warn!("{:#}", e); + None + }, + } + } +} + +#[derive(Debug,Clone)] +#[derive(StructOpt)] +pub struct Opts { + #[structopt(long="--as-if")] + pub as_if: Option, + + #[structopt(long="--no-bwrap")] + pub no_bwrap: bool, + + #[structopt(long="--tmp-dir", default_value="tmp")] + pub tmp_dir: String, + + #[structopt(long="--pause", default_value="0ms")] + pub pause: humantime::Duration, + + pub tests: Vec, +} + +#[derive(Clone,Debug)] +pub struct DirSubst { + pub tmp: String, + pub abstmp: String, + pub start_dir: String, + pub src: String, +} + +pub struct Instance(pub InstanceName); + +#[derive(Clone,Debug)] +pub struct Subst(HashMap); + +#[derive(Clone,Debug)] +pub struct ExtendedSubst(B, X); + +impl<'i, + T: AsRef + 'i, + U: AsRef + 'i, + L: IntoIterator> + From for Subst +{ + fn from(l: L) -> Subst { + let map = l.into_iter() + .map(|(k,v)| (k.as_ref().to_owned(), v.as_ref().to_owned())).collect(); + Subst(map) + } +} + +pub trait Substitutor { + fn get(&self, kw: &str) -> Option; + + fn also>(&self, xl: L) -> ExtendedSubst + where Self: Clone + Sized { + ExtendedSubst(self.clone(), xl.into()) + } + + #[throws(AE)] + fn subst>(&self, s: S) -> String + where Self: Sized { + #[throws(AE)] + fn inner(self_: &dyn Substitutor, s: &dyn AsRef) -> String { + let s = s.as_ref(); + let re = Regex::new(r"@(\w+)@").expect("bad re!"); + let mut errs = vec![]; + let out = re.replace_all(s, |caps: ®ex::Captures| { + let kw = caps.get(1).expect("$1 missing!").as_str(); + if kw == "" { return "".to_owned() } + let v = self_.get(kw); + v.unwrap_or_else(||{ + errs.push(kw.to_owned()); + "".to_owned() + }) + }); + if ! errs.is_empty() { + throw!(anyhow!("bad substitution(s) {:?} in {:?}", + &errs, s)); + } + out.into() + } + inner(self, &s)? + } + + #[throws(AE)] + fn ss(&self, s: &str) -> Vec + where Self: Sized { + self.subst(s)? + .trim() + .split(' ') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + } +} + +impl Substitutor for Subst { + fn get(&self, kw: &str) -> Option { + self.0.get(kw).map(String::clone) + } +} + +impl Substitutor for ExtendedSubst { + fn get(&self, kw: &str) -> Option { + self.1.get(kw).or_else(|| self.0.get(kw)) + } +} + +impl Substitutor for DirSubst { + fn get(&self, kw: &str) -> Option { + Some(match kw { + "url" => URL.to_owned(), + "src" => self.src.clone(), + "build" => self.start_dir.clone(), + "abstmp" => self.abstmp.clone(), + "target" => format!("{}/target", &self.start_dir), + "specs" => self.specs_dir(), + _ => return None, + }) + } +} + +pub mod cleanup_notify { + use super::imports::*; + use super::AE; + + use anyhow::Context; + use fehler::{throw, throws}; + use libc::_exit; + use nix::errno::Errno::*; + use nix::{unistd::*, fcntl::OFlag}; + use nix::sys::signal::*; + use nix::Error::Sys; + use void::Void; + use std::io; + use std::os::unix::io::RawFd; + use std::panic::catch_unwind; + use std::process::Command; + + pub struct Handle(RawFd); + + #[throws(io::Error)] + fn mkpipe() -> (RawFd,RawFd) { + pipe2(OFlag::O_CLOEXEC).map_err(nix2io)? + } + + #[throws(io::Error)] + fn read_await(fd: RawFd) { + loop { + let mut buf = [0u8; 1]; + match nix::unistd::read(fd, &mut buf) { + Ok(0) => break, + Ok(_) => throw!(io::Error::from_raw_os_error(libc::EINVAL)), + Err(Sys(EINTR)) => continue, + _ => throw!(io::Error::last_os_error()), + } + } + } + + fn nix2io(_n: nix::Error) -> io::Error { + io::Error::last_os_error() + } + + impl Handle { + #[throws(AE)] + pub fn new() -> Self { + let (reading_end, _writing_end) = mkpipe() + .context("create cleanup notify pipe")?; + // we leak the writing end, keeping it open only in this process + Handle(reading_end) + } + + #[throws(AE)] + pub fn arm_hook(&self, cmd: &mut Command) { unsafe { + use std::os::unix::process::CommandExt; + + let notify_writing_end = self.0; + let all_signals = nix::sys::signal::SigSet::all(); + + cmd.pre_exec(move || -> Result<(), io::Error> { + let semidaemon = nix::unistd::getpid(); + let (reading_end, writing_end) = mkpipe()?; + + match fork().map_err(nix2io)? { + ForkResult::Child => { + let _ = catch_unwind(move || -> Void { + let _ = sigprocmask( + SigmaskHow::SIG_BLOCK, + Some(&all_signals), + None + ); + + let _ = close(writing_end); + let _ = nix::unistd::dup2(2, 1); + + for fd in 2.. { + if fd == notify_writing_end { continue } + let r = close(fd); + if fd >= writing_end && matches!(r, Err(Sys(EBADF))) { + break; + } + } + let _ = read_await(notify_writing_end); + let _ = kill(semidaemon, SIGTERM); + let _ = kill(semidaemon, SIGCONT); + _exit(0); + }); + let _ = raise(SIGABRT); + _exit(127); + }, + ForkResult::Parent{..} => { + // parent + close(writing_end).map_err(nix2io)?; + read_await(reading_end)?; + }, + }; + + Ok(()) + }); + } } + } +} + +#[throws(AE)] +pub fn reinvoke_via_bwrap(_opts: &Opts, current_exe: &str) -> Void { + debug!("running bwrap"); + + let mut bcmd = Command::new("bwrap"); + bcmd + .args("--unshare-net \ + --dev-bind / / \ + --tmpfs /tmp \ + --die-with-parent".split(" ")) + .arg(current_exe) + .arg("--no-bwrap") + .args(env::args_os().skip(1)); + + std::io::stdout().flush().context("flush stdout")?; + let e: AE = bcmd.exec().into(); + throw!(e.context("exec bwrap")); +} + +#[throws(AE)] +pub fn prepare_tmpdir<'x>(opts: &'x Opts, mut current_exe: &'x str) -> DirSubst { + #[throws(AE)] + fn getcwd() -> String { + env::current_dir() + .context("getcwd")? + .to_str() + .ok_or_else(|| anyhow!("path is not UTF-8"))? + .to_owned() + } + + if let Some(as_if) = &opts.as_if { + current_exe = as_if; + } + + let start_dir = getcwd() + .context("canonicalise our invocation directory (getcwd)")?; + + (||{ + match fs::metadata(&opts.tmp_dir) { + Ok(m) => { + if !m.is_dir() { + throw!(anyhow!("existing object is not a directory")); + } + if (m.st_mode() & 0o01002) != 0 { + throw!(anyhow!( + "existing directory mode {:#o} is sticky or world-writeable. \ + We use predictable pathnames so that would be a tmp race", + m.st_mode() + )); + } + } + Err(e) if e.kind() == ErrorKind::NotFound => { + fs::create_dir(&opts.tmp_dir) + .context("create")?; + } + Err(e) => { + let e: AE = e.into(); + throw!(e.context("stat existing directory")) + } + } + + env::set_current_dir(&opts.tmp_dir) + .context("chdir into it")?; + + Ok::<_,AE>(()) + })() + .with_context(|| opts.tmp_dir.to_owned()) + .context("prepare/create tmp-dir")?; + + let leaf = current_exe.rsplitn(2, '/').next().unwrap(); + let our_tmpdir = format!("{}/{}", &opts.tmp_dir, &leaf); + (||{ + match fs::remove_dir_all(&leaf) { + Ok(()) => {}, + Err(e) if e.kind() == ErrorKind::NotFound => {}, + Err(e) => throw!(AE::from(e).context("remove previous directory")), + }; + + fs::DirBuilder::new().create(&leaf) + .context("create fresh subdirectory")?; + + env::set_current_dir(&leaf) + .context("chdir into it")?; + + Ok::<_,AE>(()) + })() + .with_context(|| our_tmpdir.to_owned()) + .context("prepare/create our tmp subdir")?; + + let abstmp = + getcwd().context("canonicalise our tmp subdir (getcwd)")?; + + env::set_var("HOME", &abstmp); + env::set_var("TMPDIR", &abstmp); + for v in "http_proxy https_proxy XAUTHORITY CDPATH \ + SSH_AGENT_PID SSH_AUTH_SOCK WINDOWID WWW_HOME".split(' ') + { + env::remove_var(v); + } + + let manifest_var = "CARGO_MANIFEST_DIR"; + let src: String = (|| Ok::<_,AE>(match env::var(manifest_var) { + Ok(dir) => dir.into(), + Err(env::VarError::NotPresent) => start_dir.clone(), + e@ Err(_) => throw!(e.context(manifest_var).err().unwrap()), + }))() + .context("find source code")?; + + DirSubst { + tmp: our_tmpdir, + abstmp, + src, + start_dir, + } +} + +#[throws(AE)] +pub fn fork_something_which_prints(mut cmd: Command, + cln: &cleanup_notify::Handle, + what: &str) + -> (String, process::Child) +{ + (||{ + cmd.stdout(Stdio::piped()); + cln.arm_hook(&mut cmd)?; + let mut child = cmd.spawn().context("spawn")?; + let mut report = BufReader::new(child.stdout.take().unwrap()) + .lines().fuse(); + + let l = report.next(); + + let s = child.try_wait().context("check on spawned child")?; + if let Some(e) = s { + throw!(anyhow!("failed to start: wait status = {}", &e)); + } + + let l = match l { + Some(Ok(l)) => l, + None => throw!(anyhow!("EOF (but it's still running?")), + Some(Err(e)) => throw!(AE::from(e).context("failed to read")), + }; + + let what = what.to_owned(); + thread::spawn(move|| (||{ + for l in report { + let l: Result = l; + let l = l.context("reading further output")?; + const MAXLEN: usize = 300; + if l.len() <= MAXLEN { + println!("{} {}", what, l); + } else { + println!("{} {}...", what, &l[..MAXLEN-3]); + } + } + Ok::<_,AE>(()) + })().context(what).just_warn() + ); + + Ok::<_,AE>((l, child)) + })().with_context(|| what.to_owned())? +} + +#[throws(AE)] +pub fn prepare_gameserver(cln: &cleanup_notify::Handle, ds: &DirSubst) + -> (MgmtChannel, process::Child) { + let subst = ds.also(&[ + ("command_socket", "command.socket"), + ]); + let config = subst.subst(r##" +change_directory = "@abstmp@" +base_dir = "@build@" +public_url = "@url@" + +save_dir = "." +command_socket = "@command_socket@" +template_dir = "@src@/templates" +nwtemplate_dir = "@src@/nwtemplates" +bundled_sources = "@target@/bundled-sources" +wasm_dir = "@target@/packed-wasm" +shapelibs = [ "@src@/library/*.toml" ] + +debug_js_inject_file = "@src@/templates/log-save.js" +check_bundled_sources = false # For testing only! see LICENCE! + +[log] +global_level = 'debug' + +[log.modules] +rocket = 'error' +_ = "error" # rocket +# ^ comment these two out to see Tera errors, *sigh* + +'hyper::server' = 'info' +"game::debugreader" = 'info' +"game::updates" = 'trace' +"##)?; + + fs::write(CONFIG, &config) + .context(CONFIG).context("create server config")?; + + let server_exe = ds.subst("@target@/debug/daemon-otter")?; + let mut cmd = Command::new(&server_exe); + cmd + .arg("--report-startup") + .arg(CONFIG); + + let child = (||{ + let (l,child) = fork_something_which_prints(cmd, cln, &server_exe)?; + if l != DAEMON_STARTUP_REPORT { + throw!(anyhow!("otter-daemon startup report {:?}, expected {:?}", + &l, DAEMON_STARTUP_REPORT)); + } + Ok::<_,AE>(child) + })() + .context("game server")?; + + let mut mgmt_conn = MgmtChannel::connect( + &subst.subst("@command_socket@")? + )?; + + mgmt_conn.cmd(&MgmtCommand::SetSuperuser(true))?; + mgmt_conn.cmd(&MgmtCommand::SelectAccount("server:".parse()?))?; + + (mgmt_conn, child) +} + +impl DirSubst { + pub fn specs_dir(&self) -> String { + format!("{}/specs" , &self.src) + } + + #[throws(AE)] + pub fn otter>(&self, xargs: &[S]) { + let ds = self; + let exe = ds.subst("@target@/debug/otter")?; + let mut args: Vec<&str> = vec![]; + args.extend(&["--config", CONFIG]); + args.extend(xargs.iter().map(AsRef::as_ref)); + let dbg = format!("running {} {:?}", &exe, &args); + debug!("{}", &dbg); + (||{ + let mut cmd = Command::new(&exe); + cmd.args(&args); + let st = cmd + .spawn().context("spawn")? + .wait().context("wait")?; + if !st.success() { + throw!(anyhow!("wait status {}", &st)); + } + Ok::<_,AE>(()) + })() + .context(dbg) + .context("run otter client")?; + } + + #[throws(AE)] + pub fn game_spec_path(&self) -> String { + self.subst("@specs@/demo.game.toml")? + } + + #[throws(AE)] + pub fn game_spec_data(&self) -> GameSpec { + let path = self.game_spec_path()?; + (||{ + let data = fs::read(&path).context("read")?; + let data = std::str::from_utf8(&data).context("convert from UTF-8")?; + let data: toml::Value = data.parse().context("parse TOM")?; + dbg!(&data); + let data = toml_de::from_value(&data).context("interperet TOML")?; + Ok::<_,AE>(data) + })() + .context(path) + .context("game spec")? + } +} + +#[throws(AE)] +pub fn prepare_game(ds: &DirSubst, table: &str) -> InstanceName { + let game_spec = ds.game_spec_path()?; + let subst = ds.also(&[ + ("table", table), + ("game_spec", &game_spec), + ]); + ds.otter(&subst.ss( + "--account server: \ + reset \ + --reset-table @specs@/test.table.toml \ + @table@ @game_spec@ \ + ")?).context("reset table")?; + + let instance: InstanceName = table.parse() + .with_context(|| table.to_owned()) + .context("parse table name")?; + + instance +} + +#[derive(Debug)] +pub struct Window { + pub name: String, + pub instance: InstanceName, +} + +impl Window { + pub fn table(&self) -> String { self.instance.to_string() } +} + +#[macro_export] +macro_rules! test { + ($c:expr, $tname:expr, $s:stmt) => { + if $c.su.want_test($tname) { + debug!("-------------------- {} starting --------------------", $tname); + $s + info!("-------------------- {} completed --------------------", $tname); + } else { + trace!("- - - {} skipped - - -", $tname); + } + } +} diff --git a/wdriver.rs b/wdriver.rs index f3495bcc..8a29821c 100644 --- a/wdriver.rs +++ b/wdriver.rs @@ -2,137 +2,37 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // There is NO WARRANTY. -pub use otter_api_tests::imports::*; - -pub use anyhow::{anyhow, ensure, Context}; -pub use boolinator::Boolinator; -pub use fehler::{throw, throws}; -pub use if_chain::if_chain; -pub use log::{debug, error, info, trace, warn}; -pub use log::{log, log_enabled}; -pub use nix::unistd::LinkatFlags; -pub use num_traits::NumCast; -pub use num_derive::FromPrimitive; -pub use parking_lot::{Mutex, MutexGuard}; -pub use regex::{Captures, Regex}; -pub use serde::{Serialize, Deserialize}; -pub use structopt::StructOpt; -pub use strum::{EnumIter, EnumProperty, IntoEnumIterator, IntoStaticStr}; +pub use otter_api_tests::*; +pub use otter_api_tests as apitest; + pub use thirtyfour_sync as t4; -pub use void::Void; pub use t4::WebDriverCommands; pub use t4::By; -pub use std::env; -pub use std::fmt::{self, Debug}; -pub use std::collections::hash_map::HashMap; -pub use std::collections::btree_set::BTreeSet; -pub use std::convert::TryInto; -pub use std::fs; -pub use std::io::{self, BufRead, BufReader, ErrorKind, Write}; -pub use std::iter; -pub use std::mem; -pub use std::net::TcpStream; -pub use std::ops::Deref; -pub use std::os::unix::process::CommandExt; -pub use std::os::unix::fs::DirBuilderExt; -pub use std::os::linux::fs::MetadataExt; // todo why linux for st_mode?? -pub use std::path; -pub use std::process::{self, Command, Stdio}; -pub use std::thread::{self, sleep}; -pub use std::time::{self, Duration}; - -pub use otter_base::misc::default; - -pub use otter::ensure_eq; -pub use otter::commands::{MgmtCommand, MgmtResponse}; -pub use otter::commands::{MgmtGameInstruction, MgmtGameResponse}; -pub use otter::commands::{MgmtGameUpdateMode}; -pub use otter::gamestate::{self, Generation, PlayerId}; -pub use otter::global::InstanceName; -pub use otter::mgmtchannel::MgmtChannel; -pub use otter::slotmap_slot_idx::KeyDataExt; -pub use otter::spec::{Coord, GameSpec, Pos, PosC}; -pub use otter::toml_de; -pub use otter::ui::{AbbrevPresentationLayout, PresentationLayout}; -pub use otter::ui::player_num_dasharray; - pub type T4d = t4::WebDriver; pub type WDE = t4::error::WebDriverError; -pub const MS: time::Duration = time::Duration::from_millis(1); -pub type AE = anyhow::Error; - -pub const URL: &str = "http://localhost:8000"; - use t4::Capabilities; -use once_cell::sync::OnceCell; -use otter::config::DAEMON_STARTUP_REPORT; - -const TABLE: &str = "server::dummy"; -const CONFIG: &str = "server-config.toml"; -#[derive(Copy,Clone,Debug,Eq,PartialEq,Ord,PartialOrd)] -#[derive(FromPrimitive,EnumIter,IntoStaticStr,EnumProperty)] -#[strum(serialize_all = "snake_case")] -pub enum StaticUser { - #[strum(props(Token="kmqAKPwK4TfReFjMor8MJhdRPBcwIBpe"))] Alice, - #[strum(props(Token="ccg9kzoTh758QrVE1xMY7BQWB36dNJTx"))] Bob, -} - -pub trait AlwaysContext { - fn always_context(self, msg: &'static str) -> anyhow::Result; -} - -impl AlwaysContext for Result -where Self: anyhow::Context, -{ - fn always_context(self, msg: &'static str) -> anyhow::Result { - let x = self.context(msg); - if x.is_ok() { info!("completed {}.", msg) }; - x - } -} - -pub trait JustWarn { - fn just_warn(self) -> Option; -} - -impl JustWarn for Result { - fn just_warn(self) -> Option { - match self { - Ok(x) => Some(x), - Err(e) => { - warn!("{:#}", e); - None - }, - } - } -} +use once_cell::sync::OnceCell; #[derive(Debug,Clone)] #[derive(StructOpt)] pub struct Opts { - #[structopt(long="--no-bwrap")] - no_bwrap: bool, - - #[structopt(long="--tmp-dir", default_value="tmp")] - tmp_dir: String, - - #[structopt(long="--as-if")] - as_if: Option, - - #[structopt(long="--pause", default_value="0ms")] - pause: humantime::Duration, + #[structopt(flatten)] + at: apitest::Opts, #[structopt(long="--layout", default_value="Portrait")] layout: PresentationLayout, #[structopt(long="--geckodriver-args", default_value="")] geckodriver_args: String, +} - tests: Vec, +impl Deref for Opts { + type Target = apitest::Opts; + fn deref(&self) -> &Self::Target { &self.at } } #[derive(Debug)] @@ -155,369 +55,6 @@ pub struct Setup { windows_squirreled: Vec, // see Drop impl } -#[derive(Clone,Debug)] -pub struct DirSubst { - pub tmp: String, - pub abstmp: String, - pub start_dir: String, - pub src: String, -} - -pub struct Instance(InstanceName); - -#[derive(Clone,Debug)] -pub struct Subst(HashMap); - -#[derive(Clone,Debug)] -pub struct ExtendedSubst(B, X); - -impl<'i, - T: AsRef + 'i, - U: AsRef + 'i, - L: IntoIterator> - From for Subst -{ - fn from(l: L) -> Subst { - let map = l.into_iter() - .map(|(k,v)| (k.as_ref().to_owned(), v.as_ref().to_owned())).collect(); - Subst(map) - } -} - -pub trait Substitutor { - fn get(&self, kw: &str) -> Option; - - fn also>(&self, xl: L) -> ExtendedSubst - where Self: Clone + Sized { - ExtendedSubst(self.clone(), xl.into()) - } - - #[throws(AE)] - fn subst>(&self, s: S) -> String - where Self: Sized { - #[throws(AE)] - fn inner(self_: &dyn Substitutor, s: &dyn AsRef) -> String { - let s = s.as_ref(); - let re = Regex::new(r"@(\w+)@").expect("bad re!"); - let mut errs = vec![]; - let out = re.replace_all(s, |caps: ®ex::Captures| { - let kw = caps.get(1).expect("$1 missing!").as_str(); - if kw == "" { return "".to_owned() } - let v = self_.get(kw); - v.unwrap_or_else(||{ - errs.push(kw.to_owned()); - "".to_owned() - }) - }); - if ! errs.is_empty() { - throw!(anyhow!("bad substitution(s) {:?} in {:?}", - &errs, s)); - } - out.into() - } - inner(self, &s)? - } - - #[throws(AE)] - fn ss(&self, s: &str) -> Vec - where Self: Sized { - self.subst(s)? - .trim() - .split(' ') - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect() - } -} - -impl Substitutor for Subst { - fn get(&self, kw: &str) -> Option { - self.0.get(kw).map(String::clone) - } -} - -impl Substitutor for ExtendedSubst { - fn get(&self, kw: &str) -> Option { - self.1.get(kw).or_else(|| self.0.get(kw)) - } -} - -impl Substitutor for DirSubst { - fn get(&self, kw: &str) -> Option { - Some(match kw { - "url" => URL.to_owned(), - "src" => self.src.clone(), - "build" => self.start_dir.clone(), - "abstmp" => self.abstmp.clone(), - "target" => format!("{}/target", &self.start_dir), - "specs" => self.specs_dir(), - _ => return None, - }) - } -} - -mod cleanup_notify { - use otter_api_tests::imports::*; - use anyhow::Context; - use fehler::{throw, throws}; - use libc::_exit; - use nix::errno::Errno::*; - use nix::{unistd::*, fcntl::OFlag}; - use nix::sys::signal::*; - use nix::Error::Sys; - use void::Void; - use std::io; - use std::os::unix::io::RawFd; - use std::panic::catch_unwind; - use std::process::Command; - type AE = anyhow::Error; - - pub struct Handle(RawFd); - - #[throws(io::Error)] - fn mkpipe() -> (RawFd,RawFd) { - pipe2(OFlag::O_CLOEXEC).map_err(nix2io)? - } - - #[throws(io::Error)] - fn read_await(fd: RawFd) { - loop { - let mut buf = [0u8; 1]; - match nix::unistd::read(fd, &mut buf) { - Ok(0) => break, - Ok(_) => throw!(io::Error::from_raw_os_error(libc::EINVAL)), - Err(Sys(EINTR)) => continue, - _ => throw!(io::Error::last_os_error()), - } - } - } - - fn nix2io(_n: nix::Error) -> io::Error { - io::Error::last_os_error() - } - - impl Handle { - #[throws(AE)] - pub fn new() -> Self { - let (reading_end, _writing_end) = mkpipe() - .context("create cleanup notify pipe")?; - // we leak the writing end, keeping it open only in this process - Handle(reading_end) - } - - #[throws(AE)] - pub fn arm_hook(&self, cmd: &mut Command) { unsafe { - use std::os::unix::process::CommandExt; - - let notify_writing_end = self.0; - let all_signals = nix::sys::signal::SigSet::all(); - - cmd.pre_exec(move || -> Result<(), io::Error> { - let semidaemon = nix::unistd::getpid(); - let (reading_end, writing_end) = mkpipe()?; - - match fork().map_err(nix2io)? { - ForkResult::Child => { - let _ = catch_unwind(move || -> Void { - let _ = sigprocmask( - SigmaskHow::SIG_BLOCK, - Some(&all_signals), - None - ); - - let _ = close(writing_end); - let _ = nix::unistd::dup2(2, 1); - - for fd in 2.. { - if fd == notify_writing_end { continue } - let r = close(fd); - if fd >= writing_end && matches!(r, Err(Sys(EBADF))) { - break; - } - } - let _ = read_await(notify_writing_end); - let _ = kill(semidaemon, SIGTERM); - let _ = kill(semidaemon, SIGCONT); - _exit(0); - }); - let _ = raise(SIGABRT); - _exit(127); - }, - ForkResult::Parent{..} => { - // parent - close(writing_end).map_err(nix2io)?; - read_await(reading_end)?; - }, - }; - - Ok(()) - }); - } } - } -} - -#[throws(AE)] -fn reinvoke_via_bwrap(_opts: &Opts, current_exe: &str) -> Void { - debug!("running bwrap"); - - let mut bcmd = Command::new("bwrap"); - bcmd - .args("--unshare-net \ - --dev-bind / / \ - --tmpfs /tmp \ - --die-with-parent".split(" ")) - .arg(current_exe) - .arg("--no-bwrap") - .args(env::args_os().skip(1)); - - std::io::stdout().flush().context("flush stdout")?; - let e: AE = bcmd.exec().into(); - throw!(e.context("exec bwrap")); -} - -#[throws(AE)] -fn prepare_tmpdir<'x>(opts: &'x Opts, mut current_exe: &'x str) -> DirSubst { - #[throws(AE)] - fn getcwd() -> String { - env::current_dir() - .context("getcwd")? - .to_str() - .ok_or_else(|| anyhow!("path is not UTF-8"))? - .to_owned() - } - - if let Some(as_if) = &opts.as_if { - current_exe = as_if; - } - - let start_dir = getcwd() - .context("canonicalise our invocation directory (getcwd)")?; - - (||{ - match fs::metadata(&opts.tmp_dir) { - Ok(m) => { - if !m.is_dir() { - throw!(anyhow!("existing object is not a directory")); - } - if (m.st_mode() & 0o01002) != 0 { - throw!(anyhow!( - "existing directory mode {:#o} is sticky or world-writeable. \ - We use predictable pathnames so that would be a tmp race", - m.st_mode() - )); - } - } - Err(e) if e.kind() == ErrorKind::NotFound => { - fs::create_dir(&opts.tmp_dir) - .context("create")?; - } - Err(e) => { - let e: AE = e.into(); - throw!(e.context("stat existing directory")) - } - } - - env::set_current_dir(&opts.tmp_dir) - .context("chdir into it")?; - - Ok::<_,AE>(()) - })() - .with_context(|| opts.tmp_dir.to_owned()) - .context("prepare/create tmp-dir")?; - - let leaf = current_exe.rsplitn(2, '/').next().unwrap(); - let our_tmpdir = format!("{}/{}", &opts.tmp_dir, &leaf); - (||{ - match fs::remove_dir_all(&leaf) { - Ok(()) => {}, - Err(e) if e.kind() == ErrorKind::NotFound => {}, - Err(e) => throw!(AE::from(e).context("remove previous directory")), - }; - - fs::DirBuilder::new().create(&leaf) - .context("create fresh subdirectory")?; - - env::set_current_dir(&leaf) - .context("chdir into it")?; - - Ok::<_,AE>(()) - })() - .with_context(|| our_tmpdir.to_owned()) - .context("prepare/create our tmp subdir")?; - - let abstmp = - getcwd().context("canonicalise our tmp subdir (getcwd)")?; - - env::set_var("HOME", &abstmp); - env::set_var("TMPDIR", &abstmp); - for v in "http_proxy https_proxy XAUTHORITY CDPATH \ - SSH_AGENT_PID SSH_AUTH_SOCK WINDOWID WWW_HOME".split(' ') - { - env::remove_var(v); - } - - let manifest_var = "CARGO_MANIFEST_DIR"; - let src: String = (|| Ok::<_,AE>(match env::var(manifest_var) { - Ok(dir) => dir.into(), - Err(env::VarError::NotPresent) => start_dir.clone(), - e@ Err(_) => throw!(e.context(manifest_var).err().unwrap()), - }))() - .context("find source code")?; - - DirSubst { - tmp: our_tmpdir, - abstmp, - src, - start_dir, - } -} - -#[throws(AE)] -fn fork_something_which_prints(mut cmd: Command, - cln: &cleanup_notify::Handle, - what: &str) - -> (String, process::Child) -{ - (||{ - cmd.stdout(Stdio::piped()); - cln.arm_hook(&mut cmd)?; - let mut child = cmd.spawn().context("spawn")?; - let mut report = BufReader::new(child.stdout.take().unwrap()) - .lines().fuse(); - - let l = report.next(); - - let s = child.try_wait().context("check on spawned child")?; - if let Some(e) = s { - throw!(anyhow!("failed to start: wait status = {}", &e)); - } - - let l = match l { - Some(Ok(l)) => l, - None => throw!(anyhow!("EOF (but it's still running?")), - Some(Err(e)) => throw!(AE::from(e).context("failed to read")), - }; - - let what = what.to_owned(); - thread::spawn(move|| (||{ - for l in report { - let l: Result = l; - let l = l.context("reading further output")?; - const MAXLEN: usize = 300; - if l.len() <= MAXLEN { - println!("{} {}", what, l); - } else { - println!("{} {}...", what, &l[..MAXLEN-3]); - } - } - Ok::<_,AE>(()) - })().context(what).just_warn() - ); - - Ok::<_,AE>((l, child)) - })().with_context(|| what.to_owned())? -} - #[throws(AE)] fn prepare_xserver(cln: &cleanup_notify::Handle, ds: &DirSubst) { const DISPLAY: u16 = 12; @@ -559,141 +96,6 @@ fn prepare_xserver(cln: &cleanup_notify::Handle, ds: &DirSubst) { Box::leak(Box::new(xconn)); } -#[throws(AE)] -fn prepare_gameserver(cln: &cleanup_notify::Handle, ds: &DirSubst) - -> (MgmtChannel, process::Child) { - let subst = ds.also(&[ - ("command_socket", "command.socket"), - ]); - let config = subst.subst(r##" -change_directory = "@abstmp@" -base_dir = "@build@" -public_url = "@url@" - -save_dir = "." -command_socket = "@command_socket@" -template_dir = "@src@/templates" -nwtemplate_dir = "@src@/nwtemplates" -bundled_sources = "@target@/bundled-sources" -wasm_dir = "@target@/packed-wasm" -shapelibs = [ "@src@/library/*.toml" ] - -debug_js_inject_file = "@src@/templates/log-save.js" -check_bundled_sources = false # For testing only! see LICENCE! - -[log] -global_level = 'debug' - -[log.modules] -rocket = 'error' -_ = "error" # rocket -# ^ comment these two out to see Tera errors, *sigh* - -'hyper::server' = 'info' -"game::debugreader" = 'info' -"game::updates" = 'trace' -"##)?; - - fs::write(CONFIG, &config) - .context(CONFIG).context("create server config")?; - - let server_exe = ds.subst("@target@/debug/daemon-otter")?; - let mut cmd = Command::new(&server_exe); - cmd - .arg("--report-startup") - .arg(CONFIG); - - let child = (||{ - let (l,child) = fork_something_which_prints(cmd, cln, &server_exe)?; - if l != DAEMON_STARTUP_REPORT { - throw!(anyhow!("otter-daemon startup report {:?}, expected {:?}", - &l, DAEMON_STARTUP_REPORT)); - } - Ok::<_,AE>(child) - })() - .context("game server")?; - - let mut mgmt_conn = MgmtChannel::connect( - &subst.subst("@command_socket@")? - )?; - - mgmt_conn.cmd(&MgmtCommand::SetSuperuser(true))?; - mgmt_conn.cmd(&MgmtCommand::SelectAccount("server:".parse()?))?; - - (mgmt_conn, child) -} - -impl DirSubst { - pub fn specs_dir(&self) -> String { - format!("{}/specs" , &self.src) - } - - #[throws(AE)] - pub fn otter>(&self, xargs: &[S]) { - let ds = self; - let exe = ds.subst("@target@/debug/otter")?; - let mut args: Vec<&str> = vec![]; - args.extend(&["--config", CONFIG]); - args.extend(xargs.iter().map(AsRef::as_ref)); - let dbg = format!("running {} {:?}", &exe, &args); - debug!("{}", &dbg); - (||{ - let mut cmd = Command::new(&exe); - cmd.args(&args); - let st = cmd - .spawn().context("spawn")? - .wait().context("wait")?; - if !st.success() { - throw!(anyhow!("wait status {}", &st)); - } - Ok::<_,AE>(()) - })() - .context(dbg) - .context("run otter client")?; - } - - #[throws(AE)] - pub fn game_spec_path(&self) -> String { - self.subst("@specs@/demo.game.toml")? - } - - #[throws(AE)] - pub fn game_spec_data(&self) -> GameSpec { - let path = self.game_spec_path()?; - (||{ - let data = fs::read(&path).context("read")?; - let data = std::str::from_utf8(&data).context("convert from UTF-8")?; - let data: toml::Value = data.parse().context("parse TOM")?; - dbg!(&data); - let data = toml_de::from_value(&data).context("interperet TOML")?; - Ok::<_,AE>(data) - })() - .context(path) - .context("game spec")? - } -} - -#[throws(AE)] -pub fn prepare_game(ds: &DirSubst, table: &str) -> InstanceName { - let game_spec = ds.game_spec_path()?; - let subst = ds.also(&[ - ("table", table), - ("game_spec", &game_spec), - ]); - ds.otter(&subst.ss( - "--account server: \ - reset \ - --reset-table @specs@/test.table.toml \ - @table@ @game_spec@ \ - ")?).context("reset table")?; - - let instance: InstanceName = table.parse() - .with_context(|| table.to_owned()) - .context("parse table name")?; - - instance -} - #[throws(AE)] fn prepare_geckodriver(opts: &Opts, cln: &cleanup_notify::Handle) { const EXPECTED: &str = "Listening on 127.0.0.1:4444"; @@ -773,16 +175,6 @@ fn fetch_log(driver: &T4d, name: &str) { .context("fetch JS log messages")?; } -#[derive(Debug)] -pub struct Window { - name: String, - instance: InstanceName, -} - -impl Window { - pub fn table(&self) -> String { self.instance.to_string() } -} - type ScreenCTM = ndarray::Array2::; pub struct WindowGuard<'g> { @@ -955,19 +347,6 @@ fn check_window_name_sanity(name: &str) -> &str { name } -#[macro_export] -macro_rules! test { - ($c:expr, $tname:expr, $s:stmt) => { - if $c.su.want_test($tname) { - debug!("-------------------- {} starting --------------------", $tname); - $s - info!("-------------------- {} completed --------------------", $tname); - } else { - trace!("- - - {} skipped - - -", $tname); - } - } -} - #[macro_export] macro_rules! ctx_with_setup { {$ctx:ident} => {