| Author | Andy Green <andy@warmcat.com> 2026-10-05 16:38 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-10-05 19:18 UTC | | Tree | 4e7547cd5130568bf547f64b1e9da36dd210072f Raw Patch | | | npro-test: npro's ws client replays C's ws client transcripts | npro-test: npro's ws client replays C's ws client transcripts
tests/ws_client_replay.rs makes C's api-test-sansio client connection, a
GET of /echo offering "echo", with C's seeded random: npro's h1 client
writes the request with npro-ws' upgrade lines, the response is checked
as C checks it, and the connection is then npro-ws', masking from the
same stream. The app is C's callback_client, which sends "Hello" once
established unless the case has it quiet.
The request, every masked frame written four bytes at a time, the app's
messages and where C closed must be the transcript's, for ws-client,
ws-client-interim, ws-client-ping-close, ws-client-huge-frame,
ws-client-rsv1-no-ext and ws-client-rsv2.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kg5Eemy68ZaqDBcUJQG6J
|
diff --git a/crates/npro-test/Cargo.toml b/crates/npro-test/Cargo.toml
index 7d31f67..100fc95 100644
--- a/crates/npro-test/Cargo.toml
+++ b/crates/npro-test/Cargo.toml
@@ -10,7 +10,7 @@ homepage.workspace = true
repository.workspace = true
[dev-dependencies]
-npro-core = { path = "../npro-core" }
+npro-core = { path = "../npro-core", features = ["replay"] }
npro-h1 = { path = "../npro-h1" }
npro-ws = { path = "../npro-ws" }
diff --git a/crates/npro-test/tests/ws_client_replay.rs b/crates/npro-test/tests/ws_client_replay.rs
new file mode 100644
index 0000000..fa92cfe
--- /dev/null
+++ b/crates/npro-test/tests/ws_client_replay.rs
@@ -0,0 +1,235 @@
+//! npro's ws client replays C's ws client transcripts byte for byte.
+//!
+//! Each is one connection C's `api-test-sansio` makes: a GET of `/echo` to
+//! `sansio`, port 80, with C's defaults (no-cache headers, an `Origin`),
+//! offering the subprotocol `echo`, its random C's seeded stream. npro's
+//! h1 client writes the request, its upgrade lines and key from npro-ws;
+//! the response is checked as C checks it, and the connection is then
+//! npro-ws', masking from the same stream. The app is C's
+//! `callback_client`: once established it sends `Hello`, unless the case
+//! has it quiet.
+//!
+//! What npro writes after each `rx` must be the transcript's `tx` bytes,
+//! the messages it hands the app its `app_rx` bytes, and it must ask to be
+//! done with the connection exactly where C closed it, its `close`. It
+//! writes four bytes at a time, so every frame goes in pieces.
+
+// held to clippy's rules for tests
+#[cfg(test)]
+mod ws_client_replay {
+ use npro_core::random::SeededRandom;
+ use npro_h1::client::{Client, Connection, Event as H1Event, Request, Scheme};
+ use npro_h1::server::TxSource;
+ use npro_h1::table::DEFAULT_CAPACITY;
+ use npro_test::{StepKind, Transcript, vendored};
+ use npro_ws::conn::{AsClient, Event, Kind, Ws};
+ use npro_ws::handshake::{ClientKey, MAX_REQUEST_LINES};
+
+ /// The ws client transcripts this phase replays, and whether the app
+ /// is quiet: not the permessage-deflate ones, the next phase's, nor the
+ /// digest retry, which needs digest auth.
+ const CASES: [(&str, bool); 6] = [
+ ("ws-client", false),
+ ("ws-client-interim", false),
+ ("ws-client-ping-close", true),
+ ("ws-client-huge-frame", false),
+ ("ws-client-rsv1-no-ext", false),
+ ("ws-client-rsv2", false),
+ ];
+
+ /// The subprotocols offered.
+ const OFFERED: &[u8] = b"echo";
+
+ /// How much is written at a time.
+ const TX_LIMIT: usize = 4;
+
+ /// C's `callback_client`, as far as ws goes.
+ #[derive(Default)]
+ struct ClientApp {
+ quiet: bool,
+ /// What it was given, for the transcript's `app_rx`.
+ app_rx: Vec<u8>,
+ out: Vec<u8>,
+ at: usize,
+ }
+
+ impl TxSource for ClientApp {
+ fn fill(&mut self, buf: &mut [u8]) -> usize {
+ let rest = &self.out[self.at..];
+ let n = rest.len().min(buf.len());
+ buf[..n].copy_from_slice(&rest[..n]);
+ self.at = self.at.checked_add(n).unwrap();
+ n
+ }
+ }
+
+ impl ClientApp {
+ /// `LWS_CALLBACK_CLIENT_ESTABLISHED`, then its writeable.
+ fn established(&mut self, ws: &mut Ws<AsClient<SeededRandom>>) {
+ if self.quiet {
+ return;
+ }
+ self.out = b"Hello".to_vec();
+ self.at = 0;
+ ws.send(Kind::Text, 5).unwrap();
+ }
+ }
+
+ /// The connection: h1 until the 101 is in, then ws, which takes the
+ /// random stream on from the key's draw.
+ enum Conn {
+ H1 {
+ client: Box<Client<Vec<u8>>>,
+ key: ClientKey,
+ random: Option<SeededRandom>,
+ },
+ Ws(Box<Ws<AsClient<SeededRandom>>>),
+ }
+
+ /// Hands `input` to the connection, with the app answering, until
+ /// neither takes or writes anything more; returns what was written.
+ fn feed(conn: &mut Conn, app: &mut ClientApp, input: &mut [u8]) -> Vec<u8> {
+ let mut wrote = Vec::new();
+ let mut buf = [0u8; TX_LIMIT];
+ let mut at = 0usize;
+ loop {
+ let mut progress = false;
+ match conn {
+ Conn::H1 {
+ client,
+ key,
+ random,
+ } => {
+ let rx = client.rx(&input[at..]).unwrap();
+ at = at.checked_add(rx.consumed).unwrap();
+ progress |= rx.consumed > 0;
+ if rx.event == Some(H1Event::Response) {
+ assert!(client.is_upgraded());
+ let chosen = key
+ .check(client.status(), client.response(), Some(OFFERED))
+ .unwrap();
+ assert_eq!(chosen, Some(OFFERED));
+ let mut ws = Ws::client(random.take().unwrap());
+ app.established(&mut ws);
+ *conn = Conn::Ws(Box::new(ws));
+ continue;
+ }
+ }
+ Conn::Ws(ws) => {
+ let rx = ws.rx(&mut input[at..]);
+ let consumed = rx.consumed;
+ if let Some(Event::Message { data, .. }) = rx.event {
+ app.app_rx.extend_from_slice(data);
+ }
+ at = at.checked_add(consumed).unwrap();
+ progress |= consumed > 0;
+ loop {
+ let n = ws.tx(&mut buf, app);
+ wrote.extend_from_slice(&buf[..n]);
+ if n == 0 {
+ break;
+ }
+ progress = true;
+ }
+ }
+ }
+ if !progress {
+ return wrote;
+ }
+ }
+ }
+
+ fn replay(t: &Transcript, quiet: bool) {
+ // C's run was seeded: its random is in the transcript
+ let seed = t.seed.unwrap_or_else(|| panic!("{}: not seeded", t.case));
+ let mut random = SeededRandom::new(seed.get());
+ let key = ClientKey::new(&mut random).unwrap();
+ let mut lines = [0u8; MAX_REQUEST_LINES + 32];
+ let lines_len = key.request_lines(Some(OFFERED), &mut lines).unwrap();
+ let mut client = Client::new(
+ vec![0u8; DEFAULT_CAPACITY],
+ Request {
+ method: b"GET",
+ path: b"/echo",
+ host: Some(b"sansio"),
+ origin: Some(b"sansio"),
+ scheme: Scheme::Http,
+ no_cache: true,
+ connection: Connection::Upgrade(&lines[..lines_len]),
+ },
+ )
+ .unwrap();
+
+ let mut steps = t.steps.iter().peekable();
+ let Some(StepKind::Tx(request)) = steps.next().map(|s| &s.kind) else {
+ panic!("{}: does not start with the request", t.case);
+ };
+ let mut out = [0u8; 512];
+ let n = client.tx(&mut out);
+ assert_eq!(
+ out[..n].escape_ascii().to_string(),
+ request.escape_ascii().to_string(),
+ "{}: the request",
+ t.case
+ );
+
+ let mut conn = Conn::H1 {
+ client: Box::new(client),
+ key,
+ random: Some(random),
+ };
+ let mut app = ClientApp {
+ quiet,
+ ..ClientApp::default()
+ };
+ while let Some(step) = steps.next() {
+ let StepKind::Rx(rx) = &step.kind else {
+ panic!("{}: {:?} with no rx before it", t.case, step.kind);
+ };
+ let (mut want, mut want_app, mut want_close) = (Vec::new(), Vec::new(), false);
+ while let Some(next) = steps.peek() {
+ match &next.kind {
+ StepKind::Tx(b) => want.extend_from_slice(b),
+ StepKind::AppRx(b) => want_app.extend_from_slice(b),
+ StepKind::Close => want_close = true,
+ StepKind::Rx(_) => break,
+ }
+ steps.next();
+ }
+ let mut input = rx.clone();
+ let got = feed(&mut conn, &mut app, &mut input);
+ assert_eq!(
+ got.escape_ascii().to_string(),
+ want.escape_ascii().to_string(),
+ "{} at {}us",
+ t.case,
+ step.t_us
+ );
+ assert_eq!(
+ core::mem::take(&mut app.app_rx),
+ want_app,
+ "{} at {}us: the app's",
+ t.case,
+ step.t_us
+ );
+ let closed = match &conn {
+ Conn::Ws(ws) => ws.close().is_some(),
+ Conn::H1 { .. } => false,
+ };
+ assert_eq!(closed, want_close, "{} at {}us: closed", t.case, step.t_us);
+ }
+ }
+
+ #[test]
+ #[cfg_attr(miri, ignore = "reads the transcripts: native runs keep it")]
+ fn cs_ws_client_transcripts_replay_byte_for_byte() {
+ let all = vendored().unwrap();
+ for (case, quiet) in CASES {
+ let t = all
+ .iter()
+ .find(|t| t.case == case)
+ .unwrap_or_else(|| panic!("no transcript {case}"));
+ replay(t, quiet);
+ }
+ }
+}
|