chiark / gitweb /
Move much code from wdriver to apitest
authorIan Jackson <ijackson@chiark.greenend.org.uk>
Sun, 21 Feb 2021 20:44:26 +0000 (20:44 +0000)
committerIan Jackson <ijackson@chiark.greenend.org.uk>
Sun, 21 Feb 2021 20:44:26 +0000 (20:44 +0000)
Signed-off-by: Ian Jackson <ijackson@chiark.greenend.org.uk>
apitest.rs
wdriver.rs

index 94a5ee1e610fca6e03b700b9c7346159fe9a383c..f7712db339436b20e17ff2ea0f290d5d4adae29f 100644 (file)
@@ -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<T,E> {
+  fn always_context(self, msg: &'static str) -> anyhow::Result<T>;
+}
+
+impl<T,E> AlwaysContext<T,E> for Result<T,E>
+where Self: anyhow::Context<T,E>,
+{
+  fn always_context(self, msg: &'static str) -> anyhow::Result<T> {
+    let x = self.context(msg);
+    if x.is_ok() { info!("completed {}.", msg) };
+    x
+  }
+}
+
+pub trait JustWarn<T> {
+  fn just_warn(self) -> Option<T>;
+}
+
+impl<T> JustWarn<T> for Result<T,AE> {
+  fn just_warn(self) -> Option<T> {
+    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<String>,
+
+  #[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<String>,
+}
+
+#[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<String,String>);
+
+#[derive(Clone,Debug)]
+pub struct ExtendedSubst<B: Substitutor, X: Substitutor>(B, X);
+
+impl<'i,
+     T: AsRef<str> + 'i,
+     U: AsRef<str> + 'i,
+     L: IntoIterator<Item=&'i (T, U)>>
+  From<L> 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<String>;
+
+  fn also<L: Into<Subst>>(&self, xl: L) -> ExtendedSubst<Self, Subst>
+  where Self: Clone + Sized {
+    ExtendedSubst(self.clone(), xl.into())
+  }
+
+  #[throws(AE)]
+  fn subst<S: AsRef<str>>(&self, s: S) -> String 
+  where Self: Sized {
+    #[throws(AE)]
+    fn inner(self_: &dyn Substitutor, s: &dyn AsRef<str>) -> 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: &regex::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<String> 
+  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<String> {
+    self.0.get(kw).map(String::clone)
+  }
+}
+
+impl<B:Substitutor, X:Substitutor> Substitutor for ExtendedSubst<B, X> {
+  fn get(&self, kw: &str) -> Option<String> {
+    self.1.get(kw).or_else(|| self.0.get(kw))
+  }
+}
+
+impl Substitutor for DirSubst {
+  fn get(&self, kw: &str) -> Option<String> {
+    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<String, io::Error> = 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<S:AsRef<str>>(&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);
+    }
+  }
+}
index f3495bcc843941581ff862e495fcd6986cdbf41b..8a29821c1ea457de138250a62ba8fb6fa3ad2425 100644 (file)
 // 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<T,E> {
-  fn always_context(self, msg: &'static str) -> anyhow::Result<T>;
-}
-
-impl<T,E> AlwaysContext<T,E> for Result<T,E>
-where Self: anyhow::Context<T,E>,
-{
-  fn always_context(self, msg: &'static str) -> anyhow::Result<T> {
-    let x = self.context(msg);
-    if x.is_ok() { info!("completed {}.", msg) };
-    x
-  }
-}
-
-pub trait JustWarn<T> {
-  fn just_warn(self) -> Option<T>;
-}
-
-impl<T> JustWarn<T> for Result<T,AE> {
-  fn just_warn(self) -> Option<T> {
-    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<String>,
-
-  #[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<String>,
+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<String>, // 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<String,String>);
-
-#[derive(Clone,Debug)]
-pub struct ExtendedSubst<B: Substitutor, X: Substitutor>(B, X);
-
-impl<'i,
-     T: AsRef<str> + 'i,
-     U: AsRef<str> + 'i,
-     L: IntoIterator<Item=&'i (T, U)>>
-  From<L> 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<String>;
-
-  fn also<L: Into<Subst>>(&self, xl: L) -> ExtendedSubst<Self, Subst>
-  where Self: Clone + Sized {
-    ExtendedSubst(self.clone(), xl.into())
-  }
-
-  #[throws(AE)]
-  fn subst<S: AsRef<str>>(&self, s: S) -> String 
-  where Self: Sized {
-    #[throws(AE)]
-    fn inner(self_: &dyn Substitutor, s: &dyn AsRef<str>) -> 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: &regex::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<String> 
-  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<String> {
-    self.0.get(kw).map(String::clone)
-  }
-}
-
-impl<B:Substitutor, X:Substitutor> Substitutor for ExtendedSubst<B, X> {
-  fn get(&self, kw: &str) -> Option<String> {
-    self.1.get(kw).or_else(|| self.0.get(kw))
-  }
-}
-
-impl Substitutor for DirSubst {
-  fn get(&self, kw: &str) -> Option<String> {
-    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<String, io::Error> = 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<S:AsRef<str>>(&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::<f64>;
 
 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} => {