chiark / gitweb /
README: Fix formatting of geckdodriver section
[otter.git] / wdriver.rs
1 // Copyright 2020 Ian Jackson
2 // SPDX-License-Identifier: AGPL-3.0-or-later
3 // There is NO WARRANTY.
4
5 #![feature(unboxed_closures)]
6 #![feature(fn_traits)]
7
8 pub use anyhow::{anyhow, ensure, Context};
9 pub use boolinator::Boolinator;
10 pub use fehler::{throw, throws};
11 pub use if_chain::if_chain;
12 pub use log::{debug, error, info, trace, warn};
13 pub use log::{log, log_enabled};
14 pub use nix::unistd::LinkatFlags;
15 pub use num_traits::NumCast;
16 pub use num_derive::FromPrimitive;
17 pub use parking_lot::{Mutex, MutexGuard};
18 pub use regex::{Captures, Regex};
19 pub use serde::{Serialize, Deserialize};
20 pub use structopt::StructOpt;
21 pub use strum::{EnumIter, EnumProperty, IntoEnumIterator, IntoStaticStr};
22 pub use thirtyfour_sync as t4;
23 pub use void::Void;
24
25 pub use t4::WebDriverCommands;
26 pub use t4::By;
27
28 pub use std::env;
29 pub use std::fmt::{self, Debug};
30 pub use std::collections::hash_map::HashMap;
31 pub use std::convert::TryInto;
32 pub use std::fs;
33 pub use std::io::{self, BufRead, BufReader, ErrorKind, Write};
34 pub use std::iter;
35 pub use std::mem;
36 pub use std::net::TcpStream;
37 pub use std::ops::Deref;
38 pub use std::os::unix::process::CommandExt;
39 pub use std::os::unix::fs::DirBuilderExt;
40 pub use std::os::linux::fs::MetadataExt; // todo why linux for st_mode??
41 pub use std::path;
42 pub use std::process::{Command, Stdio};
43 pub use std::thread::{self, sleep};
44 pub use std::time;
45
46 pub use otter::ensure_eq;
47 pub use otter::commands::{MgmtCommand, MgmtResponse};
48 pub use otter::commands::{MgmtGameInstruction, MgmtGameResponse};
49 pub use otter::commands::{MgmtGameUpdateMode};
50 pub use otter::gamestate::{self, Generation};
51 pub use otter::global::InstanceName;
52 pub use otter::mgmtchannel::MgmtChannel;
53 pub use otter::spec::{Coord, Pos, PosC};
54 pub use otter::ui::{AbbrevPresentationLayout, PresentationLayout};
55
56 pub type T4d = t4::WebDriver;
57 pub type WDE = t4::error::WebDriverError;
58
59 pub const MS : time::Duration = time::Duration::from_millis(1);
60 pub type AE = anyhow::Error;
61
62 pub const URL : &str = "http://localhost:8000";
63
64 pub fn default<T:Default>() -> T { Default::default() }
65
66 use t4::Capabilities;
67 use otter::config::DAEMON_STARTUP_REPORT;
68
69 const TABLE : &str = "server::dummy";
70 const CONFIG : &str = "server-config.toml";
71
72 #[derive(Copy,Clone,Debug,Eq,PartialEq,Ord,PartialOrd)]
73 #[derive(FromPrimitive,EnumIter,IntoStaticStr,EnumProperty)]
74 #[strum(serialize_all = "snake_case")]
75 pub enum StaticUser {
76   #[strum(props(Token="kmqAKPwK4TfReFjMor8MJhdRPBcwIBpe"))] Alice,
77   #[strum(props(Token="ccg9kzoTh758QrVE1xMY7BQWB36dNJTx"))] Bob,
78 }
79
80 pub trait AlwaysContext<T,E> {
81   fn always_context(self, msg: &'static str) -> anyhow::Result<T>;
82 }
83
84 impl<T,E> AlwaysContext<T,E> for Result<T,E>
85 where Self: anyhow::Context<T,E>,
86 {
87   fn always_context(self, msg: &'static str) -> anyhow::Result<T> {
88     let x = self.context(msg);
89     if x.is_ok() { info!("completed {}.", msg) };
90     x
91   }
92 }
93
94 pub trait JustWarn<T> {
95   fn just_warn(self) -> Option<T>;
96 }
97
98 impl<T> JustWarn<T> for Result<T,AE> {
99   fn just_warn(self) -> Option<T> {
100     match self {
101       Ok(x) => Some(x),
102       Err(e) => {
103         warn!("{:#}", e);
104         None
105       },
106     }
107   }
108 }
109
110 #[derive(Debug,Clone)]
111 #[derive(StructOpt)]
112 pub struct Opts {
113   #[structopt(long="--no-bwrap")]
114   no_bwrap: bool,
115
116   #[structopt(long="--tmp-dir", default_value="tmp")]
117   tmp_dir: String,
118
119   #[structopt(long="--as-if")]
120   as_if: Option<String>,
121
122   #[structopt(long="--pause", default_value="0ms")]
123   pause: humantime::Duration,
124
125   #[structopt(long="--layout", default_value="Portrait")]
126   layout: PresentationLayout,
127
128   #[structopt(long="--geckodriver-args", default_value="")]
129   geckodriver_args: String,
130 }
131
132 #[derive(Debug)]
133 pub struct FinalInfoCollection;
134
135 type ScreenShotCount = u32;
136 type WindowState = Option<String>;
137
138 #[derive(Debug)]
139 pub struct Setup {
140   pub ds: DirSubst,
141   pub mgmt_conn: MgmtChannel,
142   pub opts: Opts,
143   driver: T4d,
144   current_window: WindowState,
145   screenshot_count: ScreenShotCount,
146   final_hook: FinalInfoCollection,
147   windows_squirreled: Vec<String>, // see Drop impl
148 }
149
150 #[derive(Clone,Debug)]
151 pub struct DirSubst {
152   pub tmp: String,
153   pub abstmp: String,
154   pub start_dir: String,
155   pub src: String,
156 }
157
158 pub struct Instance(InstanceName);
159
160 #[derive(Clone,Debug)]
161 pub struct Subst(HashMap<String,String>);
162
163 #[derive(Clone,Debug)]
164 pub struct ExtendedSubst<B: Substitutor, X: Substitutor>(B, X);
165
166 impl<'i,
167      T: AsRef<str> + 'i,
168      U: AsRef<str> + 'i,
169      L: IntoIterator<Item=&'i (T, U)>>
170   From<L> for Subst
171 {
172   fn from(l: L) -> Subst {
173     let map = l.into_iter()
174       .map(|(k,v)| (k.as_ref().to_owned(), v.as_ref().to_owned())).collect();
175     Subst(map)
176   }
177 }
178
179 pub trait Substitutor {
180   fn get(&self, kw: &str) -> Option<String>;
181
182   fn also<L: Into<Subst>>(&self, xl: L) -> ExtendedSubst<Self, Subst>
183   where Self: Clone + Sized {
184     ExtendedSubst(self.clone(), xl.into())
185   }
186
187   #[throws(AE)]
188   fn subst<S: AsRef<str>>(&self, s: S) -> String 
189   where Self: Sized {
190     #[throws(AE)]
191     fn inner(self_: &dyn Substitutor, s: &dyn AsRef<str>) -> String {
192       let s = s.as_ref();
193       let re = Regex::new(r"@(\w+)@").expect("bad re!");
194       let mut errs = vec![];
195       let out = re.replace_all(s, |caps: &regex::Captures| {
196         let kw = caps.get(1).expect("$1 missing!").as_str();
197         if kw == "" { return "".to_owned() }
198         let v = self_.get(kw);
199         v.unwrap_or_else(||{
200           errs.push(kw.to_owned());
201           "".to_owned()
202         })
203       });
204       if ! errs.is_empty() {
205         throw!(anyhow!("bad substitution(s) {:?} in {:?}",
206                        &errs, s));
207       }
208       out.into()
209     }
210     inner(self, &s)?
211   }
212
213   #[throws(AE)]
214   fn ss(&self, s: &str) -> Vec<String> 
215   where Self: Sized {
216     self.subst(s)?
217       .trim()
218       .split(' ')
219       .filter(|s| !s.is_empty())
220       .map(str::to_string)
221       .collect()
222   }
223 }
224
225 impl Substitutor for Subst {
226   fn get(&self, kw: &str) -> Option<String> {
227     self.0.get(kw).map(String::clone)
228   }
229 }
230
231 impl<B:Substitutor, X:Substitutor> Substitutor for ExtendedSubst<B, X> {
232   fn get(&self, kw: &str) -> Option<String> {
233     self.1.get(kw).or_else(|| self.0.get(kw))
234   }
235 }
236
237 impl Substitutor for DirSubst {
238   fn get(&self, kw: &str) -> Option<String> {
239     Some(match kw {
240       "url"    => URL.to_owned(),
241       "src"    => self.src.clone(),
242       "build"  => self.start_dir.clone(),
243       "abstmp" => self.abstmp.clone(),
244       "target" => format!("{}/target", &self.start_dir),
245       "specs"  => format!("{}/specs" , &self.src      ),
246       _ => return None,
247     })
248   }
249 }
250
251 mod cleanup_notify {
252   use anyhow::Context;
253   use fehler::{throw, throws};
254   use libc::_exit;
255   use nix::errno::Errno::*;
256   use nix::{unistd::*, fcntl::OFlag};
257   use nix::sys::signal::*;
258   use nix::Error::Sys;
259   use void::Void;
260   use std::io;
261   use std::os::unix::io::RawFd;
262   use std::panic::catch_unwind;
263   use std::process::Command;
264   type AE = anyhow::Error;
265
266   pub struct Handle(RawFd);
267
268   #[throws(io::Error)]
269   fn mkpipe() -> (RawFd,RawFd) {
270     pipe2(OFlag::O_CLOEXEC).map_err(nix2io)?
271   }
272
273   #[throws(io::Error)]
274   fn read_await(fd: RawFd) {
275     loop {
276       let mut buf = [0u8; 1];
277       match nix::unistd::read(fd, &mut buf) {
278         Ok(0) => break,
279         Ok(_) => throw!(io::Error::from_raw_os_error(libc::EINVAL)),
280         Err(Sys(EINTR)) => continue,
281         _ => throw!(io::Error::last_os_error()),
282       }
283     }
284   }
285
286   fn nix2io(_n: nix::Error) -> io::Error {
287     io::Error::last_os_error()
288   }
289
290   impl Handle {
291     #[throws(AE)]
292     pub fn new() -> Self {
293       let (reading_end, _writing_end) = mkpipe()
294         .context("create cleanup notify pipe")?;
295       // we leak the writing end, keeping it open only in this process
296       Handle(reading_end)
297     }
298
299     #[throws(AE)]
300     pub fn arm_hook(&self, cmd: &mut Command) { unsafe {
301       use std::os::unix::process::CommandExt;
302
303       let notify_writing_end = self.0;
304       let all_signals = nix::sys::signal::SigSet::all();
305
306       cmd.pre_exec(move || -> Result<(), io::Error> {
307         let semidaemon = nix::unistd::getpid();
308         let (reading_end, writing_end) = mkpipe()?;
309
310         match fork().map_err(nix2io)? {
311           ForkResult::Child => {
312             let _ = catch_unwind(move || -> Void {
313               let _ = sigprocmask(
314                 SigmaskHow::SIG_BLOCK,
315                 Some(&all_signals),
316                 None
317               );
318
319               let _ = close(writing_end);
320               let _ = nix::unistd::dup2(2, 1);
321
322               for fd in 2.. {
323                 if fd == notify_writing_end { continue }
324                 let r = close(fd);
325                 if fd >= writing_end && matches!(r, Err(Sys(EBADF))) {
326                   break;
327                 }                  
328               }
329               let _ = read_await(notify_writing_end);
330               let _ = kill(semidaemon, SIGTERM);
331               _exit(0);
332             });
333             let _ = raise(SIGABRT);
334             _exit(127);
335           },
336           ForkResult::Parent{..} => {
337             // parent
338             close(writing_end).map_err(nix2io)?;
339             read_await(reading_end)?;
340           },
341         };
342
343         Ok(())
344       });
345     } }
346   }
347 }
348
349 #[throws(AE)]
350 fn reinvoke_via_bwrap(_opts: &Opts, current_exe: &str) -> Void {
351   debug!("running bwrap");
352   
353   let mut bcmd = Command::new("bwrap");
354   bcmd
355     .args("--unshare-net \
356            --dev-bind / / \
357            --tmpfs /tmp \
358            --die-with-parent".split(" "))
359     .arg(current_exe)
360     .arg("--no-bwrap")
361     .args(env::args_os().skip(1));
362
363   std::io::stdout().flush().context("flush stdout")?;
364   let e : AE = bcmd.exec().into();
365   throw!(e.context("exec bwrap"));
366 }
367
368 #[throws(AE)]
369 fn prepare_tmpdir<'x>(opts: &'x Opts, mut current_exe: &'x str) -> DirSubst {
370   #[throws(AE)]
371   fn getcwd() -> String {
372     env::current_dir()
373       .context("getcwd")?
374       .to_str()
375       .ok_or_else(|| anyhow!("path is not UTF-8"))?
376       .to_owned()
377   }
378
379   if let Some(as_if) = &opts.as_if {
380     current_exe = as_if;
381   }
382
383   let start_dir = getcwd()
384     .context("canonicalise our invocation directory (getcwd)")?;
385
386   (||{
387     match fs::metadata(&opts.tmp_dir) {
388       Ok(m) => {
389         if !m.is_dir() {
390           throw!(anyhow!("existing object is not a directory"));
391         }
392         if (m.st_mode() & 0o01002) != 0 {
393           throw!(anyhow!(
394             "existing directory mode {:#o} is sticky or world-writeable. \
395              We use predictable pathnames so that would be a tmp race",
396             m.st_mode()
397           ));
398         }
399       }
400       Err(e) if e.kind() == ErrorKind::NotFound => {
401         fs::create_dir(&opts.tmp_dir)
402           .context("create")?;
403       }
404       Err(e) => {
405         let e : AE = e.into();
406         throw!(e.context("stat existing directory"))
407       }
408     }
409
410     env::set_current_dir(&opts.tmp_dir)
411       .context("chdir into it")?;
412
413     Ok::<_,AE>(())
414   })()
415     .with_context(|| opts.tmp_dir.to_owned())
416     .context("prepare/create tmp-dir")?;
417
418   let leaf = current_exe.rsplitn(2, '/').next().unwrap();
419   let our_tmpdir = format!("{}/{}", &opts.tmp_dir, &leaf);
420   (||{
421     match fs::remove_dir_all(&leaf) {
422       Ok(()) => {},
423       Err(e) if e.kind() == ErrorKind::NotFound => {},
424       Err(e) => throw!(AE::from(e).context("remove previous directory")),
425     };
426
427     fs::DirBuilder::new().create(&leaf)
428       .context("create fresh subdirectory")?;
429
430     env::set_current_dir(&leaf)
431       .context("chdir into it")?;
432
433     Ok::<_,AE>(())
434   })()
435     .with_context(|| our_tmpdir.to_owned())
436     .context("prepare/create our tmp subdir")?;
437
438   let abstmp =
439     getcwd().context("canonicalise our tmp subdir (getcwd)")?;
440
441   env::set_var("HOME", &abstmp);
442   env::set_var("TMPDIR", &abstmp);
443   for v in "http_proxy https_proxy XAUTHORITY CDPATH \
444             SSH_AGENT_PID SSH_AUTH_SOCK WINDOWID WWW_HOME".split(' ')
445   {
446     env::remove_var(v);
447   }
448
449   let manifest_var = "CARGO_MANIFEST_DIR";
450   let src : String = (|| Ok::<_,AE>(match env::var(manifest_var) {
451     Ok(dir) => dir.into(),
452     Err(env::VarError::NotPresent) => start_dir.clone(),
453     e@ Err(_) => throw!(e.context(manifest_var).err().unwrap()),
454   }))()
455     .context("find source code")?;
456
457   DirSubst {
458     tmp: our_tmpdir,
459     abstmp,
460     src,
461     start_dir,
462   }
463 }
464
465 #[throws(AE)]
466 fn fork_something_which_prints(mut cmd: Command,
467                                cln: &cleanup_notify::Handle,
468                                what: &str)
469                                -> String
470 {
471   (||{
472     cmd.stdout(Stdio::piped());
473     cln.arm_hook(&mut cmd)?;
474     let mut child = cmd.spawn().context("spawn")?;
475     let mut report = BufReader::new(child.stdout.take().unwrap())
476       .lines().fuse();
477
478     let l = report.next();
479
480     let s = child.try_wait().context("check on spawned child")?;
481     if let Some(e) = s {
482       throw!(anyhow!("failed to start: wait status = {}", &e));
483     }
484
485     let l = match l {
486       Some(Ok(l)) => l,
487       None => throw!(anyhow!("EOF (but it's still running?")),
488       Some(Err(e)) => throw!(AE::from(e).context("failed to read")),
489     };
490
491     let what = what.to_owned();
492     thread::spawn(move|| (||{
493       for l in report {
494         let l : Result<String, io::Error> = l;
495         let l = l.context("reading further output")?;
496         const MAXLEN : usize = 300;
497         if l.len() <= MAXLEN {
498           println!("{} {}", what, l);
499         } else {
500           println!("{} {}...", what, &l[..MAXLEN-3]);
501         }
502       }
503       Ok::<_,AE>(())
504     })().context(what).just_warn()
505     );
506
507     Ok::<_,AE>(l)
508   })().with_context(|| what.to_owned())?
509 }
510
511 #[throws(AE)]
512 fn prepare_xserver(cln: &cleanup_notify::Handle, ds: &DirSubst) {
513   const DISPLAY : u16 = 12;
514
515   let mut xcmd = Command::new("Xvfb");
516   xcmd
517     .args("-nolisten unix \
518            -nolisten local \
519            -listen inet \
520            -listen inet6 \
521            -terminate \
522            -retro \
523            -displayfd 1".split(' '))
524     .args(&["-fbdir", &ds.abstmp])
525     .arg(format!(":{}", DISPLAY));
526
527   let l = fork_something_which_prints(xcmd, cln, "Xvfb")?;
528
529   if l != DISPLAY.to_string() {
530     throw!(anyhow!(
531       "Xfvb said {:?}, expected {:?}",
532       l, DISPLAY
533     ));
534   }
535
536   let display = format!("[::1]:{}", DISPLAY);
537   env::set_var("DISPLAY", &display);
538
539   // Doesn't do IPv6 ??
540   let v4display = format!("127.0.0.1:{}", DISPLAY);
541   let (xconn, _) = x11rb::connect(Some(&v4display))
542     .context("make keepalive connection to X server")?;
543
544   // Sadly, if we die between spawning Xfvb, and here, we will
545   // permanently leak the whole Xfvb process (and the network
546   // namespace).  There doesn't seem to a way to avoid this without
547   // editing Xvfb,
548
549   Box::leak(Box::new(xconn));
550 }
551
552 #[throws(AE)]
553 fn prepare_gameserver(cln: &cleanup_notify::Handle, ds: &DirSubst)
554                       -> MgmtChannel {
555   let subst = ds.also(&[
556     ("command_socket", "command.socket"),
557   ]);
558   let config = subst.subst(r##"
559 change_directory = "@abstmp@"
560 base_dir = "@build@"
561 public_url = "@url@"
562
563 save_dir = "."
564 command_socket = "@command_socket@"
565 template_dir = "@src@/templates"
566 nwtemplate_dir = "@src@/nwtemplates"
567 bundled_sources = "@target@/bundled-sources"
568 wasm_dir = "@target@/packed-wasm"
569 shapelibs = [ "@src@/library/*.toml" ]
570
571 debug_js_inject_file = "@src@/templates/log-save.js"
572 check_bundled_sources = false # For testing only! see LICENCE!
573
574 [log]
575 global_level = 'debug'
576
577 [log.modules]
578 rocket = 'error'
579 _ = "error" # rocket
580 # ^ comment these two out to see Tera errors, *sigh*
581
582 'hyper::server' = 'info'
583 "game::debugreader" = 'info'
584 "game::updates" = 'trace'
585 "##)?;
586
587   fs::write(CONFIG, &config)
588     .context(CONFIG).context("create server config")?;
589
590   let server_exe = ds.subst("@target@/debug/daemon-otter")?;
591   let mut cmd = Command::new(&server_exe);
592   cmd
593     .arg("--report-startup")
594     .arg(CONFIG);
595
596   (||{
597     let l = fork_something_which_prints(cmd, cln, &server_exe)?;
598     if l != DAEMON_STARTUP_REPORT {
599       throw!(anyhow!("otter-daemon startup report {:?}, expected {:?}",
600                      &l, DAEMON_STARTUP_REPORT));
601     }
602     Ok::<_,AE>(())
603   })()
604     .context("game server")?;
605
606   let mut mgmt_conn = MgmtChannel::connect(
607     &subst.subst("@command_socket@")?
608   )?;
609
610   mgmt_conn.cmd(&MgmtCommand::SetSuperuser(true))?;
611   mgmt_conn.cmd(&MgmtCommand::SelectAccount("server:".parse()?))?;  
612
613   mgmt_conn
614 }
615
616 impl DirSubst {
617   #[throws(AE)]
618   pub fn otter<S:AsRef<str>>(&self, xargs: &[S]) {
619     let ds = self;
620     let exe = ds.subst("@target@/debug/otter")?;
621     let mut args : Vec<&str> = vec![];
622     args.extend(&["--config", CONFIG]);
623     args.extend(xargs.iter().map(AsRef::as_ref));
624     let dbg = format!("running {} {:?}", &exe, &args);
625     debug!("{}", &dbg);
626     (||{
627       let mut cmd = Command::new(&exe);
628       cmd.args(&args);
629       let st = cmd
630         .spawn().context("spawn")?
631         .wait().context("wait")?;
632       if !st.success() {
633         throw!(anyhow!("wait status {}", &st));
634       }
635       Ok::<_,AE>(())
636     })()
637       .context(dbg)
638       .context("run otter client")?;
639   }
640 }
641
642 #[throws(AE)]
643 pub fn prepare_game(ds: &DirSubst, table: &str) -> InstanceName {
644   let subst = ds.also(&[("table", &table)]);
645   ds.otter(&subst.ss(
646     "--account server:                                  \
647      reset                                              \
648      --reset-table @specs@/test.table.toml              \
649                    @table@ @specs@/demo.game.toml \
650     ")?).context("reset table")?;
651
652   let instance : InstanceName = table.parse()
653     .with_context(|| table.to_owned())
654     .context("parse table name")?;
655
656   instance
657 }
658
659 #[throws(AE)]
660 fn prepare_geckodriver(opts: &Opts, cln: &cleanup_notify::Handle) {
661   const EXPECTED : &str = "Listening on 127.0.0.1:4444";
662   let mut cmd = Command::new("geckodriver");
663   if opts.geckodriver_args != "" {
664     cmd.args(opts.geckodriver_args.split(' '));
665   }
666   let l = fork_something_which_prints(cmd, cln, "geckodriver")?;
667   let fields : Vec<_> = l.split('\t').skip(2).take(2).collect();
668   let expected = ["INFO", EXPECTED];
669   if fields != expected {
670     throw!(anyhow!("geckodriver did not report as expected \
671                     - got {:?}, expected {:?}",
672                    fields, &expected));
673   }
674 }
675
676 #[throws(AE)]
677 fn prepare_thirtyfour() -> (T4d, ScreenShotCount, Vec<String>) {
678   let mut count = 0;
679   let mut caps = t4::DesiredCapabilities::firefox();
680   let prefs : HashMap<_,_> = [
681     ("devtools.console.stdout.content", true),
682   ].iter().cloned().collect();
683   caps.add("prefs", prefs)?;
684   caps.add("stdio", "inherit")?;
685   let mut driver = t4::WebDriver::new("http://localhost:4444", &caps)
686     .context("create 34 WebDriver")?;
687
688   const FRONT : &str = "front";
689   let window_names = vec![FRONT.into()];
690   driver.set_window_name(FRONT).context("set initial window name")?;
691   screenshot(&mut driver, &mut count, "startup")?;
692   driver.get(URL).context("navigate to front page")?;
693   screenshot(&mut driver, &mut count, "front")?;
694
695   fetch_log(&driver, "front")?;
696   
697   let t = Some(5_000 * MS);
698   driver.set_timeouts(t4::TimeoutConfiguration::new(t,t,t))
699     .context("set webdriver timeouts")?;
700
701   (driver, count, window_names)
702 }
703
704 /// current window must be `name`
705 #[throws(AE)]
706 fn fetch_log(driver: &T4d, name: &str) {
707   (||{
708     let got = driver.execute_script(r#"
709       var returning = window.console.saved;
710       window.console.saved = [];
711       return returning;
712     "#).context("get log")?;
713
714     for ent in got.value().as_array()
715       .ok_or(anyhow!("saved isn't an array?"))?
716     {
717       #[derive(Deserialize)]
718       struct LogEnt(String, Vec<serde_json::Value>);
719       impl fmt::Display for LogEnt {
720         #[throws(fmt::Error)]
721         fn fmt(&self, f: &mut fmt::Formatter) {
722           write!(f, "{}:", self.0)?;
723           for a in &self.1 { write!(f, " {}", a)?; }
724         }
725       }
726
727       let ent: LogEnt = serde_json::from_value(ent.clone())
728         .context("parse log entry")?;
729
730       debug!("JS {} {}", name, &ent);
731     }
732     Ok::<_,AE>(())
733   })()
734     .with_context(|| name.to_owned())
735     .context("fetch JS log messages")?;
736 }
737
738 #[derive(Debug)]
739 pub struct Window {
740   name: String,
741   instance: InstanceName,
742 }
743
744 impl Window {
745   pub fn table(&self) -> String { self.instance.to_string() }
746 }
747
748 type ScreenCTM = ndarray::Array2::<f64>;
749
750 pub struct WindowGuard<'g> {
751   su: &'g mut Setup,
752   w: &'g Window,
753   matrix: once_cell::sync::OnceCell<ScreenCTM>,
754 }
755
756 impl Debug for WindowGuard<'_> {
757   #[throws(fmt::Error)]
758   fn fmt(&self, f: &mut fmt::Formatter) {
759     f.debug_struct("WindowGuard")
760       .field("w.name", &self.w.name)
761       .field("w.instance", &self.w.instance.to_string())
762       .finish()?
763   }
764 }
765
766 impl<'g> WindowGuard<'g> {
767   #[throws(AE)]
768   pub fn find_piece(&'g self, pieceid: &'g str) -> PieceElement<'g> {
769     let id = format!("use{}", pieceid);
770     let elem = self.su.driver.find_element(By::Id(&id))?;
771     PieceElement {
772       pieceid, elem,
773       w: self,
774     }
775   }
776 }
777
778 pub type WebCoord = i32;
779 pub type WebPos = (WebCoord, WebCoord);
780
781 pub struct PieceElement<'g> {
782   pieceid: &'g str,
783   w: &'g WindowGuard<'g>,
784   elem: t4::WebElement<'g>,
785 }
786
787 impl<'g> Deref for PieceElement<'g> {
788   type Target = t4::WebElement<'g>;
789   fn deref<'i>(&'i self) -> &'i t4::WebElement<'g> { &self.elem }
790 }
791
792 impl<'g> PieceElement<'g> {
793   #[throws(AE)]
794   pub fn posg(&self) -> Pos {
795     (||{
796       let a = |a| Ok::<_,AE>(
797         self.get_attribute(a)?.ok_or(anyhow!("{}", a))?.parse()?
798       );
799       let x = a("x")?;
800       let y = a("y")?;
801       Ok::<_,AE>(PosC([x,y]))
802     })()
803       .with_context(|| self.pieceid.to_owned())
804       .context("read position of piece out of x,y attributes")?
805   }
806
807   #[throws(AE)]
808   pub fn posw(&self) -> WebPos {
809     let posg = self.posg()?;
810
811     let mat = self.w.matrix.get_or_try_init(||{
812       let ary = self.w.su.driver.execute_script(r#"
813         let m = space.getScreenCTM();
814         return [m.a, m.b, m.c, m.d, m.e, m.f];
815       "#)?;
816       let ary = ary.value();
817       dbg!(ary);
818
819       let mat = (||{
820         let ary = ary.as_array().ok_or_else(|| anyhow!("not array"))?;
821         let mut mat : ScreenCTM = ndarray::Array2::zeros((3,3));
822         for got in itertools::Itertools::zip_longest(
823           [11, 12, 21, 22, 41, 42].iter(),
824           // ^ from https://developer.mozilla.org/en-US/docs/Web/API/DOMMatrix
825           ary.iter(),
826         ) {
827           let (mij, v) = got.both().ok_or_else(|| anyhow!("wrong length"))?;
828           let adj = |v| (if v == 4 { 3 } else { v }) - 1;
829           let i = adj(mij / 10);
830           let j = adj(mij % 10);
831           mat[(j,i)] = v.as_f64().ok_or_else(|| anyhow!("entry not f64"))?;
832         }
833         Ok::<_,AE>(mat)
834       })()
835         .with_context(|| format!("getScreenCGM script gave {:?}", &ary))?;
836
837       dbg!(&mat);
838       Ok::<_,AE>(mat)
839     })?;
840     (||{
841       let vec : ndarray::Array1<f64> =
842         posg.0.iter()
843         .cloned()
844         .map(|v| v as f64)
845         .chain(iter::once(1.))
846         .collect();
847       dbg!(&vec);
848
849       let vec = mat.dot(&vec);
850       let mut coords = vec.iter().map(
851         |v| NumCast::from(v.round()).ok_or_else(
852           || anyhow!("coordinate {} out of range in {:?}", v, &vec))
853       );
854       let mut coord = || coords.next().unwrap();
855       Ok::<WebPos,AE>((
856         coord()?,
857         coord()?,
858       ))
859     })()
860       .with_context(|| self.pieceid.to_owned())
861       .context("find piece position")?
862   }
863 }
864
865 #[throws(AE)]
866 fn check_window_name_sanity(name: &str) -> &str {
867   let e = || anyhow!("bad window name {:?}", &name);
868
869   name.chars().nth(0).ok_or(e())?
870     .is_ascii_alphanumeric().ok_or(e())?;
871
872   name.chars().all(
873     |c| c.is_ascii_alphanumeric() || c == '-' || c == '_'
874   ).ok_or(e())?;
875
876   name
877 }
878
879 impl Setup {
880   #[throws(AE)]
881   pub fn new_window<'s>(&'s mut self, instance: &Instance, name: &str)
882                         -> Window {
883     let name = check_window_name_sanity(name)?;
884     let window = (||{
885
886       self.current_window = None; // we might change the current window
887
888       match self.driver.switch_to().window_name(name) {
889         Ok(()) => throw!(anyhow!("window already exists")),
890         Err(WDE::NoSuchWindow(_)) |
891         Err(WDE::NotFound(..)) => (),
892         e@ Err(_) => {
893           eprintln!("wot {:?}", &e);
894           throw!(e
895                             .context("check for pre-existing window")
896                             .err().unwrap())
897         },
898       };
899
900       self.driver.execute_script(&format!(
901         r#"window.open('', target='{}');"#,
902         name,
903       ))
904         .context("execute script to create window")?;
905
906       Ok::<_,AE>(Window {
907         name: name.to_owned(),
908         instance: instance.0.clone(),
909       })
910     })()
911       .with_context(|| name.to_owned())
912       .context("create window")?;
913
914     self.windows_squirreled.push(name.to_owned());
915     window
916   }
917 }
918
919 impl Setup {
920   #[throws(AE)]
921   pub fn w<'s>(&'s mut self, w: &'s Window) -> WindowGuard<'s> {
922     if self.current_window.as_ref() != Some(&w.name) {
923       self.driver.switch_to().window_name(&w.name)
924         .with_context(|| w.name.to_owned())
925         .context("switch to window")?;
926       self.current_window = Some(w.name.clone());
927     }
928     WindowGuard {
929       w,
930       su: self,
931       matrix: default(),
932     }
933   }
934 }
935
936 impl<'g> Deref for WindowGuard<'g> {
937   type Target = T4d;
938   fn deref(&self) -> &T4d { &self.su.driver }
939 }
940
941 impl<'g> Drop for WindowGuard<'g> {
942   fn drop(&mut self) {
943     fetch_log(&self.su.driver, &self.w.name)
944       .just_warn();
945   }
946 }
947
948 pub trait Screenshottable {
949   fn screenshot(&mut self, slug: &str) -> Result<(),AE>;
950 }
951
952 impl<'g> Screenshottable for WindowGuard<'g> {
953   #[throws(AE)]
954   fn screenshot(&mut self, slug: &str) {
955     screenshot(&self.su.driver, &mut self.su.screenshot_count,
956                &format!("{}-{}", &self.w.name, slug))?
957   }
958 }
959
960 #[throws(AE)]
961 fn screenshot(driver: &T4d, count: &mut ScreenShotCount, slug: &str) {
962   let path = format!("{:03}{}.png", count, slug);
963   *count += 1;
964   driver.screenshot(&path::PathBuf::from(&path))
965     .with_context(|| path.clone())
966     .context("take screenshot")?;
967   debug!("screenshot {}", &path);
968 }
969
970 impl<'g> WindowGuard<'g> {
971   #[throws(AE)]
972   pub fn synch(&mut self) {
973     let cmd = MgmtCommand::AlterGame {
974       game: self.w.instance.clone(),
975       how: MgmtGameUpdateMode::Online,
976       insns: vec![ MgmtGameInstruction::Synch ],
977     };
978     let gen = if_chain!{
979       let resp = self.su.mgmt_conn.cmd(&cmd)?;
980       if let MgmtResponse::AlterGame {
981         error: None,
982         ref responses
983       } = resp;
984       if let [MgmtGameResponse::Synch(gen)] = responses[..];
985       then { gen }
986       else { throw!(anyhow!("unexpected resp to synch {:?}", resp)) }
987     };
988     trace!("{:?} gen={} ...", self, gen);
989     (|| {
990       loop {
991         let tgen = self.su.driver.execute_async_script(
992           &Subst::from(&[
993             ("wanted", &gen.to_string())
994           ]).subst(r#"
995             var done = arguments[0];
996             if (gen >= @wanted@) { done(gen); return; }
997             window.gen_update_hook = function() {
998               window.gen_update_hook = function() { };
999               done(gen);
1000             };
1001           "#)?
1002         )
1003           .context("run async script")?
1004           .value().as_u64().ok_or(anyhow!("script return is not u64"))?;
1005         let tgen = Generation(tgen);
1006         trace!("{:?} gen={} tgen={}", self, gen, tgen);
1007         if tgen >= gen { break; }
1008       }
1009       Ok::<(),AE>(())
1010     })()
1011       .context("await gen update via async js script")?;
1012
1013     (|| {
1014       let errors = self.su.driver.execute_script(r#"
1015         let e = document.getElementById('error');
1016         if (e) {
1017           return e.innerHTML;
1018         } else {
1019           console.log('wdt-*: no errors element, no trapped errors check');
1020           return "";
1021         }
1022       "#)
1023         .context("get errors")?;
1024       let errors = errors
1025         .value()
1026         .as_str()
1027         .ok_or_else(|| anyhow!("errors script gave non-string"))?;
1028       if ! errors.is_empty() {
1029         throw!(anyhow!("JS errors - HTML: {}", errors));
1030       }
1031       Ok::<(),AE>(())
1032     })()
1033       .context("check for in-client trapped errors")?;
1034   }
1035 }
1036
1037 impl Drop for FinalInfoCollection {
1038   fn drop(&mut self) {
1039     nix::unistd::linkat(None, "Xvfb_screen0",
1040                         None, "Xvfb_keep.xwd",
1041                         LinkatFlags::NoSymlinkFollow)
1042       .context("preserve Xvfb screen")
1043       .just_warn();
1044   }
1045 }
1046
1047 impl Drop for Setup {
1048   fn drop(&mut self) {
1049     (||{
1050       for name in mem::take(&mut self.windows_squirreled) {
1051         // This constructor is concurrency-hazardous.  It's only OK
1052         // here because we have &mut self.  If there is only one
1053         // Setup, there can be noone else with a Window with an
1054         // identical name to be interfered with by us.
1055         let w = Window {
1056           name: name.clone(),
1057           instance: TABLE.parse().context(TABLE)?,
1058         };
1059         self.w(&w)?.screenshot("final")
1060           .context(name)
1061           .context("final screenshot")
1062           .just_warn();
1063       }
1064       Ok::<_,AE>(())
1065     })()
1066       .context("screenshots, in Setup::drop")
1067       .just_warn();
1068   }
1069 }
1070
1071 #[throws(AE)]
1072 pub fn setup(exe_module_path: &str) -> (Setup, Instance) {
1073   env_logger::Builder::new()
1074     .format_timestamp_micros()
1075     .format_level(true)
1076     .filter_module("otter_webdriver_tests", log::LevelFilter::Debug)
1077     .filter_module(exe_module_path, log::LevelFilter::Debug)
1078     .filter_level(log::LevelFilter::Info)
1079     .parse_env("OTTER_WDT_LOG")
1080     .init();
1081   debug!("starting");
1082
1083   let current_exe : String = env::current_exe()
1084     .context("find current executable")?
1085     .to_str()
1086     .ok_or_else(|| anyhow!("current executable path is not UTF-8 !"))?
1087     .to_owned();
1088
1089   let opts = Opts::from_args();
1090   if !opts.no_bwrap {
1091     reinvoke_via_bwrap(&opts, &current_exe)
1092       .context("reinvoke via bwrap")?;
1093   }
1094
1095   info!("pid = {}", nix::unistd::getpid());
1096   sleep(opts.pause.into());
1097
1098   let cln = cleanup_notify::Handle::new()?;
1099   let ds = prepare_tmpdir(&opts, &current_exe)?;
1100
1101   prepare_xserver(&cln, &ds).always_context("setup X server")?;
1102
1103   let mgmt_conn =
1104     prepare_gameserver(&cln, &ds).always_context("setup game server")?;
1105
1106   let instance_name =
1107     prepare_game(&ds, TABLE).context("setup game")?;
1108
1109   let final_hook = FinalInfoCollection;
1110
1111   prepare_geckodriver(&opts, &cln).always_context("setup webdriver server")?;
1112   let (driver, screenshot_count, windows_squirreled) =
1113     prepare_thirtyfour().always_context("prepare web session")?;
1114
1115   (Setup {
1116     ds,
1117     mgmt_conn,
1118     driver,
1119     opts,
1120     screenshot_count,
1121     current_window: None,
1122     windows_squirreled,
1123     final_hook,
1124   },
1125    Instance(
1126      instance_name
1127    ))
1128 }
1129
1130 impl Setup {
1131   #[throws(AE)]
1132   pub fn setup_static_users(&mut self, instance: &Instance) -> Vec<Window> {
1133     #[throws(AE)]
1134     fn mk(su: &mut Setup, instance: &Instance, u: StaticUser) -> Window {
1135       let nick: &str = u.into();
1136       let token = u.get_str("Token").expect("StaticUser missing Token");
1137       let pl = AbbrevPresentationLayout(su.opts.layout).to_string();
1138       let subst = su.ds.also([
1139         ("nick",  nick),
1140         ("token", token),
1141         ("pl",    &pl),
1142       ].iter());
1143       su.ds.otter(&subst
1144                   .ss("--super                          \
1145                        --account server:@nick@       \
1146                        --fixed-token @token@         \
1147                        join-game server::dummy")?)?;
1148       let w = su.new_window(instance, nick)?;
1149       let url = subst.subst("@url@/@pl@?@token@")?;
1150       su.w(&w)?.get(url)?;
1151       su.w(&w)?.screenshot("initial")?;
1152       w
1153     }
1154     StaticUser::iter().map(
1155       |u| mk(self, instance, u)
1156         .with_context(|| format!("{:?}", u))
1157         .context("make static user")
1158     )
1159       .collect::<Result<Vec<Window>,AE>>()?
1160   }
1161 }