chiark / gitweb /
apitest: Move a lot of code from at-otter to apitest/main
authorIan Jackson <ijackson@chiark.greenend.org.uk>
Mon, 17 May 2021 17:15:10 +0000 (18:15 +0100)
committerIan Jackson <ijackson@chiark.greenend.org.uk>
Mon, 17 May 2021 17:15:28 +0000 (18:15 +0100)
Signed-off-by: Ian Jackson <ijackson@chiark.greenend.org.uk>
apitest/at-bundles.rs
apitest/at-otter.rs
apitest/main.rs

index a286802c7a6d7d1a7d49e6c3b02768d2a922a4e4..516ab88bb7ece8fcda6faae8c199662cb7038e67 100644 (file)
@@ -12,6 +12,9 @@ struct Ctx {
   su_rc: Setup,
 }
 
+impl Ctx {
+}
+
 #[throws(Explode)]
 fn tests(_c: Ctx) {
 }
index b0e6aedb49a37fe5327fbe089c949711b9920c85..fb1843d4f35a8013ced67fff5a80ac9593644bed 100644 (file)
@@ -4,535 +4,9 @@
 
 use crate::*;
 
-pub use index_vec::Idx;
-
-#[allow(dead_code)]
-struct Ctx {
-  opts: Opts,
-  su_rc: Setup,
-  spec: GameSpec,
-  alice: Player,
-  bob: Player,
-  prctx: PathResolveContext,
-}
-
-impl Ctx {
-  fn su(&self) -> std::cell::Ref<SetupCore> { RefCell::borrow(&self.su_rc) }
-  fn su_mut(&self) -> RefMut<SetupCore> { self.su_rc.borrow_mut() }
-
-  fn wanted_tests(&self) -> TrackWantedTestsGuard {
-    TrackWantedTestsGuard(self.su_mut())
-  }
-}
-struct TrackWantedTestsGuard<'m>(RefMut<'m, SetupCore>);
-deref_to_field_mut!{TrackWantedTestsGuard<'_>,
-                    TrackWantedTests,
-                    0.wanted_tests}
-
-#[derive(Debug)]
-struct Player {
-  pub nick: &'static str,
-  url: String,
-}
-
-struct Session {
-  pub nick: &'static str,
-  pub su_rc: Setup,
-  pub ctoken: RawToken,
-  pub gen: Generation,
-  pub cseq: RawClientSequence,
-  pub dom: scraper::Html,
-  pub updates: mpsc::Receiver<Update>,
-  pub client: reqwest::blocking::Client,
-}
-
-mod scraper_ext {
-  use super::*;
-  use scraper::*;
-  use scraper::html::{*, Html};
-
-  #[ext(pub)]
-  impl Html {
-    fn select<'a,'b>(&'a self, selector: &'b Selector) -> Select<'a, 'b> {
-      self.select(selector)
-    }
-
-    #[throws(as Option)]
-    fn element<S>(&self, sel: S) -> ElementRef
-    where S: TryInto<Selector>,
-          <S as TryInto<Selector>>::Error: Debug,
-    {
-      self
-        .select(&sel.try_into().unwrap())
-        .next()?
-    }
-
-    #[throws(as Option)]
-    fn e_attr<S>(&self, sel: S, attr: &str) -> &str
-    where S: TryInto<Selector>,
-          <S as TryInto<Selector>>::Error: Debug,
-    {
-      self
-        .element(sel).unwrap()
-        .value().attr(attr)?
-    }
-  }
-
-  #[throws(Explode)]
-  pub fn parse_html(resp: reqwest::blocking::Response) -> Html {
-    assert_eq!(resp.status(), 200);
-    let body = resp.text()?;
-    let dom = scraper::Html::parse_document(&body);
-    //dbgc!(&&dom);
-    dom
-  }
-
-  #[ext(pub, name=RequestBuilderExt)]
-  impl reqwest::blocking::RequestBuilder {
-    #[throws(Explode)]
-    fn send(self) -> reqwest::blocking::Response { self.send()? }
-
-    #[throws(Explode)]
-    fn send_parse_html(self) -> Html {
-      let resp = self.send()?;
-      parse_html(resp)?
-    }
-  }
-}
-
-use scraper_ext::{HtmlExt, RequestBuilderExt};
-
-type Update = JsV;
-
-#[throws(Explode)]
-fn updates_parser<R:Read>(input: R, out: &mut mpsc::Sender<Update>) {
-  let mut accum: HashMap<String, String> = default();
-  for l in BufReader::new(input).lines() {
-    let l = l?;
-    if ! l.is_empty() {
-      let mut l = l.splitn(2, ':');
-      let lhs = l.next().unwrap();
-      let rhs = l.next().unwrap();
-      let rhs = rhs.trim_start();
-      let () = accum.insert(lhs.to_string(), rhs.to_string())
-        .is_none().expect("duplicate field");
-      continue;
-    }
-    let entry = mem::take(&mut accum);
-    #[allow(unused_variables)] let accum = (); // stops accidental use of accum
-    if entry.get("event").map(String::as_str) == Some("commsworking") {
-      eprintln!("commsworking: {}", entry["data"]);
-    } else if let Some(event) = entry.get("event") {
-      panic!("unexpected event: {}", event);
-    } else {
-      let update = &entry["data"];
-      let update = serde_json::from_str(update).unwrap();
-      if out.send(update).is_err() { break }
-    }
-  }
-}
-
-impl Ctx {
-  #[throws(Explode)]
-  fn connect_player<'su>(&self, player: &Player) -> Session {
-    let client = reqwest::blocking::Client::new();
-    let loading = client.get(&player.url).send_parse_html()?;
-    let ptoken = loading.e_attr("#loading_token", "data-ptoken").unwrap();
-    dbgc!(&ptoken);
-
-    let session = client.post(&self.su().ds.subst("@url@/_/session/Portrait")?)
-      .json(&json!({ "ptoken": ptoken }))
-      .send_parse_html()?;
-
-    let ctoken = session.e_attr("#main-body", "data-ctoken").unwrap();
-    dbgc!(&ctoken);
-
-    let gen: Generation = Generation(
-      session.e_attr("#main-body", "data-gen").unwrap()
-        .parse().unwrap()
-    );
-    dbgc!(gen);
-
-    let mut sse = client.get(
-      &self.su().ds
-        .also(&[("ctoken", ctoken),
-                ("gen",    &gen.to_string())])
-        .subst("@url@/_/updates?ctoken=@ctoken@&gen=@gen@")?
-    ).send()?;
-
-    let (mut wpipe, rpipe) = UnixStream::pair()?;
-    thread::spawn(move ||{
-      eprintln!("copy_to'ing");
-      match sse.copy_to(&mut wpipe) {
-        Err(re) => match (||{
-          // reqwest::Error won't give us the underlying io::Error :-/
-          wpipe.write_all(b"\n")?;
-          wpipe.flush()?;
-          Ok::<_,io::Error>(())
-        })() {
-          Err(pe) if pe.kind() == ErrorKind::BrokenPipe => { Ok(()) }
-          Err(pe) => Err(AE::from(pe)),
-          Ok(_) => Err(AE::from(re)),
-        }
-        Ok(_n) => Ok(()),
-      }.unwrap();
-      eprintln!("copy_to'd!"); 
-    });
-
-    let (mut csend, crecv) = mpsc::channel();
-    thread::spawn(move ||{
-      updates_parser(rpipe, &mut csend).expect("udpates parser failed")
-    });
-
-    Session {
-      nick: player.nick,
-      client, gen,
-      cseq: 42,
-      ctoken: RawToken(ctoken.to_string()),
-      dom: session,
-      updates: crecv,
-      su_rc: self.su_rc.clone(),
-    }
-  }
-
-  pub fn chdir_root<F>(&mut self, f: F)
-  where F: FnOnce(&mut Self) -> Result<(),Explode>
-  {
-    let tmp = self.su().ds.abstmp.clone();
-    env::set_current_dir("/").expect("cd /");
-    self.prctx = PathResolveContext::RelativeTo(tmp.clone());
-    f(self).expect("run test");
-    env::set_current_dir(&tmp).expect("cd back");
-    self.prctx = default();
-  }
-}
-
-mod pi {
-  use otter::prelude::define_index_type;
-  define_index_type!{ pub struct PIA = usize; }
-  define_index_type!{ pub struct PIB = usize; }
-}
-pub use pi::*;
-
-type Pieces<PI> = IndexVec<PI, PieceInfo<JsV>>;
-type PiecesSlice<PI> = IndexSlice<PI,[PieceInfo<JsV>]>;
-
-#[derive(Debug,Clone)]
-pub struct PieceInfo<I> {
-  id: String,
-  pos: Pos,
-  info: I,
-}
-
-impl Session {
-  #[throws(Explode)]
-  fn pieces<PI:Idx>(&self) -> Pieces<PI> {
-    let pieces = self.dom
-      .element("#pieces_marker")
-      .unwrap().next_siblings()
-      .map_loop(|puse: ego_tree::NodeRef<scraper::Node>| {
-        let puse = puse.value();
-        let puse = puse.as_element().ok_or(Loop::Continue)?;
-        let attr = puse.attr("data-info").ok_or(Loop::Break)?;
-        let pos = Pos::from_iter(["x","y"].iter().map(|attr|{
-          puse
-            .attr(attr).unwrap()
-            .parse().unwrap()
-        })).unwrap();
-        let id = puse.id.as_ref().unwrap();
-        let id = id.strip_prefix("use").unwrap().to_string();
-        let info = serde_json::from_str(attr).unwrap();
-        Loop::ok(PieceInfo { id, pos, info })
-      })
-      .collect();
-    let nick = self.nick;
-    dbgc!(nick, &pieces);
-    pieces
-  }
-
-  #[throws(Explode)]
-  fn api_piece_op_single<O:PieceOp>(&mut self, piece: &str, o: O) {
-    let (opname, payload) = if let Some(o) = o.api() { o } else { return };
-
-    self.cseq += 1;
-    let cseq = self.cseq;
-
-    let su = self.su_rc.borrow_mut();
-    let resp = self.client.post(&su.ds.also(&[("opname",opname)])
-                                .subst("@url@/_/api/@opname@")?)
-      .json(&json!({
-        "ctoken": self.ctoken,
-        "piece": piece,
-        "gen": self.gen,
-        "cseq": cseq,
-        "op": payload,
-      }))
-      .send()?;
-    assert_eq!(resp.status(), 200);
-  }
-
-  #[throws(Explode)]
-  fn api_piece<P:PieceSpecForOp, O:PieceOp>(
-    &mut self, g: GrabHow, mut p: P, o: O
-  ) {
-    if let GH::With | GH::Grab = g {
-      self.api_piece_op_single(p.id(), ("grab", json!({})))?;
-    }
-    if let Some(u) = p.for_update() {
-      o.update(u);
-    }
-    {
-      self.api_piece_op_single(p.id(), o)?;
-    }
-    if let Some(s) = p.for_synch() {
-      self.synchu(s)?;
-    }
-    if let GH::With | GH::Ungrab = g {
-      self.api_piece_op_single(p.id(), ("ungrab", json!({})))?;
-    }
-  }
-
-  #[throws(Explode)]
-  fn await_update<
-    R,
-    F: FnMut(&mut Session, Generation, &str, &JsV) -> Option<R>,
-    G: FnMut(&mut Session, Generation) -> Option<R>,
-    E: FnMut(&mut Session, Generation, &JsV)
-             -> Result<Option<R>, AE>
-   > (&mut self, mut g: G, mut f: F, mut ef: Option<E>) -> R {
-    let nick = self.nick;
-    'overall: loop {
-      let update = self.updates.recv()?;
-      let update = update.as_array().unwrap();
-      let new_gen = Generation(
-        update[0]
-          .as_i64().unwrap()
-          .try_into().unwrap()
-      );
-      self.gen = new_gen;
-      dbgc!(nick, new_gen);
-      if let Some(y) = g(self, new_gen) { break 'overall y }
-      for ue in update[1].as_array().unwrap() {
-        let (k,v) = ue.as_object().unwrap().iter().next().unwrap();
-        dbgc!(nick, k, &v);
-        if let Some(y) = {
-          if k != "Error" {
-            f(self, new_gen, k, v)
-          } else if let Some(ef) = &mut ef {
-            ef(self, new_gen, v)?
-          } else {
-            panic!("synch error: {:?}", &(k, v));
-          }
-        } { break 'overall y }
-      }
-    }
-  }
-
-  #[throws(Explode)]
-  fn synchx<
-    PI: Idx,
-    F: FnMut(&mut Session, Generation, &str, &JsV),
-  > (&mut self,
-     mut pieces: Option<&mut Pieces<PI>>,
-     ef: Option<&mut dyn FnMut(&mut Session, Generation, &JsV)
-                               -> Result<(), AE>>,
-     mut f: F
-  ) {
-    let exp = {
-      self.su_rc.borrow_mut().mgmt_conn()
-        .game_synch(TABLE.parse().unwrap())?
-    };
-    let efwrap = ef.map(|ef| {
-      move |s: &mut _, g, v: &_| { ef(s,g,v)?; Ok::<_,AE>(None) }
-    });
-    self.await_update(
-      |_session, gen      | (gen == exp).as_option(),
-      | session, gen, k, v| {
-        if let Some(pieces) = pieces.as_mut() {
-          update_update_pieces(&session.nick, pieces, k, v);
-        }
-        f(session,gen,k,v);
-        None
-      },
-      efwrap,
-    )?;
-  }
-
-  #[throws(Explode)]
-  fn synchu<PI:Idx>(&mut self, pieces: &mut Pieces<PI>) {
-    self.synchx(Some(pieces), None, |_session, _gen, _k, _v| ())?;
-  }
-
-  #[throws(Explode)]
-  fn synch(&mut self) {
-    self.synchx::<PIA,_>(None, None, |_session, _gen, _k, _v|())?;
-  }
-}
-
-pub fn update_update_pieces<PI:Idx>(
-  nick: &str,
-  pieces: &mut Pieces<PI>,
-  k: &str, v: &JsV
-) {
-  if k != "Piece" { return }
-  let v = v.as_object().unwrap();
-  let piece = v["piece"].as_str().unwrap();
-  let p = pieces.iter_mut().find(|p| p.id == piece);
-  if_let!{ Some(p) = p; else return }
-  let (op, d) = v["op"].as_object().unwrap().iter().next().unwrap();
-
-  fn coord(j: &JsV) -> Pos {
-    PosC::from_iter_2(
-      j.as_array().unwrap().iter()
-        .map(|n| n.as_i64().unwrap().try_into().unwrap())
-    )
-  }
-
-  match op.as_str() {
-    "Move" => {
-      p.pos = coord(d);
-    },
-    "Modify" | "ModifyQuiet" => {
-      let d = d.as_object().unwrap();
-      p.pos = coord(&d["pos"]);
-      for (k,v) in d {
-        p.info
-          .as_object_mut().unwrap()
-          .insert(k.to_string(), v.clone());
-      }
-    },
-    _ => {
-      panic!("unknown op {:?} {:?}", &op, &d);
-    },
-  };
-  dbgc!(nick, k,v,p);
-}
-
-pub type PieceOpData = (&'static str, JsV);
-pub trait PieceOp: Debug {
-  fn api(&self) -> Option<PieceOpData>;
-  fn update(&self, _pi: &mut PieceInfo<JsV>) { info!("no update {:?}", self) }
-}
-impl PieceOp for PieceOpData {
-  fn api(&self) -> Option<PieceOpData> { Some((self.0, self.1.clone())) }
-}
-impl PieceOp for Pos {
-  fn api(&self) -> Option<PieceOpData> { Some(("m", json![self.coords])) }
-  fn update(&self, pi: &mut PieceInfo<JsV>) { pi.pos = *self }
-}
-impl PieceOp for () {
-  fn api(&self) -> Option<PieceOpData> { None }
-  fn update(&self, _pi: &mut PieceInfo<JsV>) {  }
-}
-
-pub trait PieceSpecForOp: Debug {
-  fn id(&self) -> &str;
-  type PI: Idx;
-  fn for_update(&mut self) -> Option<&mut PieceInfo<JsV>> { None }
-  fn for_synch(&mut self) -> Option<&mut Pieces<Self::PI>> { None }
-}
-
-impl PieceSpecForOp for str {
-  type PI = PIA;
-  fn id(&self) -> &str { self }
-}
-impl PieceSpecForOp for &String {
-  type PI = PIA;
-  fn id(&self) -> &str { self }
-}
-
-type PuUp<'pcs, PI> = (&'pcs mut Pieces<PI>, PI);
-#[derive(Debug)]
-/// Synchronise after op but before any ungrab.
-pub struct PuSynch<T>(T);
-
-macro_rules! impl_PieceSpecForOp {
-  ($($amp:tt $mut:tt)?) => {
-
-    impl<PI:Idx> PieceSpecForOp for $($amp $mut)? PuUp<'_, PI> {
-      type PI = PI;
-      fn id(&self) -> &str { &self.0[self.1].id }
-      fn for_update(&mut self) -> Option<&mut PieceInfo<JsV>> {
-        Some(&mut self.0[self.1])
-      }
-    }
-
-    impl<PI:Idx> PieceSpecForOp for PuSynch<$($amp $mut)? PuUp<'_,PI>> {
-      type PI = PI;
-      fn id(&self) -> &str { self.0.id() }
-      fn for_update(&mut self) -> Option<&mut PieceInfo<JsV>> {
-        self.0.for_update()
-      }
-      fn for_synch(&mut self) -> Option<&mut Pieces<PI>> {
-        Some(self.0.0)
-      }
-    }
-  }
-}
-impl_PieceSpecForOp!{}
-impl_PieceSpecForOp!{&mut}
-
-#[derive(Debug,Copy,Clone)]
-pub enum GrabHow { Raw, Grab, Ungrab, With }
-pub use GrabHow as GH;
-
+type Ctx = UsualCtx;
 
 impl Ctx {
-  #[throws(AE)]
-  pub fn otter<S:AsRef<str>>(&mut self, args: &[S]) -> OtterOutput {
-    let args: Vec<String> =
-      ["--account", "server:"].iter().cloned().map(Into::into)
-      .chain(args.iter().map(|s| s.as_ref().to_owned()))
-      .collect();
-    self.su().ds.otter_prctx(&self.prctx, &args)?
-  }
-
-  #[throws(Explode)]
-  fn place_library_load_markers(&mut self) -> Session {
-    let mut session = self.connect_player(&self.alice)?;
-    let pieces = session.pieces::<PIA>()?;
-    let llm = pieces.into_iter()
-      .filter(|pi| pi.info["desc"] == "a library load area marker")
-      .collect::<ArrayVec<_>>();
-    let llm: [_;2] = llm.into_inner().unwrap();
-    dbgc!(&llm);
-
-    for (llm, &pos) in izip!(&llm, [PosC::new(5,5), PosC::new(50,25)].iter())
-    {
-      session.api_piece(GH::With, &llm.id, pos)?;
-    }
-
-    session.synch()?;
-    session
-  }
-
-  #[throws(Explode)]
-  fn some_library_add(&mut self, command: &[String]) -> Vec<String> {
-    let add_err = self.otter(command)
-      .expect_err("library-add succeeded after reset!");
-    assert_eq!(add_err.downcast::<ExitStatusError>()?.0.code(),
-               Some(EXIT_NOTFOUND));
-
-    let mut session = self.place_library_load_markers()?;
-
-    self.otter(&command)
-      .expect("library-add failed after place!");
-
-    let mut added = vec![];
-    session.synchx::<PIA,_>(None, None,
-      |_session, _gen, k, v| if_chain! {
-        if k == "Piece";
-        let piece = v["piece"].as_str().unwrap().to_string();
-        let op = v["op"].as_object().unwrap();
-        if let Some(_) = op.get("Insert");
-        then { added.push(piece); }
-      }
-    )?;
-
-    dbgc!(&added);
-    added
-  }
-
   #[throws(Explode)]
   fn library_load(&mut self) {
     prepare_game(&self.su().ds, &self.prctx, TABLE)?;
index 71dda64caaf234f7e85248b6f02a27d1bd791bfd..98afbb66892a6ae4430f722ec44441e6019e54c9 100644 (file)
@@ -7,7 +7,538 @@ pub use otter_api_tests::*;
 pub use std::cell::{RefCell, RefMut};
 pub use std::rc::Rc;
 
-type Setup = Rc<RefCell<SetupCore>>;
+pub type Setup = Rc<RefCell<SetupCore>>;
+
+pub use index_vec::Idx;
+
+struct TrackWantedTestsGuard<'m>(RefMut<'m, SetupCore>);
+deref_to_field_mut!{TrackWantedTestsGuard<'_>,
+                    TrackWantedTests,
+                    0.wanted_tests}
+
+#[allow(dead_code)]
+struct UsualCtx {
+  opts: Opts,
+  su_rc: Setup,
+  spec: GameSpec,
+  alice: Player,
+  bob: Player,
+  prctx: PathResolveContext,
+}
+
+impl UsualCtx {
+  fn su(&self) -> std::cell::Ref<SetupCore> { RefCell::borrow(&self.su_rc) }
+  fn su_mut(&self) -> RefMut<SetupCore> { self.su_rc.borrow_mut() }
+
+  fn wanted_tests(&self) -> TrackWantedTestsGuard {
+    TrackWantedTestsGuard(self.su_mut())
+  }
+}
+
+#[derive(Debug)]
+struct Player {
+  pub nick: &'static str,
+  url: String,
+}
+
+struct Session {
+  pub nick: &'static str,
+  pub su_rc: Setup,
+  pub ctoken: RawToken,
+  pub gen: Generation,
+  pub cseq: RawClientSequence,
+  pub dom: scraper::Html,
+  pub updates: mpsc::Receiver<Update>,
+  pub client: reqwest::blocking::Client,
+}
+
+mod scraper_ext {
+  use super::*;
+  use scraper::*;
+  use scraper::html::{*, Html};
+
+  #[ext(pub)]
+  impl Html {
+    fn select<'a,'b>(&'a self, selector: &'b Selector) -> Select<'a, 'b> {
+      self.select(selector)
+    }
+
+    #[throws(as Option)]
+    fn element<S>(&self, sel: S) -> ElementRef
+    where S: TryInto<Selector>,
+          <S as TryInto<Selector>>::Error: Debug,
+    {
+      self
+        .select(&sel.try_into().unwrap())
+        .next()?
+    }
+
+    #[throws(as Option)]
+    fn e_attr<S>(&self, sel: S, attr: &str) -> &str
+    where S: TryInto<Selector>,
+          <S as TryInto<Selector>>::Error: Debug,
+    {
+      self
+        .element(sel).unwrap()
+        .value().attr(attr)?
+    }
+  }
+
+  #[throws(Explode)]
+  pub fn parse_html(resp: reqwest::blocking::Response) -> Html {
+    assert_eq!(resp.status(), 200);
+    let body = resp.text()?;
+    let dom = scraper::Html::parse_document(&body);
+    //dbgc!(&&dom);
+    dom
+  }
+
+  #[ext(pub, name=RequestBuilderExt)]
+  impl reqwest::blocking::RequestBuilder {
+    #[throws(Explode)]
+    fn send(self) -> reqwest::blocking::Response { self.send()? }
+
+    #[throws(Explode)]
+    fn send_parse_html(self) -> Html {
+      let resp = self.send()?;
+      parse_html(resp)?
+    }
+  }
+}
+
+use scraper_ext::{HtmlExt, RequestBuilderExt};
+
+type Update = JsV;
+
+#[throws(Explode)]
+fn updates_parser<R:Read>(input: R, out: &mut mpsc::Sender<Update>) {
+  let mut accum: HashMap<String, String> = default();
+  for l in BufReader::new(input).lines() {
+    let l = l?;
+    if ! l.is_empty() {
+      let mut l = l.splitn(2, ':');
+      let lhs = l.next().unwrap();
+      let rhs = l.next().unwrap();
+      let rhs = rhs.trim_start();
+      let () = accum.insert(lhs.to_string(), rhs.to_string())
+        .is_none().expect("duplicate field");
+      continue;
+    }
+    let entry = mem::take(&mut accum);
+    #[allow(unused_variables)] let accum = (); // stops accidental use of accum
+    if entry.get("event").map(String::as_str) == Some("commsworking") {
+      eprintln!("commsworking: {}", entry["data"]);
+    } else if let Some(event) = entry.get("event") {
+      panic!("unexpected event: {}", event);
+    } else {
+      let update = &entry["data"];
+      let update = serde_json::from_str(update).unwrap();
+      if out.send(update).is_err() { break }
+    }
+  }
+}
+
+impl UsualCtx {
+  #[throws(Explode)]
+  fn connect_player<'su>(&self, player: &Player) -> Session {
+    let client = reqwest::blocking::Client::new();
+    let loading = client.get(&player.url).send_parse_html()?;
+    let ptoken = loading.e_attr("#loading_token", "data-ptoken").unwrap();
+    dbgc!(&ptoken);
+
+    let session = client.post(&self.su().ds.subst("@url@/_/session/Portrait")?)
+      .json(&json!({ "ptoken": ptoken }))
+      .send_parse_html()?;
+
+    let ctoken = session.e_attr("#main-body", "data-ctoken").unwrap();
+    dbgc!(&ctoken);
+
+    let gen: Generation = Generation(
+      session.e_attr("#main-body", "data-gen").unwrap()
+        .parse().unwrap()
+    );
+    dbgc!(gen);
+
+    let mut sse = client.get(
+      &self.su().ds
+        .also(&[("ctoken", ctoken),
+                ("gen",    &gen.to_string())])
+        .subst("@url@/_/updates?ctoken=@ctoken@&gen=@gen@")?
+    ).send()?;
+
+    let (mut wpipe, rpipe) = UnixStream::pair()?;
+    thread::spawn(move ||{
+      eprintln!("copy_to'ing");
+      match sse.copy_to(&mut wpipe) {
+        Err(re) => match (||{
+          // reqwest::Error won't give us the underlying io::Error :-/
+          wpipe.write_all(b"\n")?;
+          wpipe.flush()?;
+          Ok::<_,io::Error>(())
+        })() {
+          Err(pe) if pe.kind() == ErrorKind::BrokenPipe => { Ok(()) }
+          Err(pe) => Err(AE::from(pe)),
+          Ok(_) => Err(AE::from(re)),
+        }
+        Ok(_n) => Ok(()),
+      }.unwrap();
+      eprintln!("copy_to'd!"); 
+    });
+
+    let (mut csend, crecv) = mpsc::channel();
+    thread::spawn(move ||{
+      updates_parser(rpipe, &mut csend).expect("udpates parser failed")
+    });
+
+    Session {
+      nick: player.nick,
+      client, gen,
+      cseq: 42,
+      ctoken: RawToken(ctoken.to_string()),
+      dom: session,
+      updates: crecv,
+      su_rc: self.su_rc.clone(),
+    }
+  }
+
+  pub fn chdir_root<F>(&mut self, f: F)
+  where F: FnOnce(&mut Self) -> Result<(),Explode>
+  {
+    let tmp = self.su().ds.abstmp.clone();
+    env::set_current_dir("/").expect("cd /");
+    self.prctx = PathResolveContext::RelativeTo(tmp.clone());
+    f(self).expect("run test");
+    env::set_current_dir(&tmp).expect("cd back");
+    self.prctx = default();
+  }
+}
+
+mod pi {
+  use otter::prelude::define_index_type;
+  define_index_type!{ pub struct PIA = usize; }
+  define_index_type!{ pub struct PIB = usize; }
+}
+pub use pi::*;
+
+type Pieces<PI> = IndexVec<PI, PieceInfo<JsV>>;
+type PiecesSlice<PI> = IndexSlice<PI,[PieceInfo<JsV>]>;
+
+#[derive(Debug,Clone)]
+pub struct PieceInfo<I> {
+  id: String,
+  pos: Pos,
+  info: I,
+}
+
+impl Session {
+  #[throws(Explode)]
+  fn pieces<PI:Idx>(&self) -> Pieces<PI> {
+    let pieces = self.dom
+      .element("#pieces_marker")
+      .unwrap().next_siblings()
+      .map_loop(|puse: ego_tree::NodeRef<scraper::Node>| {
+        let puse = puse.value();
+        let puse = puse.as_element().ok_or(Loop::Continue)?;
+        let attr = puse.attr("data-info").ok_or(Loop::Break)?;
+        let pos = Pos::from_iter(["x","y"].iter().map(|attr|{
+          puse
+            .attr(attr).unwrap()
+            .parse().unwrap()
+        })).unwrap();
+        let id = puse.id.as_ref().unwrap();
+        let id = id.strip_prefix("use").unwrap().to_string();
+        let info = serde_json::from_str(attr).unwrap();
+        Loop::ok(PieceInfo { id, pos, info })
+      })
+      .collect();
+    let nick = self.nick;
+    dbgc!(nick, &pieces);
+    pieces
+  }
+
+  #[throws(Explode)]
+  fn api_piece_op_single<O:PieceOp>(&mut self, piece: &str, o: O) {
+    let (opname, payload) = if let Some(o) = o.api() { o } else { return };
+
+    self.cseq += 1;
+    let cseq = self.cseq;
+
+    let su = self.su_rc.borrow_mut();
+    let resp = self.client.post(&su.ds.also(&[("opname",opname)])
+                                .subst("@url@/_/api/@opname@")?)
+      .json(&json!({
+        "ctoken": self.ctoken,
+        "piece": piece,
+        "gen": self.gen,
+        "cseq": cseq,
+        "op": payload,
+      }))
+      .send()?;
+    assert_eq!(resp.status(), 200);
+  }
+
+  #[throws(Explode)]
+  fn api_piece<P:PieceSpecForOp, O:PieceOp>(
+    &mut self, g: GrabHow, mut p: P, o: O
+  ) {
+    if let GH::With | GH::Grab = g {
+      self.api_piece_op_single(p.id(), ("grab", json!({})))?;
+    }
+    if let Some(u) = p.for_update() {
+      o.update(u);
+    }
+    {
+      self.api_piece_op_single(p.id(), o)?;
+    }
+    if let Some(s) = p.for_synch() {
+      self.synchu(s)?;
+    }
+    if let GH::With | GH::Ungrab = g {
+      self.api_piece_op_single(p.id(), ("ungrab", json!({})))?;
+    }
+  }
+
+  #[throws(Explode)]
+  fn await_update<
+    R,
+    F: FnMut(&mut Session, Generation, &str, &JsV) -> Option<R>,
+    G: FnMut(&mut Session, Generation) -> Option<R>,
+    E: FnMut(&mut Session, Generation, &JsV)
+             -> Result<Option<R>, AE>
+   > (&mut self, mut g: G, mut f: F, mut ef: Option<E>) -> R {
+    let nick = self.nick;
+    'overall: loop {
+      let update = self.updates.recv()?;
+      let update = update.as_array().unwrap();
+      let new_gen = Generation(
+        update[0]
+          .as_i64().unwrap()
+          .try_into().unwrap()
+      );
+      self.gen = new_gen;
+      dbgc!(nick, new_gen);
+      if let Some(y) = g(self, new_gen) { break 'overall y }
+      for ue in update[1].as_array().unwrap() {
+        let (k,v) = ue.as_object().unwrap().iter().next().unwrap();
+        dbgc!(nick, k, &v);
+        if let Some(y) = {
+          if k != "Error" {
+            f(self, new_gen, k, v)
+          } else if let Some(ef) = &mut ef {
+            ef(self, new_gen, v)?
+          } else {
+            panic!("synch error: {:?}", &(k, v));
+          }
+        } { break 'overall y }
+      }
+    }
+  }
+
+  #[throws(Explode)]
+  fn synchx<
+    PI: Idx,
+    F: FnMut(&mut Session, Generation, &str, &JsV),
+  > (&mut self,
+     mut pieces: Option<&mut Pieces<PI>>,
+     ef: Option<&mut dyn FnMut(&mut Session, Generation, &JsV)
+                               -> Result<(), AE>>,
+     mut f: F
+  ) {
+    let exp = {
+      self.su_rc.borrow_mut().mgmt_conn()
+        .game_synch(TABLE.parse().unwrap())?
+    };
+    let efwrap = ef.map(|ef| {
+      move |s: &mut _, g, v: &_| { ef(s,g,v)?; Ok::<_,AE>(None) }
+    });
+    self.await_update(
+      |_session, gen      | (gen == exp).as_option(),
+      | session, gen, k, v| {
+        if let Some(pieces) = pieces.as_mut() {
+          update_update_pieces(&session.nick, pieces, k, v);
+        }
+        f(session,gen,k,v);
+        None
+      },
+      efwrap,
+    )?;
+  }
+
+  #[throws(Explode)]
+  fn synchu<PI:Idx>(&mut self, pieces: &mut Pieces<PI>) {
+    self.synchx(Some(pieces), None, |_session, _gen, _k, _v| ())?;
+  }
+
+  #[throws(Explode)]
+  fn synch(&mut self) {
+    self.synchx::<PIA,_>(None, None, |_session, _gen, _k, _v|())?;
+  }
+}
+
+pub fn update_update_pieces<PI:Idx>(
+  nick: &str,
+  pieces: &mut Pieces<PI>,
+  k: &str, v: &JsV
+) {
+  if k != "Piece" { return }
+  let v = v.as_object().unwrap();
+  let piece = v["piece"].as_str().unwrap();
+  let p = pieces.iter_mut().find(|p| p.id == piece);
+  if_let!{ Some(p) = p; else return }
+  let (op, d) = v["op"].as_object().unwrap().iter().next().unwrap();
+
+  fn coord(j: &JsV) -> Pos {
+    PosC::from_iter_2(
+      j.as_array().unwrap().iter()
+        .map(|n| n.as_i64().unwrap().try_into().unwrap())
+    )
+  }
+
+  match op.as_str() {
+    "Move" => {
+      p.pos = coord(d);
+    },
+    "Modify" | "ModifyQuiet" => {
+      let d = d.as_object().unwrap();
+      p.pos = coord(&d["pos"]);
+      for (k,v) in d {
+        p.info
+          .as_object_mut().unwrap()
+          .insert(k.to_string(), v.clone());
+      }
+    },
+    _ => {
+      panic!("unknown op {:?} {:?}", &op, &d);
+    },
+  };
+  dbgc!(nick, k,v,p);
+}
+
+pub type PieceOpData = (&'static str, JsV);
+pub trait PieceOp: Debug {
+  fn api(&self) -> Option<PieceOpData>;
+  fn update(&self, _pi: &mut PieceInfo<JsV>) { info!("no update {:?}", self) }
+}
+impl PieceOp for PieceOpData {
+  fn api(&self) -> Option<PieceOpData> { Some((self.0, self.1.clone())) }
+}
+impl PieceOp for Pos {
+  fn api(&self) -> Option<PieceOpData> { Some(("m", json![self.coords])) }
+  fn update(&self, pi: &mut PieceInfo<JsV>) { pi.pos = *self }
+}
+impl PieceOp for () {
+  fn api(&self) -> Option<PieceOpData> { None }
+  fn update(&self, _pi: &mut PieceInfo<JsV>) {  }
+}
+
+pub trait PieceSpecForOp: Debug {
+  fn id(&self) -> &str;
+  type PI: Idx;
+  fn for_update(&mut self) -> Option<&mut PieceInfo<JsV>> { None }
+  fn for_synch(&mut self) -> Option<&mut Pieces<Self::PI>> { None }
+}
+
+impl PieceSpecForOp for str {
+  type PI = PIA;
+  fn id(&self) -> &str { self }
+}
+impl PieceSpecForOp for &String {
+  type PI = PIA;
+  fn id(&self) -> &str { self }
+}
+
+type PuUp<'pcs, PI> = (&'pcs mut Pieces<PI>, PI);
+#[derive(Debug)]
+/// Synchronise after op but before any ungrab.
+pub struct PuSynch<T>(T);
+
+macro_rules! impl_PieceSpecForOp {
+  ($($amp:tt $mut:tt)?) => {
+
+    impl<PI:Idx> PieceSpecForOp for $($amp $mut)? PuUp<'_, PI> {
+      type PI = PI;
+      fn id(&self) -> &str { &self.0[self.1].id }
+      fn for_update(&mut self) -> Option<&mut PieceInfo<JsV>> {
+        Some(&mut self.0[self.1])
+      }
+    }
+
+    impl<PI:Idx> PieceSpecForOp for PuSynch<$($amp $mut)? PuUp<'_,PI>> {
+      type PI = PI;
+      fn id(&self) -> &str { self.0.id() }
+      fn for_update(&mut self) -> Option<&mut PieceInfo<JsV>> {
+        self.0.for_update()
+      }
+      fn for_synch(&mut self) -> Option<&mut Pieces<PI>> {
+        Some(self.0.0)
+      }
+    }
+  }
+}
+impl_PieceSpecForOp!{}
+impl_PieceSpecForOp!{&mut}
+
+#[derive(Debug,Copy,Clone)]
+pub enum GrabHow { Raw, Grab, Ungrab, With }
+pub use GrabHow as GH;
+
+
+impl UsualCtx {
+  #[throws(AE)]
+  pub fn otter<S:AsRef<str>>(&mut self, args: &[S]) -> OtterOutput {
+    let args: Vec<String> =
+      ["--account", "server:"].iter().cloned().map(Into::into)
+      .chain(args.iter().map(|s| s.as_ref().to_owned()))
+      .collect();
+    self.su().ds.otter_prctx(&self.prctx, &args)?
+  }
+
+  #[throws(Explode)]
+  fn place_library_load_markers(&mut self) -> Session {
+    let mut session = self.connect_player(&self.alice)?;
+    let pieces = session.pieces::<PIA>()?;
+    let llm = pieces.into_iter()
+      .filter(|pi| pi.info["desc"] == "a library load area marker")
+      .collect::<ArrayVec<_>>();
+    let llm: [_;2] = llm.into_inner().unwrap();
+    dbgc!(&llm);
+
+    for (llm, &pos) in izip!(&llm, [PosC::new(5,5), PosC::new(50,25)].iter())
+    {
+      session.api_piece(GH::With, &llm.id, pos)?;
+    }
+
+    session.synch()?;
+    session
+  }
+
+  #[throws(Explode)]
+  fn some_library_add(&mut self, command: &[String]) -> Vec<String> {
+    let add_err = self.otter(command)
+      .expect_err("library-add succeeded after reset!");
+    assert_eq!(add_err.downcast::<ExitStatusError>()?.0.code(),
+               Some(EXIT_NOTFOUND));
+
+    let mut session = self.place_library_load_markers()?;
+
+    self.otter(&command)
+      .expect("library-add failed after place!");
+
+    let mut added = vec![];
+    session.synchx::<PIA,_>(None, None,
+      |_session, _gen, k, v| if_chain! {
+        if k == "Piece";
+        let piece = v["piece"].as_str().unwrap().to_string();
+        let op = v["op"].as_object().unwrap();
+        if let Some(_) = op.get("Insert");
+        then { added.push(piece); }
+      }
+    )?;
+
+    dbgc!(&added);
+    added
+  }
+}
 
 portmanteau_has!("at-otter.rs",   at_otter);
 portmanteau_has!("at-bundles.rs", at_bundles);