1 // Copyright 2020 Ian Jackson
2 // SPDX-License-Identifier: AGPL-3.0-or-later
3 // There is NO WARRANTY.
5 #![feature(unboxed_closures)]
8 pub use anyhow::{anyhow, 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_derive::FromPrimitive;
16 pub use parking_lot::{Mutex, MutexGuard};
17 pub use regex::{Captures, Regex};
18 pub use serde::{Serialize, Deserialize};
19 pub use structopt::StructOpt;
20 pub use strum::{EnumIter, EnumProperty, IntoEnumIterator, IntoStaticStr};
21 pub use thirtyfour_sync as t4;
24 pub use t4::WebDriverCommands;
27 pub use std::fmt::{self, Debug};
29 pub use std::collections::hash_map::HashMap;
30 pub use std::convert::TryInto;
31 pub use std::io::{self, BufRead, BufReader, ErrorKind, Write};
33 pub use std::net::TcpStream;
34 pub use std::ops::Deref;
35 pub use std::os::unix::process::CommandExt;
36 pub use std::os::unix::fs::DirBuilderExt;
37 pub use std::os::linux::fs::MetadataExt; // todo why linux for st_mode??
39 pub use std::process::{Command, Stdio};
40 pub use std::thread::{self, sleep};
43 pub use otter::commands::{MgmtCommand, MgmtResponse};
44 pub use otter::commands::{MgmtGameInstruction, MgmtGameResponse};
45 pub use otter::commands::{MgmtGameUpdateMode};
46 pub use otter::gamestate::Generation;
47 pub use otter::global::InstanceName;
48 pub use otter::mgmtchannel::MgmtChannel;
50 pub type T4d = t4::WebDriver;
51 pub type WDE = t4::error::WebDriverError;
53 pub const MS : time::Duration = time::Duration::from_millis(1);
54 pub type AE = anyhow::Error;
56 pub const URL : &str = "http://localhost:8000";
59 use otter::config::DAEMON_STARTUP_REPORT;
61 const TABLE : &str = "server::dummy";
62 const CONFIG : &str = "server-config.toml";
64 #[derive(Copy,Clone,Debug,Eq,PartialEq,Ord,PartialOrd)]
65 #[derive(FromPrimitive,EnumIter,IntoStaticStr,EnumProperty)]
66 #[strum(serialize_all = "snake_case")]
68 #[strum(props(Token="kmqAKPwK4TfReFjMor8MJhdRPBcwIBpe"))] Alice,
69 #[strum(props(Token="ccg9kzoTh758QrVE1xMY7BQWB36dNJTx"))] Bob,
72 pub trait AlwaysContext<T,E> {
73 fn always_context(self, msg: &'static str) -> anyhow::Result<T>;
76 impl<T,E> AlwaysContext<T,E> for Result<T,E>
77 where Self: anyhow::Context<T,E>,
79 fn always_context(self, msg: &'static str) -> anyhow::Result<T> {
80 let x = self.context(msg);
81 if x.is_ok() { info!("completed {}.", msg) };
86 pub trait JustWarn<T> {
87 fn just_warn(self) -> Option<T>;
90 impl<T> JustWarn<T> for Result<T,AE> {
91 fn just_warn(self) -> Option<T> {
102 #[derive(Debug,Clone)]
105 #[structopt(long="--no-bwrap")]
108 #[structopt(long="--tmp-dir", default_value="tmp")]
111 #[structopt(long="--pause", default_value="0ms")]
112 pause: humantime::Duration,
114 #[structopt(long="--geckodriver-args", default_value="")]
115 geckodriver_args: String,
119 pub struct FinalInfoCollection;
121 type ScreenShotCount = u32;
122 type WindowState = Option<String>;
127 pub mgmt_conn: MgmtChannel,
129 current_window: WindowState,
130 screenshot_count: ScreenShotCount,
131 final_hook: FinalInfoCollection,
132 windows_squirreled: Vec<String>, // see Drop impl
135 #[derive(Clone,Debug)]
136 pub struct DirSubst {
139 pub start_dir: String,
143 pub struct Instance(InstanceName);
145 #[derive(Clone,Debug)]
146 pub struct Subst(HashMap<String,String>);
148 #[derive(Clone,Debug)]
149 pub struct ExtendedSubst<B: Substitutor, X: Substitutor>(B, X);
154 L: IntoIterator<Item=&'i (T, U)>>
157 fn from(l: L) -> Subst {
158 let map = l.into_iter()
159 .map(|(k,v)| (k.as_ref().to_owned(), v.as_ref().to_owned())).collect();
164 pub trait Substitutor {
165 fn get(&self, kw: &str) -> Option<String>;
167 fn also<L: Into<Subst>>(&self, xl: L) -> ExtendedSubst<Self, Subst>
168 where Self: Clone + Sized {
169 ExtendedSubst(self.clone(), xl.into())
173 fn subst<S: AsRef<str>>(&self, s: S) -> String
176 fn inner(self_: &dyn Substitutor, s: &dyn AsRef<str>) -> String {
178 let re = Regex::new(r"@(\w+)@").expect("bad re!");
179 let mut errs = vec![];
180 let out = re.replace_all(s, |caps: ®ex::Captures| {
181 let kw = caps.get(1).expect("$1 missing!").as_str();
182 if kw == "" { return "".to_owned() }
183 let v = self_.get(kw);
185 errs.push(kw.to_owned());
189 if ! errs.is_empty() {
190 throw!(anyhow!("bad substitution(s) {:?} in {:?}",
199 fn ss(&self, s: &str) -> Vec<String>
204 .filter(|s| !s.is_empty())
210 impl Substitutor for Subst {
211 fn get(&self, kw: &str) -> Option<String> {
212 self.0.get(kw).map(String::clone)
216 impl<B:Substitutor, X:Substitutor> Substitutor for ExtendedSubst<B, X> {
217 fn get(&self, kw: &str) -> Option<String> {
218 self.1.get(kw).or_else(|| self.0.get(kw))
222 impl Substitutor for DirSubst {
223 fn get(&self, kw: &str) -> Option<String> {
225 "url" => URL.to_owned(),
226 "src" => self.src.clone(),
227 "build" => self.start_dir.clone(),
228 "target" => format!("{}/target", &self.start_dir),
229 "specs" => format!("{}/specs" , &self.src ),
237 use fehler::{throw, throws};
239 use nix::errno::Errno::*;
240 use nix::{unistd::*, fcntl::OFlag};
241 use nix::sys::signal::*;
245 use std::os::unix::io::RawFd;
246 use std::panic::catch_unwind;
247 use std::process::Command;
248 type AE = anyhow::Error;
250 pub struct Handle(RawFd);
253 fn mkpipe() -> (RawFd,RawFd) {
254 pipe2(OFlag::O_CLOEXEC).map_err(nix2io)?
258 fn read_await(fd: RawFd) {
260 let mut buf = [0u8; 1];
261 match nix::unistd::read(fd, &mut buf) {
263 Ok(_) => throw!(io::Error::from_raw_os_error(libc::EINVAL)),
264 Err(Sys(EINTR)) => continue,
265 _ => throw!(io::Error::last_os_error()),
270 fn nix2io(_n: nix::Error) -> io::Error {
271 io::Error::last_os_error()
276 pub fn new() -> Self {
277 let (reading_end, _writing_end) = mkpipe()
278 .context("create cleanup notify pipe")?;
279 // we leak the writing end, keeping it open only in this process
284 pub fn arm_hook(&self, cmd: &mut Command) { unsafe {
285 use std::os::unix::process::CommandExt;
287 let notify_writing_end = self.0;
288 let all_signals = nix::sys::signal::SigSet::all();
290 cmd.pre_exec(move || -> Result<(), io::Error> {
291 let semidaemon = nix::unistd::getpid();
292 let (reading_end, writing_end) = mkpipe()?;
294 match fork().map_err(nix2io)? {
295 ForkResult::Child => {
296 let _ = catch_unwind(move || -> Void {
298 SigmaskHow::SIG_BLOCK,
303 let _ = close(writing_end);
305 if fd == notify_writing_end { continue }
307 if fd >= writing_end && matches!(r, Err(Sys(EBADF))) {
311 let _ = read_await(notify_writing_end);
312 let _ = kill(semidaemon, SIGTERM);
315 let _ = raise(SIGABRT);
318 ForkResult::Parent{..} => {
320 close(writing_end).map_err(nix2io)?;
321 read_await(reading_end)?;
332 fn reinvoke_via_bwrap(_opts: &Opts, current_exe: &str) -> Void {
333 debug!("running bwrap");
335 let mut bcmd = Command::new("bwrap");
337 .args("--unshare-net \
340 --die-with-parent".split(" "))
343 .args(env::args_os().skip(1));
345 std::io::stdout().flush().context("flush stdout")?;
346 let e : AE = bcmd.exec().into();
347 throw!(e.context("exec bwrap"));
351 fn prepare_tmpdir(opts: &Opts, current_exe: &str) -> DirSubst {
353 fn getcwd() -> String {
357 .ok_or_else(|| anyhow!("path is not UTF-8"))?
361 let start_dir = getcwd()
362 .context("canonicalise our invocation directory (getcwd)")?;
365 match fs::metadata(&opts.tmp_dir) {
368 throw!(anyhow!("existing object is not a directory"));
370 if (m.st_mode() & 0o01002) != 0 {
372 "existing directory mode {:#o} is sticky or world-writeable. \
373 We use predictable pathnames so that would be a tmp race",
378 Err(e) if e.kind() == ErrorKind::NotFound => {
379 fs::create_dir(&opts.tmp_dir)
383 let e : AE = e.into();
384 throw!(e.context("stat existing directory"))
388 env::set_current_dir(&opts.tmp_dir)
389 .context("chdir into it")?;
393 .with_context(|| opts.tmp_dir.to_owned())
394 .context("prepare/create tmp-dir")?;
396 let leaf = current_exe.rsplitn(2, '/').next().unwrap();
397 let our_tmpdir = format!("{}/{}", &opts.tmp_dir, &leaf);
399 match fs::remove_dir_all(&leaf) {
401 Err(e) if e.kind() == ErrorKind::NotFound => {},
402 Err(e) => throw!(AE::from(e).context("remove previous directory")),
405 fs::DirBuilder::new().create(&leaf)
406 .context("create fresh subdirectory")?;
408 env::set_current_dir(&leaf)
409 .context("chdir into it")?;
413 .with_context(|| our_tmpdir.to_owned())
414 .context("prepare/create our tmp subdir")?;
417 getcwd().context("canonicalise our tmp subdir (getcwd)")?;
419 env::set_var("HOME", &abstmp);
420 env::set_var("TMPDIR", &abstmp);
421 for v in "http_proxy https_proxy XAUTHORITY CDPATH \
422 SSH_AGENT_PID SSH_AUTH_SOCK WINDOWID WWW_HOME".split(' ')
427 let manifest_var = "CARGO_MANIFEST_DIR";
428 let src : String = (|| Ok::<_,AE>(match env::var(manifest_var) {
429 Ok(dir) => dir.into(),
430 Err(env::VarError::NotPresent) => start_dir.clone(),
431 e@ Err(_) => throw!(e.context(manifest_var).err().unwrap()),
433 .context("find source code")?;
444 fn fork_something_which_prints(mut cmd: Command,
445 cln: &cleanup_notify::Handle,
450 cmd.stdout(Stdio::piped());
451 cln.arm_hook(&mut cmd)?;
452 let mut child = cmd.spawn().context("spawn")?;
453 let mut report = BufReader::new(child.stdout.take().unwrap())
456 let l = report.next();
458 let s = child.try_wait().context("check on spawned child")?;
460 throw!(anyhow!("failed to start: wait status = {}", &e));
465 None => throw!(anyhow!("EOF (but it's still running?")),
466 Some(Err(e)) => throw!(AE::from(e).context("failed to read")),
469 let what = what.to_owned();
470 thread::spawn(move|| (||{
472 let l : Result<String, io::Error> = l;
473 let l = l.context("reading further output")?;
474 const MAXLEN : usize = 300;
475 if l.len() <= MAXLEN {
476 println!("{} {}", what, l);
478 println!("{} {}...", what, &l[..MAXLEN-3]);
482 })().context(what).just_warn()
486 })().with_context(|| what.to_owned())?
490 fn prepare_xserver(cln: &cleanup_notify::Handle, ds: &DirSubst) {
491 const DISPLAY : u16 = 12;
493 let mut xcmd = Command::new("Xvfb");
495 .args("-nolisten unix \
501 -displayfd 1".split(' '))
502 .args(&["-fbdir", &ds.abstmp])
503 .arg(format!(":{}", DISPLAY));
505 let l = fork_something_which_prints(xcmd, cln, "Xvfb")?;
507 if l != DISPLAY.to_string() {
509 "Xfvb said {:?}, expected {:?}",
514 let display = format!("[::1]:{}", DISPLAY);
515 env::set_var("DISPLAY", &display);
517 // Doesn't do IPv6 ??
518 let v4display = format!("127.0.0.1:{}", DISPLAY);
519 let (xconn, _) = x11rb::connect(Some(&v4display))
520 .context("make keepalive connection to X server")?;
522 // Sadly, if we die between spawning Xfvb, and here, we will
523 // permanently leak the whole Xfvb process (and the network
524 // namespace). There doesn't seem to a way to avoid this without
527 Box::leak(Box::new(xconn));
531 fn prepare_gameserver(cln: &cleanup_notify::Handle, ds: &DirSubst)
533 let subst = ds.also(&[
534 ("command_socket", "command.socket"),
536 let config = subst.subst(r##"
541 command_socket = "@command_socket@"
542 template_dir = "@src@/templates"
543 nwtemplate_dir = "@src@/nwtemplates"
544 bundled_sources = "@target@/bundled-sources"
545 wasm_dir = "@target@/packed-wasm"
546 shapelibs = [ "@src@/library/*.toml" ]
548 debug_js_inject_file = "@src@/templates/log-save.js"
551 global_level = 'debug'
556 # ^ comment these two out to see Tera errors, *sigh*
558 'hyper::server' = 'info'
559 "game::debugreader" = 'info'
560 "game::updates" = 'trace'
563 fs::write(CONFIG, &config)
564 .context(CONFIG).context("create server config")?;
566 let server_exe = ds.subst("@target@/debug/daemon-otter")?;
567 let mut cmd = Command::new(&server_exe);
569 .arg("--report-startup")
573 let l = fork_something_which_prints(cmd, cln, &server_exe)?;
574 if l != DAEMON_STARTUP_REPORT {
575 throw!(anyhow!("otter-daemon startup report {:?}, expected {:?}",
576 &l, DAEMON_STARTUP_REPORT));
580 .context("game server")?;
582 let mut mgmt_conn = MgmtChannel::connect(
583 &subst.subst("@command_socket@")?
586 mgmt_conn.cmd(&MgmtCommand::SetSuperuser(true))?;
587 mgmt_conn.cmd(&MgmtCommand::SelectAccount("server:".parse()?))?;
594 pub fn otter<S:AsRef<str>>(&self, xargs: &[S]) {
596 let exe = ds.subst("@target@/debug/otter")?;
597 let mut args : Vec<&str> = vec![];
598 args.extend(&["--config", CONFIG]);
599 args.extend(xargs.iter().map(AsRef::as_ref));
600 let dbg = format!("running {} {:?}", &exe, &args);
603 let mut cmd = Command::new(&exe);
606 .spawn().context("spawn")?
607 .wait().context("wait")?;
609 throw!(anyhow!("wait status {}", &st));
614 .context("run otter client")?;
619 pub fn prepare_game(ds: &DirSubst, table: &str) -> InstanceName {
620 let subst = ds.also(&[("table", &table)]);
624 --reset-table @specs@/test.table.toml \
625 @table@ @specs@/demo.game.toml \
626 ")?).context("reset table")?;
628 let instance : InstanceName = table.parse()
629 .with_context(|| table.to_owned())
630 .context("parse table name")?;
636 fn prepare_geckodriver(opts: &Opts, cln: &cleanup_notify::Handle) {
637 const EXPECTED : &str = "Listening on 127.0.0.1:4444";
638 let mut cmd = Command::new("geckodriver");
639 if opts.geckodriver_args != "" {
640 cmd.args(opts.geckodriver_args.split(' '));
642 let l = fork_something_which_prints(cmd, cln, "geckodriver")?;
643 let fields : Vec<_> = l.split('\t').skip(2).take(2).collect();
644 let expected = ["INFO", EXPECTED];
645 if fields != expected {
646 throw!(anyhow!("geckodriver did not report as expected \
647 - got {:?}, expected {:?}",
653 fn prepare_thirtyfour() -> (T4d, ScreenShotCount, Vec<String>) {
655 let mut caps = t4::DesiredCapabilities::firefox();
656 let prefs : HashMap<_,_> = [
657 ("devtools.console.stdout.content", true),
658 ].iter().cloned().collect();
659 caps.add("prefs", prefs)?;
660 caps.add("stdio", "inherit")?;
661 let mut driver = t4::WebDriver::new("http://localhost:4444", &caps)
662 .context("create 34 WebDriver")?;
664 const FRONT : &str = "front";
665 let window_names = vec![FRONT.into()];
666 driver.set_window_name(FRONT).context("set initial window name")?;
667 screenshot(&mut driver, &mut count, "startup")?;
668 driver.get(URL).context("navigate to front page")?;
669 screenshot(&mut driver, &mut count, "front")?;
671 fetch_log(&driver, "front")?;
673 let t = Some(5_000 * MS);
674 driver.set_timeouts(t4::TimeoutConfiguration::new(t,t,t))
675 .context("set webdriver timeouts")?;
677 (driver, count, window_names)
680 /// current window must be `name`
682 fn fetch_log(driver: &T4d, name: &str) {
684 let got = driver.execute_script(r#"
685 var returning = window.console.saved;
686 window.console.saved = [];
688 "#).context("get log")?;
690 for ent in got.value().as_array()
691 .ok_or(anyhow!("saved isn't an array?"))?
693 #[derive(Deserialize)]
694 struct LogEnt(String, Vec<serde_json::Value>);
695 impl fmt::Display for LogEnt {
696 #[throws(fmt::Error)]
697 fn fmt(&self, f: &mut fmt::Formatter) {
698 write!(f, "{}:", self.0)?;
699 for a in &self.1 { write!(f, " {}", a)?; }
703 let ent: LogEnt = serde_json::from_value(ent.clone())
704 .context("parse log entry")?;
706 debug!("JS {} {}", name, &ent);
710 .with_context(|| name.to_owned())
711 .context("fetch JS log messages")?;
717 instance: InstanceName,
720 pub struct WindowGuard<'g> {
725 impl Debug for WindowGuard<'_> {
726 #[throws(fmt::Error)]
727 fn fmt(&self, f: &mut fmt::Formatter) {
728 f.debug_struct("WindowGuard")
729 .field("w.name", &self.w.name)
730 .field("w.instance", &self.w.instance.to_string())
736 fn check_window_name_sanity(name: &str) -> &str {
737 let e = || anyhow!("bad window name {:?}", &name);
739 name.chars().nth(0).ok_or(e())?
740 .is_ascii_alphanumeric().ok_or(e())?;
743 |c| c.is_ascii_alphanumeric() || c == '-' || c == '_'
751 pub fn new_window<'s>(&'s mut self, instance: &Instance, name: &str)
753 let name = check_window_name_sanity(name)?;
756 self.current_window = None; // we might change the current window
758 match self.driver.switch_to().window_name(name) {
759 Ok(()) => throw!(anyhow!("window already exists")),
760 Err(WDE::NoSuchWindow(_)) |
761 Err(WDE::NotFound(..)) => (),
763 eprintln!("wot {:?}", &e);
765 .context("check for pre-existing window")
770 self.driver.execute_script(&format!(
771 r#"window.open('', target='{}');"#,
774 .context("execute script to create window")?;
777 name: name.to_owned(),
778 instance: instance.0.clone(),
781 .with_context(|| name.to_owned())
782 .context("create window")?;
784 self.windows_squirreled.push(name.to_owned());
791 pub fn w<'s>(&'s mut self, w: &'s Window) -> WindowGuard<'s> {
792 if self.current_window.as_ref() != Some(&w.name) {
793 self.driver.switch_to().window_name(&w.name)
794 .with_context(|| w.name.to_owned())
795 .context("switch to window")?;
796 self.current_window = Some(w.name.clone());
798 WindowGuard { su: self, w }
802 impl<'g> Deref for WindowGuard<'g> {
804 fn deref(&self) -> &T4d { &self.su.driver }
807 impl<'g> Drop for WindowGuard<'g> {
809 fetch_log(&self.su.driver, &self.w.name)
814 pub trait Screenshottable {
815 fn screenshot(&mut self, slug: &str) -> Result<(),AE>;
818 impl<'g> Screenshottable for WindowGuard<'g> {
820 fn screenshot(&mut self, slug: &str) {
821 screenshot(&self.su.driver, &mut self.su.screenshot_count,
822 &format!("{}-{}", &self.w.name, slug))?
827 fn screenshot(driver: &T4d, count: &mut ScreenShotCount, slug: &str) {
828 let path = format!("{:03}{}.png", count, slug);
830 driver.screenshot(&path::PathBuf::from(&path))
832 .context("take screenshot")?;
835 impl<'g> WindowGuard<'g> {
837 pub fn synch(&mut self) {
838 let cmd = MgmtCommand::AlterGame {
839 game: self.w.instance.clone(),
840 how: MgmtGameUpdateMode::Online,
841 insns: vec![ MgmtGameInstruction::Synch ],
844 let resp = self.su.mgmt_conn.cmd(&cmd)?;
845 if let MgmtResponse::AlterGame {
849 if let [MgmtGameResponse::Synch(gen)] = responses[..];
851 else { throw!(anyhow!("unexpected resp to synch {:?}", resp)) }
853 trace!("{:?} gen={} ...", self, gen);
856 let tgen = self.su.driver.execute_async_script(
858 ("wanted", &gen.to_string())
860 var done = arguments[0];
861 if (gen >= @wanted@) { done(gen); return; }
862 gen_update_hook = function() {
863 gen_update_hook = function() { };
868 .context("run async script")?
869 .value().as_u64().ok_or(anyhow!("script return is not u64"))?;
870 let tgen = Generation(tgen);
871 trace!("{:?} gen={} tgen={}", self, gen, tgen);
872 if tgen >= gen { break; }
876 .context("await gen update via async js script")?;
880 impl Drop for FinalInfoCollection {
882 nix::unistd::linkat(None, "Xvfb_screen0",
883 None, "Xvfb_keep.xwd",
884 LinkatFlags::NoSymlinkFollow)
885 .context("preserve Xvfb screen")
890 impl Drop for Setup {
893 for name in mem::take(&mut self.windows_squirreled) {
894 // This constructor is concurrency-hazardous. It's only OK
895 // here because we have &mut self. If there is only one
896 // Setup, there can be noone else with a Window with an
897 // identical name to be interfered with by us.
900 instance: TABLE.parse().context(TABLE)?,
902 self.w(&w)?.screenshot("final")
904 .context("final screenshot")
909 .context("screenshots, in Setup::drop")
915 pub fn setup(exe_module_path: &str) -> (Setup, Instance) {
916 env_logger::Builder::new()
917 .format_timestamp_micros()
919 .filter_module("otter_webdriver_tests", log::LevelFilter::Debug)
920 .filter_module(exe_module_path, log::LevelFilter::Debug)
921 .filter_level(log::LevelFilter::Info)
922 .parse_env("OTTER_WDT_LOG")
926 let current_exe : String = env::current_exe()
927 .context("find current executable")?
929 .ok_or_else(|| anyhow!("current executable path is not UTF-8 !"))?
932 let opts = Opts::from_args();
934 reinvoke_via_bwrap(&opts, ¤t_exe)
935 .context("reinvoke via bwrap")?;
938 info!("pid = {}", nix::unistd::getpid());
939 sleep(opts.pause.into());
941 let cln = cleanup_notify::Handle::new()?;
942 let ds = prepare_tmpdir(&opts, ¤t_exe)?;
944 prepare_xserver(&cln, &ds).always_context("setup X server")?;
947 prepare_gameserver(&cln, &ds).always_context("setup game server")?;
950 prepare_game(&ds, TABLE).context("setup game")?;
952 let final_hook = FinalInfoCollection;
954 prepare_geckodriver(&opts, &cln).always_context("setup webdriver server")?;
955 let (driver, screenshot_count, windows_squirreled) =
956 prepare_thirtyfour().always_context("prepare web session")?;
963 current_window: None,
974 pub fn setup_static_users(&mut self, instance: &Instance) -> Vec<Window> {
976 fn mk(su: &mut Setup, instance: &Instance, u: StaticUser) -> Window {
977 let nick: &str = u.into();
978 let token = u.get_str("Token").expect("StaticUser missing Token");
979 let subst = su.ds.also([("nick", nick),
980 ("token", token)].iter());
983 --account server:@nick@ \
984 --fixed-token @token@ \
985 join-game server::dummy")?)?;
986 let w = su.new_window(instance, nick)?;
987 let url = subst.subst("@url@/?@token@")?;
989 su.w(&w)?.screenshot("initial")?;
992 StaticUser::iter().map(
993 |u| mk(self, instance, u)
994 .with_context(|| format!("{:?}", u))
995 .context("make static user")
997 .collect::<Result<Vec<Window>,AE>>()?