| Author | Andy Green <andy@warmcat.com> 2026-10-03 09:24 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-10-05 06:32 UTC | | Tree | 836374f4c7b7fcae13fc91fc4a6f53f400177304 Raw Patch | | | npro-test: read the C transcripts, and keep copies of them | npro-test: read the C transcripts, and keep copies of them
C lws' api-test-sansio records each connection it drives through the
sansIO half as an lws-transcript/1 file: the bytes in and out, what the
application was given, and when. They are the byte-exact specification
npro replays against. The 48 from C main-dev 75b415f4 are copied into
crates/npro-test/transcripts, along with:
- C-COMMIT, naming the commit they came from;
- README.lws.md, the C README that specifies the format and each
connection's application.
scripts/sync-c-oracle.sh refreshes them all from a C checkout, so a
change to one is always a change made in C first.
The reader is a strict, non-recursive parser for exactly this format,
not a general JSON parser, so the crate needs no dependency. It refuses:
- unknown keys and duplicate keys;
- string escapes;
- anything but lowercase hex of whole bytes;
- a step that is not a time and one of rx, tx, app_rx or an empty close;
- step times going backwards;
- numbers past 64 bits;
- more than MAX_STEPS steps or MAX_BYTES of text;
- a file not named for its case.
A seed of 0 reads as None, the connection drawing nothing from lws'
random. Tests cover each refusal and every truncation of a text. They
also read every vendored file and check what the C README promises: the
fixed start times, and seeds only on client connections.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kg5Eemy68ZaqDBcUJQG6J
|
diff --git a/Cargo.lock b/Cargo.lock
index 852c0a8..e86a423 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5,3 +5,7 @@ version = 4
[[package]]
name = "npro"
version = "0.0.2"
+
+[[package]]
+name = "npro-test"
+version = "0.0.2"
diff --git a/crates/npro-test/Cargo.toml b/crates/npro-test/Cargo.toml
new file mode 100644
index 0000000..8b26318
--- /dev/null
+++ b/crates/npro-test/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "npro-test"
+description = "Test support for npro: the C transcripts and the harness that replays them"
+publish = false
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+homepage.workspace = true
+repository.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/npro-test/src/lib.rs b/crates/npro-test/src/lib.rs
new file mode 100644
index 0000000..5f2f67c
--- /dev/null
+++ b/crates/npro-test/src/lib.rs
@@ -0,0 +1,14 @@
+//! Test support for npro, not published.
+//!
+//! C lws records connections driven through its sansIO half as
+//! transcripts: the bytes in and out, what the application was given, and
+//! when (`minimal-examples-lowlevel/api-tests/api-test-sansio` in the C
+//! tree). They are the byte-exact specification npro is checked against.
+//! This crate reads them; copies of them, with where they came from, are in
+//! this crate's `transcripts/` directory.
+
+#![forbid(unsafe_code)]
+
+mod transcript;
+
+pub use transcript::{Error, MAX_BYTES, MAX_STEPS, Side, Step, StepKind, Transcript, vendored};
diff --git a/crates/npro-test/src/transcript.rs b/crates/npro-test/src/transcript.rs
new file mode 100644
index 0000000..a23d527
--- /dev/null
+++ b/crates/npro-test/src/transcript.rs
@@ -0,0 +1,644 @@
+//! The `lws-transcript/1` format, as C lws' `api-test-sansio` writes it.
+//!
+//! A transcript is one JSON object: the format, the connection's name, which
+//! side lws was on, the time the run started, the seed of lws' random, and
+//! the steps. Each step is a time and one of: bytes the peer sent (`rx`),
+//! bytes lws wrote (`tx`), a payload lws gave the application (`app_rx`), or
+//! lws releasing the transport (`close`). The C tree's
+//! `transcripts/README.md`, copied beside the transcripts here, is the
+//! specification.
+//!
+//! The reader takes that format and nothing else: only the keys it defines,
+//! each once; lowercase hex; no string escapes; no recursion. Anything else
+//! is a new format version, and refused.
+
+use std::{
+ fmt, fs, io,
+ num::NonZeroU64,
+ path::{Path, PathBuf},
+};
+
+/// The one format version this reader takes.
+const FORMAT: &str = "lws-transcript/1";
+
+/// The largest transcript file read, in bytes.
+pub const MAX_BYTES: usize = 1 << 20;
+
+/// The most steps one transcript may have.
+pub const MAX_STEPS: usize = 4096;
+
+/// Which side of the connection lws was on.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Side {
+ /// The peer connected to lws.
+ Server,
+ /// lws connected to the peer.
+ Client,
+}
+
+/// What happened at one step.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum StepKind {
+ /// The peer sent these bytes, handed to lws at the step's time.
+ Rx(Vec<u8>),
+ /// lws wrote these bytes. How many writes it took is not behaviour.
+ Tx(Vec<u8>),
+ /// lws gave the application this payload: a response body, or a ws
+ /// message's payload. How a payload is split into deliveries is not
+ /// behaviour, only the concatenation of each message or body.
+ AppRx(Vec<u8>),
+ /// lws released the connection's transport.
+ Close,
+}
+
+/// One step of a transcript.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Step {
+ /// When, in microseconds after the run's start, `t0_us`.
+ pub t_us: u64,
+ /// What happened.
+ pub kind: StepKind,
+}
+
+/// One connection's life, as recorded by C lws.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Transcript {
+ /// The connection's name, which is also its file's.
+ pub case: String,
+ /// Which side lws was on.
+ pub side: Side,
+ /// The monotonic time when the run starts, in microseconds.
+ pub t0_us: u64,
+ /// The wall time at `t0_us`, in seconds since 1970.
+ pub t0_wall: u64,
+ /// The seed of lws' random, reseeded when this connection starts; `None`
+ /// when nothing in its bytes depends on lws' random.
+ pub seed: Option<NonZeroU64>,
+ /// What happened, in order. Step times never go backwards.
+ pub steps: Vec<Step>,
+}
+
+/// Why a transcript could not be read.
+#[derive(Debug)]
+pub enum Error {
+ /// The file could not be read.
+ Io(io::Error),
+ /// The file is larger than [`MAX_BYTES`].
+ TooLarge,
+ /// The text is not the JSON this format allows at this byte offset.
+ Syntax {
+ /// Byte offset of the problem.
+ offset: usize,
+ /// What was expected there.
+ expected: &'static str,
+ },
+ /// `format` names a version other than `lws-transcript/1`.
+ UnknownFormat,
+ /// A key the format does not define, at this byte offset.
+ UnknownKey {
+ /// Byte offset of the key.
+ offset: usize,
+ },
+ /// A key given twice.
+ DuplicateKey(&'static str),
+ /// A key the format requires is missing.
+ MissingKey(&'static str),
+ /// `side` is neither `server` nor `client`.
+ BadSide {
+ /// Byte offset of the value.
+ offset: usize,
+ },
+ /// A payload that is not lowercase hex of whole bytes.
+ BadHex {
+ /// Byte offset of the value.
+ offset: usize,
+ },
+ /// A step that is not a time and exactly one of `rx`, `tx`, `app_rx` or
+ /// an empty `close`.
+ BadStep {
+ /// Byte offset of the step.
+ offset: usize,
+ },
+ /// A number too large for 64 bits.
+ Overflow {
+ /// Byte offset of the number.
+ offset: usize,
+ },
+ /// More than [`MAX_STEPS`] steps.
+ TooManySteps,
+ /// A step earlier than the one before it.
+ TimeGoesBackwards {
+ /// Index of the step.
+ step: usize,
+ },
+ /// The file's name is not its `case`.
+ CaseMismatch,
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Io(e) => write!(f, "cannot read transcript: {e}"),
+ Self::TooLarge => write!(f, "transcript larger than {MAX_BYTES} bytes"),
+ Self::Syntax { offset, expected } => {
+ write!(f, "at byte {offset}: expected {expected}")
+ }
+ Self::UnknownFormat => write!(f, "format is not {FORMAT}"),
+ Self::UnknownKey { offset } => write!(f, "at byte {offset}: unknown key"),
+ Self::DuplicateKey(k) => write!(f, "key \"{k}\" given twice"),
+ Self::MissingKey(k) => write!(f, "key \"{k}\" missing"),
+ Self::BadSide { offset } => {
+ write!(f, "at byte {offset}: side is not server or client")
+ }
+ Self::BadHex { offset } => {
+ write!(f, "at byte {offset}: not lowercase hex of whole bytes")
+ }
+ Self::BadStep { offset } => write!(
+ f,
+ "at byte {offset}: a step is a time and one of rx, tx, app_rx or an empty close"
+ ),
+ Self::Overflow { offset } => write!(f, "at byte {offset}: number too large"),
+ Self::TooManySteps => write!(f, "more than {MAX_STEPS} steps"),
+ Self::TimeGoesBackwards { step } => write!(f, "step {step} goes back in time"),
+ Self::CaseMismatch => write!(f, "file name is not the transcript's case"),
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Io(e) => Some(e),
+ _ => None,
+ }
+ }
+}
+
+impl From<io::Error> for Error {
+ fn from(e: io::Error) -> Self {
+ Self::Io(e)
+ }
+}
+
+impl Transcript {
+ /// Reads a transcript from its text.
+ ///
+ /// ```
+ /// use npro_test::{Side, StepKind, Transcript};
+ ///
+ /// let t = Transcript::parse(br#"{
+ /// "format": "lws-transcript/1", "case": "x", "side": "client",
+ /// "t0_us": 1000000000, "t0_wall": 1767225600, "seed": 0,
+ /// "steps": [ {"t": 1000, "tx": "4745"}, {"t": 2000, "close": ""} ]
+ /// }"#)?;
+ ///
+ /// assert_eq!(t.side, Side::Client);
+ /// assert_eq!(t.steps[0].kind, StepKind::Tx(b"GE".to_vec()));
+ /// assert_eq!(t.steps[1].kind, StepKind::Close);
+ /// # Ok::<(), npro_test::Error>(())
+ /// ```
+ ///
+ /// # Errors
+ ///
+ /// Any departure from the format: see [`Error`].
+ pub fn parse(text: &[u8]) -> Result<Self, Error> {
+ if text.len() > MAX_BYTES {
+ return Err(Error::TooLarge);
+ }
+
+ let mut c = Cursor { b: text, pos: 0 };
+ let mut format = None;
+ let mut case = None;
+ let mut side = None;
+ let mut t0_us = None;
+ let mut t0_wall = None;
+ let mut seed = None;
+ let mut steps = None;
+
+ c.members(|c, key, at| match key {
+ "format" => once(&mut format, "format", c.string()?),
+ "case" => once(&mut case, "case", c.string()?),
+ "side" => {
+ let at = c.value_offset();
+ let s = match c.string()? {
+ "server" => Side::Server,
+ "client" => Side::Client,
+ _ => return Err(Error::BadSide { offset: at }),
+ };
+ once(&mut side, "side", s)
+ }
+ "t0_us" => once(&mut t0_us, "t0_us", c.uint()?),
+ "t0_wall" => once(&mut t0_wall, "t0_wall", c.uint()?),
+ "seed" => once(&mut seed, "seed", c.uint()?),
+ "steps" => once(&mut steps, "steps", c.steps()?),
+ _ => Err(Error::UnknownKey { offset: at }),
+ })?;
+
+ c.ws();
+ if c.peek().is_some() {
+ return Err(c.syntax("the end of the text"));
+ }
+
+ if format.ok_or(Error::MissingKey("format"))? != FORMAT {
+ return Err(Error::UnknownFormat);
+ }
+
+ let steps: Vec<Step> = steps.ok_or(Error::MissingKey("steps"))?;
+ let mut last = 0;
+ for (i, s) in steps.iter().enumerate() {
+ if s.t_us < last {
+ return Err(Error::TimeGoesBackwards { step: i });
+ }
+ last = s.t_us;
+ }
+
+ Ok(Self {
+ case: case.ok_or(Error::MissingKey("case"))?.to_owned(),
+ side: side.ok_or(Error::MissingKey("side"))?,
+ t0_us: t0_us.ok_or(Error::MissingKey("t0_us"))?,
+ t0_wall: t0_wall.ok_or(Error::MissingKey("t0_wall"))?,
+ seed: NonZeroU64::new(seed.ok_or(Error::MissingKey("seed"))?),
+ steps,
+ })
+ }
+
+ /// Reads a transcript file, whose name must be its case.
+ ///
+ /// # Errors
+ ///
+ /// The file cannot be read, is larger than [`MAX_BYTES`], is not the
+ /// format, or is not named for its case.
+ pub fn read(path: &Path) -> Result<Self, Error> {
+ let max = u64::try_from(MAX_BYTES).map_err(|_| Error::TooLarge)?;
+ if fs::metadata(path)?.len() > max {
+ return Err(Error::TooLarge);
+ }
+
+ let t = Self::parse(&fs::read(path)?)?;
+ if path.file_stem().and_then(|s| s.to_str()) != Some(t.case.as_str()) {
+ return Err(Error::CaseMismatch);
+ }
+
+ Ok(t)
+ }
+}
+
+/// The transcripts copied into this crate from the C tree, sorted by case.
+///
+/// # Errors
+///
+/// The directory cannot be listed, or any `.json` file in it cannot be read
+/// as a transcript.
+pub fn vendored() -> Result<Vec<Transcript>, Error> {
+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("transcripts");
+ let mut paths: Vec<PathBuf> = fs::read_dir(dir)?
+ .map(|e| e.map(|e| e.path()))
+ .collect::<Result<_, _>>()?;
+ paths.retain(|p| p.extension().is_some_and(|x| x == "json"));
+ paths.sort();
+
+ paths.iter().map(|p| Transcript::read(p)).collect()
+}
+
+/// Sets a key's value, refusing a second one.
+fn once<T>(slot: &mut Option<T>, key: &'static str, v: T) -> Result<(), Error> {
+ if slot.replace(v).is_some() {
+ return Err(Error::DuplicateKey(key));
+ }
+ Ok(())
+}
+
+/// The digits of lowercase hex, in order of value.
+const HEX: &[u8; 16] = b"0123456789abcdef";
+
+/// The value of one lowercase hex digit.
+fn nibble(c: u8) -> Option<u8> {
+ HEX.iter()
+ .position(|&d| d == c)
+ .and_then(|n| u8::try_from(n).ok())
+}
+
+/// Decodes lowercase hex of whole bytes.
+fn hex(s: &str) -> Option<Vec<u8>> {
+ let pairs = s.as_bytes().chunks_exact(2);
+ if !pairs.remainder().is_empty() {
+ return None;
+ }
+
+ pairs
+ .map(|p| match p {
+ [hi, lo] => Some(nibble(*hi)?.checked_shl(4)? | nibble(*lo)?),
+ _ => None,
+ })
+ .collect()
+}
+
+/// A position in the text being read.
+struct Cursor<'a> {
+ b: &'a [u8],
+ pos: usize,
+}
+
+impl<'a> Cursor<'a> {
+ fn peek(&self) -> Option<u8> {
+ self.b.get(self.pos).copied()
+ }
+
+ fn bump(&mut self) {
+ self.pos = self.pos.saturating_add(1);
+ }
+
+ fn syntax(&self, expected: &'static str) -> Error {
+ Error::Syntax {
+ offset: self.pos,
+ expected,
+ }
+ }
+
+ /// Skips JSON whitespace.
+ fn ws(&mut self) {
+ while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
+ self.bump();
+ }
+ }
+
+ /// The offset of the next value, after any whitespace.
+ fn value_offset(&mut self) -> usize {
+ self.ws();
+ self.pos
+ }
+
+ fn eat(&mut self, c: u8, expected: &'static str) -> Result<(), Error> {
+ self.ws();
+ if self.peek() != Some(c) {
+ return Err(self.syntax(expected));
+ }
+ self.bump();
+ Ok(())
+ }
+
+ /// A string with no escapes and no control characters.
+ fn string(&mut self) -> Result<&'a str, Error> {
+ self.eat(b'"', "a string")?;
+ let start = self.pos;
+ loop {
+ match self.peek() {
+ None => return Err(self.syntax("the end of the string")),
+ Some(b'"') => break,
+ Some(b'\\') => return Err(self.syntax("a string with no escapes")),
+ Some(c) if c < 0x20 => return Err(self.syntax("no control characters")),
+ Some(_) => self.bump(),
+ }
+ }
+ let s = self
+ .b
+ .get(start..self.pos)
+ .ok_or_else(|| self.syntax("a string"))?;
+ self.bump();
+ std::str::from_utf8(s).map_err(|_| Error::Syntax {
+ offset: start,
+ expected: "UTF-8",
+ })
+ }
+
+ /// An unsigned integer that fits 64 bits.
+ fn uint(&mut self) -> Result<u64, Error> {
+ self.ws();
+ let start = self.pos;
+ let mut v: u64 = 0;
+ while let Some(d) = self.peek().and_then(|c| char::from(c).to_digit(10)) {
+ v = v
+ .checked_mul(10)
+ .and_then(|v| v.checked_add(u64::from(d)))
+ .ok_or(Error::Overflow { offset: start })?;
+ self.bump();
+ }
+ if self.pos == start {
+ return Err(self.syntax("a number"));
+ }
+ Ok(v)
+ }
+
+ /// An object: each member's key and the key's offset are handed to
+ /// `member`, which must read the value.
+ fn members(
+ &mut self,
+ mut member: impl FnMut(&mut Self, &'a str, usize) -> Result<(), Error>,
+ ) -> Result<(), Error> {
+ self.eat(b'{', "{")?;
+ self.ws();
+ if self.peek() == Some(b'}') {
+ self.bump();
+ return Ok(());
+ }
+ loop {
+ let at = self.value_offset();
+ let key = self.string()?;
+ self.eat(b':', ":")?;
+ member(self, key, at)?;
+ self.ws();
+ match self.peek() {
+ Some(b',') => self.bump(),
+ Some(b'}') => {
+ self.bump();
+ return Ok(());
+ }
+ _ => return Err(self.syntax(", or }")),
+ }
+ }
+ }
+
+ /// The array of steps.
+ fn steps(&mut self) -> Result<Vec<Step>, Error> {
+ let mut steps = Vec::new();
+ self.eat(b'[', "[")?;
+ self.ws();
+ if self.peek() == Some(b']') {
+ self.bump();
+ return Ok(steps);
+ }
+ loop {
+ if steps.len() >= MAX_STEPS {
+ return Err(Error::TooManySteps);
+ }
+ steps.push(self.step()?);
+ self.ws();
+ match self.peek() {
+ Some(b',') => self.bump(),
+ Some(b']') => {
+ self.bump();
+ return Ok(steps);
+ }
+ _ => return Err(self.syntax(", or ]")),
+ }
+ }
+ }
+
+ /// One step: a time and exactly one thing that happened.
+ fn step(&mut self) -> Result<Step, Error> {
+ let start = self.value_offset();
+ let mut t_us = None;
+ let mut kind = None;
+
+ self.members(|c, key, at| {
+ if key == "t" {
+ return once(&mut t_us, "t", c.uint()?);
+ }
+
+ let v_at = c.value_offset();
+ let k = match key {
+ "rx" | "tx" | "app_rx" => {
+ let bytes = hex(c.string()?).ok_or(Error::BadHex { offset: v_at })?;
+ match key {
+ "rx" => StepKind::Rx(bytes),
+ "tx" => StepKind::Tx(bytes),
+ _ => StepKind::AppRx(bytes),
+ }
+ }
+ "close" if c.string()?.is_empty() => StepKind::Close,
+ "close" => return Err(Error::BadStep { offset: start }),
+ _ => return Err(Error::UnknownKey { offset: at }),
+ };
+ if kind.replace(k).is_some() {
+ return Err(Error::BadStep { offset: start });
+ }
+ Ok(())
+ })?;
+
+ Ok(Step {
+ t_us: t_us.ok_or(Error::MissingKey("t"))?,
+ kind: kind.ok_or(Error::BadStep { offset: start })?,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const HEAD: &str = r#""format": "lws-transcript/1", "case": "c", "side": "server",
+ "t0_us": 1000000000, "t0_wall": 1767225600, "seed": 0"#;
+
+ fn with_steps(steps: &str) -> String {
+ format!("{{ {HEAD}, \"steps\": [ {steps} ] }}")
+ }
+
+ #[test]
+ fn reads_every_kind_of_step() {
+ let t = Transcript::parse(
+ with_steps(
+ r#"{"t": 1, "rx": "00ff"}, {"t": 1, "tx": ""},
+ {"t": 2, "app_rx": "41"}, {"t": 3, "close": ""}"#,
+ )
+ .as_bytes(),
+ )
+ .unwrap();
+
+ assert_eq!(t.case, "c");
+ assert_eq!(t.side, Side::Server);
+ assert_eq!(t.seed, None);
+ let kinds: Vec<_> = t.steps.into_iter().map(|s| s.kind).collect();
+ assert_eq!(
+ kinds,
+ [
+ StepKind::Rx(vec![0, 0xff]),
+ StepKind::Tx(vec![]),
+ StepKind::AppRx(b"A".to_vec()),
+ StepKind::Close
+ ]
+ );
+ }
+
+ #[test]
+ fn a_nonzero_seed_is_kept() {
+ let t = Transcript::parse(
+ with_steps("")
+ .replace("\"seed\": 0", "\"seed\": 1")
+ .as_bytes(),
+ )
+ .unwrap();
+ assert_eq!(t.seed, NonZeroU64::new(1));
+ }
+
+ fn refused(text: &str) -> Error {
+ Transcript::parse(text.as_bytes()).unwrap_err()
+ }
+
+ #[test]
+ fn refuses_what_the_format_does_not_define() {
+ assert!(matches!(
+ refused(&with_steps("").replace("/1", "/2")),
+ Error::UnknownFormat
+ ));
+ assert!(matches!(
+ refused(&with_steps("").replace("\"server\"", "\"peer\"")),
+ Error::BadSide { .. }
+ ));
+ assert!(matches!(
+ refused(&with_steps("").replace("\"seed\"", "\"sneed\"")),
+ Error::UnknownKey { .. }
+ ));
+ assert!(matches!(
+ refused(&with_steps("").replace(", \"seed\": 0", "")),
+ Error::MissingKey("seed")
+ ));
+ assert!(matches!(
+ refused(&with_steps("").replace("\"seed\": 0", "\"seed\": 0, \"seed\": 1")),
+ Error::DuplicateKey("seed")
+ ));
+ assert!(matches!(
+ refused(&format!("{} x", with_steps(""))),
+ Error::Syntax { .. }
+ ));
+ }
+
+ #[test]
+ fn refuses_bad_steps() {
+ for (steps, why) in [
+ (r#"{"t": 1, "rx": "0"}"#, "odd hex"),
+ (r#"{"t": 1, "rx": "0A"}"#, "uppercase hex"),
+ (r#"{"t": 1, "rx": "0g"}"#, "not hex"),
+ (r#"{"t": 1, "close": "00"}"#, "a close with a payload"),
+ (r#"{"t": 1, "rx": "00", "tx": "00"}"#, "two kinds"),
+ (r#"{"t": 1}"#, "no kind"),
+ (r#"{"rx": "00"}"#, "no time"),
+ (r#"{"t": 1, "rx": "a\"b"}"#, "an escape"),
+ (
+ r#"{"t": 2, "rx": ""}, {"t": 1, "rx": ""}"#,
+ "time going back",
+ ),
+ (
+ r#"{"t": 18446744073709551616, "rx": ""}"#,
+ "a time past 64 bits",
+ ),
+ (r#"{"t": 1, "rx": ""},"#, "a trailing comma"),
+ ] {
+ assert!(
+ Transcript::parse(with_steps(steps).as_bytes()).is_err(),
+ "accepted {why}"
+ );
+ }
+ }
+
+ #[test]
+ fn refuses_too_many_steps_and_too_much_text() {
+ let many = vec![r#"{"t": 0, "rx": ""}"#; MAX_STEPS + 1].join(",");
+ assert!(matches!(refused(&with_steps(&many)), Error::TooManySteps));
+
+ let big = vec![b' '; MAX_BYTES + 1];
+ assert!(matches!(Transcript::parse(&big), Err(Error::TooLarge)));
+ }
+
+ #[test]
+ fn refuses_a_truncated_text() {
+ // a text cut anywhere must be refused, never read as a shorter one
+ let text = with_steps(r#"{"t": 1, "rx": "00ff"}, {"t": 2, "close": ""}"#);
+ for n in 0..text.len() {
+ assert!(
+ Transcript::parse(text.as_bytes().get(..n).unwrap()).is_err(),
+ "accepted a text cut at {n}"
+ );
+ }
+ assert!(Transcript::parse(text.as_bytes()).is_ok());
+ }
+}
diff --git a/crates/npro-test/tests/vendored.rs b/crates/npro-test/tests/vendored.rs
new file mode 100644
index 0000000..2cd4b2d
--- /dev/null
+++ b/crates/npro-test/tests/vendored.rs
@@ -0,0 +1,53 @@
+//! Every transcript copied from C reads, and says what its README says.
+
+use npro_test::{Side, StepKind, vendored};
+
+#[test]
+fn every_vendored_transcript_reads() {
+ let all = vendored().unwrap();
+
+ // as many as the sync copied: a file that failed to read would have
+ // failed vendored() itself, so this catches a sync that copied none
+ let files = std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/transcripts"))
+ .unwrap()
+ .filter(|e| {
+ e.as_ref()
+ .unwrap()
+ .path()
+ .extension()
+ .is_some_and(|x| x == "json")
+ })
+ .count();
+ assert!(files > 0);
+ assert_eq!(all.len(), files);
+
+ for t in &all {
+ // the harness starts every run at the same times
+ assert_eq!(t.t0_us, 1_000_000_000, "{}", t.case);
+ assert_eq!(t.t0_wall, 1_767_225_600, "{}", t.case);
+
+ // only a client draws lws' random in what these record: its ws key
+ // and its masks
+ if t.seed.is_some() {
+ assert_eq!(t.side, Side::Client, "{} is seeded", t.case);
+ }
+
+ // a connection with no bytes either way records nothing
+ assert!(
+ t.steps
+ .iter()
+ .any(|s| matches!(s.kind, StepKind::Rx(_) | StepKind::Tx(_))),
+ "{} moves no bytes",
+ t.case
+ );
+ }
+}
+
+#[test]
+fn the_c_commit_is_recorded() {
+ let c = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/transcripts/C-COMMIT"))
+ .unwrap();
+ let hash = c.split_whitespace().next().unwrap();
+ assert_eq!(hash.len(), 40);
+ assert!(hash.bytes().all(|b| b.is_ascii_hexdigit()));
+}
diff --git a/crates/npro-test/transcripts/C-COMMIT b/crates/npro-test/transcripts/C-COMMIT
new file mode 100644
index 0000000..27ce8ae
--- /dev/null
+++ b/crates/npro-test/transcripts/C-COMMIT
@@ -0,0 +1 @@
+75b415f4afd5b542badbbf87b6ffec56f3cf3cfc api-test-ws-pmd-takeover: xorshift masks what each left shift would discard
diff --git a/crates/npro-test/transcripts/README.lws.md b/crates/npro-test/transcripts/README.lws.md
new file mode 100644
index 0000000..b5da82b
--- /dev/null
+++ b/crates/npro-test/transcripts/README.lws.md
@@ -0,0 +1,122 @@
+# lws sansIO transcripts
+
+Each file here is one connection's life as data: the times, the bytes its
+peer sent, the bytes lws wrote, what lws gave the application, and its
+close. `api-test-sansio` produces them by driving lws' protocol half with
+no socket and no clock of its own (READMEs/README.sans-io-split.md, "Time"),
+and checks, in every build that runs it, that what lws does now is still
+what is recorded here. They are also the specification a port of the
+sansIO half is checked against: fed the same bytes at the same times, with
+the same application, it must write the same bytes and deliver the same
+payloads.
+
+## Format
+
+```
+{
+ "format": "lws-transcript/1",
+ "case": "h1-ws-server",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 1000, "rx": "474554..."},
+ {"t": 1000, "tx": "485454..."},
+ ...
+ ]
+}
+```
+
+|member|meaning|
+|---|---|
+|`format`|`lws-transcript/1`. A change to what follows is a new version|
+|`case`|the connection's name, and the file's|
+|`side`|`server`: the peer connected to lws. `client`: lws connected to the peer|
+|`t0_us`|the monotonic time, in microseconds, when the run starts: every step's `t` is after it|
+|`t0_wall`|the wall time, seconds since 1970, at `t0_us`. The wall time moves with the monotonic time|
+|`seed`|0: nothing in the connection's bytes depends on lws' random. Otherwise lws' random is the xoshiro256** stream seeded with it (`lws_fi_random_seed()`), see "Random" below|
+|`steps`|what happened, in order|
+
+Each step has `t`, microseconds since `t0_us`, and one of:
+
+|kind|meaning|
+|---|---|
+|`rx`|the peer sent these bytes, and they are handed to lws at `t`|
+|`tx`|lws wrote these bytes to the peer at `t`. Bytes written in several writes at the same time are one step: how many writes it took is not part of the behaviour|
+|`app_rx`|lws delivered this payload to the application: a response body, or a ws message's payload. How a message or a body was split into deliveries follows how much lws was handed at a time, and is not behaviour: what a replay must match is the concatenation of the `app_rx` steps of each ws message, and of each response body, and where each of those ends relative to the other steps|
+|`close`|lws released the connection's transport. The value is empty|
+
+Bytes are lowercase hex. A replay hands lws each `rx` at its `t`, telling
+it the time first, and runs anything that falls due at a `t` before what
+happens then. The `tx`, `app_rx` and `close` steps are what it must see,
+in order, at their times.
+
+## Random
+
+A connection whose bytes depend on lws' random (a ws client's
+`Sec-WebSocket-Key` and its frame masks) has a nonzero `seed`, and the
+peer's side of it depends on those bytes too: the 101 response carries the
+accept value of the key the client chose. Such a transcript reproduces only
+when the random is the seeded stream drawn in the same order. The stream
+starts afresh from `seed` when the connection does, so what it draws does not
+depend on the connections before it, or on which of them a build has. So:
+
+ - `api-test-sansio` records and checks it only in a build with fault
+ injection (`LWS_WITH_SYS_FAULT_INJECTION`), which can seed lws' random,
+ and says so in other builds;
+ - a port either draws the same bytes from the same stream in the same order
+ (xoshiro256** seeded by splitmix64, each 64-bit result little-endian,
+ the vector in `api-test-random-prng`), or its replay treats the drawn
+ values as free, taking them from its own `tx` and checking what follows
+ from them (the accept value, the masking) instead of the bytes.
+
+The tls library's own random, inside its handshakes, is not lws' and is not
+covered: no transcript here is under tls yet.
+
+## The connections and their applications
+
+A replay needs the same application on lws' side. In each connection it
+does only this:
+
+|case|side|the application|
+|---|---|---|
+|`h1-ws-server`|server|one vhost with protocols `http` and `echo`. `http`: answers any request 200, `text/plain`, content length 10, body `sansio ok\n`, then completes the transaction (the connection is kept). `echo`: sends each ws message that came in one frame back, as text; it sends nothing for a fragmented one. The peer does an h1 GET, then a ws upgrade to `echo` on the same connection, then sends the masked text frame `Hello` of RFC 6455 5.7|
+|`h1-client-get`|client|connects to `sansio`, port 80, h1, `GET /x`, and pulls the response body as it comes|
+|`h1-client-cl-junk`, `h1-client-cl-twice`, `h1-client-te-list`|client|as `h1-client-get`. The peer answers 200 with `Content-Length: 10abc`; with `Content-Length: 10` and a second `Content-Length: 5`; or with `Transfer-Encoding: chunked` and a second `Transfer-Encoding: gzip`, which makes the codings a list. lws cannot know where such a body ends: it gives the app none of it, fails the connection and releases it|
+|`h1-client-head-chunked`, `h1-client-head-cl`, `h1-client-304-cl`|client|as `h1-client-get`, asking with `HEAD` for the first two. The peer answers 200 with `Transfer-Encoding: chunked`; 200 with `Content-Length: 10`; or, to the `GET`, 304 with `Content-Length: 10`. None of these has a body (RFC 9112 6.3): lws completes the transaction at the end of the headers and gives the app nothing|
+|`ws-client`|client|connects to `sansio`, port 80, `GET /echo`, ws subprotocol `echo`; once established, sends one text message `Hello`, and takes the messages it receives|
+|`ws-server-version-8`, `ws-server-no-version`, `ws-server-conn-no-upgrade`, `ws-server-no-subprotocol`|server|as `h1-ws-server`. The peer asks for a ws upgrade saying `Sec-WebSocket-Version: 8`; or no version at all; or `Connection: up`, which is not the `upgrade` token; or only the subprotocol `chat`, which the vhost does not have. lws answers the first with 426 and `sec-websocket-version: 13`, the others with 400, each with the usual status page, then shuts the connection down (a half-close, waiting for the peer's; no `close` step follows)|
+|`ws-client-rsv1-no-ext`, `ws-client-rsv2`, `ws-client-huge-frame`|client|as `ws-client`, no extension offered. After the 101, the peer sends a frame with RSV1, with RSV2, or whose length is 256MiB + 1: lws gives the app nothing of it and sends a close, status 1002 `rsv bits` for the RSV ones and 1009 `huge frame` for the length; the peer answers the close and lws releases the connection|
+|`ws-client-ping-close`|client|as `ws-client`, sending nothing once established. The peer sends a ping `p` and then a close with status 1000 in one read: lws sends the masked pong, then the masked answer to the close with the peer's 1000, and releases the connection|
+|`ws-client-interim`|client|as `ws-client`. The peer answers the upgrade with an interim `100 Continue`, then in the same read the 101: lws skips the interim response, goes on waiting, and is established by the 101|
+|`ws-client-pmd-rsv2`, `ws-client-pmd-rsv1-continuation`, `ws-client-pmd-rsv1-ping`|client|as `ws-client`, on a vhost offering `permessage-deflate` (no parameters), sending nothing once established, and the peer's 101 accepts `permessage-deflate`. The peer then sends a frame with RSV2; or an uncompressed, unfinished text frame `He` (given to the app) and then a continuation with RSV1; or a ping with RSV1. As above, lws fails the connection, with 1002 `rsv bits`|
+|`h1-uri-dotdot-args`, `h1-uri-dot-args`, `h1-uri-plus`, `h1-uri-at-limit`|server|a third vhost, `sansio-uri`, whose only protocol, `http`, answers any request 200, `text/plain`, with the path lws decoded, a newline, then the args, and completes the transaction. The context limits the request URI to 33 bytes and `User-Agent` to 16 (`token_limits`). The peer asks for `/x/..?a=b` (path `/`, args `a=b`), `/x/.?a=b` (path `/x/`), `/a+b?c+d=e+f` (path `/a+b`, args `c d=e f`: `+` is only a space in the args) and a 33-byte path, which is served whole|
+|`h1-uri-past-limit`, `h1-header-past-limit`|server|as above. The peer asks for a 34-byte path, or sends a 17-byte `User-Agent`: rather than serving the request with the value cut short, lws answers 414, or 431, with the usual status page saying `Oversized request URI` or `Oversized headers`, and shuts the connection down|
+|`h1-reqline-http09`, `h1-reqline-no-method`, `h1-reqline-version-2`, `h1-reqline-version-junk`, `h1-reqline-version-long`|server|as above. The peer sends a request line with no version, `GET /x` (HTTP/0.9); a head with no request line, only `Host: sansio-uri`; or asks for `/x` as `HTTP/2.0`, `HTTP/1.x` or `HTTP/1.10`. A request line is method, target and `HTTP/` digit `.` digit: lws answers 400 with the usual status page, except 505 for the major version it does not speak, and shuts the connection down|
+|`h1-reqline-unknown-method`, `h1-reqline-unknown-header-first`|server|as above. The peer asks for `/x` with the method `FOO`, which lws does not implement: 501 (RFC 9110 9.1); or starts its head with `X-Foo: bar`, a header lws does not know, rather than a request line: 400. Either way with the usual status page, and the connection is shut down|
+|`h1-reqline-leading-empty`, `h1-reqline-leading-empty-many`|server|as above. The peer sends two empty lines, then its request for `/x`: they are ignored (RFC 9112 2.2) and it is served; or nine: past eight, no request is coming, and lws answers 400 and shuts the connection down|
+|`h1-reqline-version-1-2`|server|as above. The peer asks for `/x` as `HTTP/1.2`: a later minor version is served as the highest lws speaks, and the response says `HTTP/1.1`|
+|`h2-oversized-headers`|server|a fourth vhost, `sansio-h2`, taking h2 with prior knowledge, whose only protocol is the `http` of `sansio-uri`. The peer sends the preface, an empty SETTINGS and the ack of the server's, then three GET / requests. sid 1: `:authority` entered in the dynamic table, then a 3000 byte user-agent and a 2000 byte referer, not indexed, that the 4096 byte ah cannot hold, then `accept: x` entered in the dynamic table after it filled: answered 431 `Oversized headers`, the connection kept. sid 3: `:authority` and `accept` from the dynamic table: `accept` could not be kept, so it is answered 431 too, rather than served as if the peer had not sent it. sid 5: only `:authority` from the dynamic table, which was kept: served|
+|`h1-404-keepalive`|server|a fifth vhost, `sansio-404`, whose 404 document is `/404.html`. `/cb` is mounted on its only protocol, `http`; everything else is a file mount on a directory that does not exist, falling back to `http` for what it cannot open. `http` answers whatever reaches it with `lws_return_http_status()` 404, then completes the transaction. On one kept-alive connection the peer asks for `/x`: lws redirects it to the 404 document with a 302; then for `/404.html`, which is not there either: the 404 status page; then for `/cb/y`: redirected with a 302 again, as `/x` was|
+|`ws-server-ping-close`, `ws-server-close-partial`|server|as `h1-ws-server`: after the upgrade, the peer sends, masked, in one read, a ping `p` and then a close with status 1000; or a text frame `Hello` and then the close, while the connection takes at most 4 bytes a write. lws answers the ping with its pong before it answers the close, with the peer's own 1000; the echo of `Hello`, left partly unwritten, goes out whole before the answer to the close, rather than the connection being dropped. After its answer lws reads nothing more, and shuts the connection down|
+|`ws-server-huge-frame`|server|as `h1-ws-server`: after the upgrade, the peer sends a masked frame whose length is 256MiB + 1. lws closes with status 1009, `huge frame`|
+|`ws-server-pmd-rsv1-continuation`|server|as `h1-ws-server`, on a second vhost, `sansio-pmd`, with `permessage-deflate` (no parameters). The peer asks for a ws upgrade to it offering `permessage-deflate`, which is accepted, then sends an unfinished text frame `He` and a continuation with RSV1: lws closes with status 1002, `rsv bits`, and once the peer answers the close, shuts the connection down|
+|`h2-ws-peer-close`|server|a sixth vhost, `sansio-h2ws`, taking h2 with prior knowledge, with the ws protocols of `sansio`. The peer sends the preface and SETTINGS, then an extended CONNECT (RFC 8441) for a ws stream on sid 1 speaking `echo`, which is accepted with 200; then one DATA frame holding, masked, a close with status 1000 and after it a ping `p`. lws answers the close with the peer's own 1000 and END_STREAM, reads nothing after it, so the ping gets no pong, and resets the stream with NO_ERROR, since the peer has not ended its side: not REFUSED_STREAM, which is only for a refused upgrade|
+|`h1-post-no-length`|server|as `h1-uri-*`, on `sansio-uri`. The peer sends, in one read, `POST /x?a=1` with neither `Content-Length` nor `Transfer-Encoding`, and behind its head `GET /y?b=2`. The POST has no body (RFC 9112 6.3): lws answers it (path `/x`, args `a=1`), then serves the GET as the next request on the kept-alive connection, rather than taking it as the POST's body and reading until the close|
+|`h1-connect-rejected-ua`|server|as `h1-reqline-*`, on `sansio-uri`, in a context that turns away a user agent containing `badbot` with `403 Go away`. The peer sends a CONNECT for `example.com:443` saying it is `badbot/1`: it is refused like any other request of its, 403 with the status page, and the connection shut down, rather than handed to the fallback role first|
+
+## Recording
+
+```
+ $ ./bin/lws-api-test-sansio --record ../minimal-examples-lowlevel/api-tests/api-test-sansio/transcripts
+```
+
+from a build with fault injection, so the seeded connections are recorded
+too. ctest runs `lws-api-test-sansio --transcripts transcripts` in this
+directory's parent, which fails if what lws does differs from what is here,
+and says at which line.
+
+A transcript is behaviour: a change to one is a change to what lws puts on
+the wire, and the commit making it says why. A new case adds a file here,
+its row above, and its connection's application to the test.
diff --git a/crates/npro-test/transcripts/README.md b/crates/npro-test/transcripts/README.md
new file mode 100644
index 0000000..f580618
--- /dev/null
+++ b/crates/npro-test/transcripts/README.md
@@ -0,0 +1,12 @@
+# Transcripts from C lws
+
+These files are copies of the transcripts C lws' `api-test-sansio` records
+and checks itself against
+(`minimal-examples-lowlevel/api-tests/api-test-sansio/transcripts` in the
+C tree). `C-COMMIT` names the C commit they were taken from.
+`README.lws.md` is that directory's README, which specifies the format and
+the application each connection needs on lws' side.
+
+They are behaviour, not fixtures. Do not edit them here: refresh them all
+from a C checkout with `scripts/sync-c-oracle.sh`. A commit that changes
+them names the C commit that changed them, and says why.
diff --git a/crates/npro-test/transcripts/h1-404-keepalive.json b/crates/npro-test/transcripts/h1-404-keepalive.json
new file mode 100644
index 0000000..71f8262
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-404-keepalive.json
@@ -0,0 +1,16 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-404-keepalive",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3601000, "rx": "474554202f7820485454502f312e310d0a486f73743a2073616e73696f2d3430340d0a0d0a"},
+ {"t": 3601000, "tx": "485454502f312e31203330322052656469726563740d0a6c6f636174696f6e3a202f3430342e68746d6c0d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a20300d0a0d0a"},
+ {"t": 3602000, "rx": "474554202f3430342e68746d6c20485454502f312e310d0a486f73743a2073616e73696f2d3430340d0a0d0a"},
+ {"t": 3602000, "tx": "485454502f312e3120343034204e6f7420466f756e640d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430343c2f68313e3c2f626f64793e3c2f68746d6c3e"},
+ {"t": 3603000, "rx": "474554202f63622f7920485454502f312e310d0a486f73743a2073616e73696f2d3430340d0a0d0a"},
+ {"t": 3603000, "tx": "485454502f312e31203330322052656469726563740d0a6c6f636174696f6e3a202f3430342e68746d6c0d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a20300d0a0d0a"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-client-304-cl.json b/crates/npro-test/transcripts/h1-client-304-cl.json
new file mode 100644
index 0000000..8f3691e
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-client-304-cl.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-client-304-cl",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 4195000, "tx": "474554202f7820485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a"},
+ {"t": 4196000, "rx": "485454502f312e3120333034204e6f74204d6f6469666965640d0a436f6e74656e742d4c656e6774683a2031300d0a0d0a"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-client-cl-junk.json b/crates/npro-test/transcripts/h1-client-cl-junk.json
new file mode 100644
index 0000000..1b97e65
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-client-cl-junk.json
@@ -0,0 +1,13 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-client-cl-junk",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3701000, "tx": "474554202f7820485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a"},
+ {"t": 3702000, "rx": "485454502f312e3120323030204f4b0d0a436f6e74656e742d4c656e6774683a2031306162630d0a0d0a73616e73696f206f6b0a"},
+ {"t": 3702000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-client-cl-twice.json b/crates/npro-test/transcripts/h1-client-cl-twice.json
new file mode 100644
index 0000000..08ddb3f
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-client-cl-twice.json
@@ -0,0 +1,13 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-client-cl-twice",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3703000, "tx": "474554202f7820485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a"},
+ {"t": 3704000, "rx": "485454502f312e3120323030204f4b0d0a436f6e74656e742d4c656e6774683a2031300d0a436f6e74656e742d4c656e6774683a20350d0a0d0a73616e73696f206f6b0a"},
+ {"t": 3704000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-client-get.json b/crates/npro-test/transcripts/h1-client-get.json
new file mode 100644
index 0000000..6a65d24
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-client-get.json
@@ -0,0 +1,13 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-client-get",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 4000, "tx": "474554202f7820485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a"},
+ {"t": 5000, "rx": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a20746578742f706c61696e0d0a436f6e74656e742d4c656e6774683a2031300d0a0d0a73616e73696f206f6b0a"},
+ {"t": 5000, "app_rx": "73616e73696f206f6b0a"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-client-head-chunked.json b/crates/npro-test/transcripts/h1-client-head-chunked.json
new file mode 100644
index 0000000..1c51c7c
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-client-head-chunked.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-client-head-chunked",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 4191000, "tx": "48454144202f7820485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a"},
+ {"t": 4192000, "rx": "485454502f312e3120323030204f4b0d0a5472616e736665722d456e636f64696e673a206368756e6b65640d0a0d0a"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-client-head-cl.json b/crates/npro-test/transcripts/h1-client-head-cl.json
new file mode 100644
index 0000000..7000ad7
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-client-head-cl.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-client-head-cl",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 4193000, "tx": "48454144202f7820485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a"},
+ {"t": 4194000, "rx": "485454502f312e3120323030204f4b0d0a436f6e74656e742d4c656e6774683a2031300d0a0d0a"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-client-te-list.json b/crates/npro-test/transcripts/h1-client-te-list.json
new file mode 100644
index 0000000..742136d
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-client-te-list.json
@@ -0,0 +1,13 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-client-te-list",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3705000, "tx": "474554202f7820485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a636f6e6e656374696f6e3a20636c6f73650d0a0d0a"},
+ {"t": 3706000, "rx": "485454502f312e3120323030204f4b0d0a5472616e736665722d456e636f64696e673a206368756e6b65640d0a5472616e736665722d456e636f64696e673a20677a69700d0a0d0a610d0a73616e73696f206f6b0a0d0a300d0a0d0a"},
+ {"t": 3706000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-connect-rejected-ua.json b/crates/npro-test/transcripts/h1-connect-rejected-ua.json
new file mode 100644
index 0000000..2397558
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-connect-rejected-ua.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-connect-rejected-ua",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 4101000, "rx": "434f4e4e454354206578616d706c652e636f6d3a34343320485454502f312e310d0a486f73743a206578616d706c652e636f6d3a3434330d0a557365722d4167656e743a20626164626f742f310d0a0d0a"},
+ {"t": 4101000, "tx": "485454502f312e312034303320466f7262696464656e0d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203138300d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430333c2f68313e476f20617761793c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-header-past-limit.json b/crates/npro-test/transcripts/h1-header-past-limit.json
new file mode 100644
index 0000000..393c917
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-header-past-limit.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-header-past-limit",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3106000, "rx": "474554202f20485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a557365722d4167656e743a2031323334353637383930313233343536370d0a0d0a"},
+ {"t": 3106000, "tx": "485454502f312e3120343331205265717565737420486561646572204669656c647320546f6f204c617267650d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203139300d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3433313c2f68313e4f76657273697a656420686561646572733c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-post-no-length.json b/crates/npro-test/transcripts/h1-post-no-length.json
new file mode 100644
index 0000000..0d8c5c4
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-post-no-length.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-post-no-length",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 200001000, "rx": "504f5354202f783f613d3120485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a474554202f793f623d3220485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 200001000, "tx": "485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a20746578742f706c61696e0d0a636f6e74656e742d6c656e6774683a20360d0a0d0a2f780a613d31485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a20746578742f706c61696e0d0a636f6e74656e742d6c656e6774683a20360d0a0d0a2f790a623d32"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-http09.json b/crates/npro-test/transcripts/h1-reqline-http09.json
new file mode 100644
index 0000000..d37ce3c
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-http09.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-http09",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3107000, "rx": "474554202f780d0a"},
+ {"t": 3107000, "tx": "485454502f312e31203430302042616420526571756573740d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430303c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-leading-empty-many.json b/crates/npro-test/transcripts/h1-reqline-leading-empty-many.json
new file mode 100644
index 0000000..b08ba95
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-leading-empty-many.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-leading-empty-many",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3116000, "rx": "0d0a0d0a0d0a0d0a0d0a0d0a0d0a0d0a0d0a474554202f7820485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3116000, "tx": "485454502f312e31203430302042616420526571756573740d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430303c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-leading-empty.json b/crates/npro-test/transcripts/h1-reqline-leading-empty.json
new file mode 100644
index 0000000..9853559
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-leading-empty.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-leading-empty",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3115000, "rx": "0d0a0d0a474554202f7820485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3115000, "tx": "485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a20746578742f706c61696e0d0a636f6e74656e742d6c656e6774683a20330d0a0d0a2f780a"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-no-method.json b/crates/npro-test/transcripts/h1-reqline-no-method.json
new file mode 100644
index 0000000..91c702f
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-no-method.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-no-method",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3108000, "rx": "486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3108000, "tx": "485454502f312e31203430302042616420526571756573740d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430303c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-unknown-header-first.json b/crates/npro-test/transcripts/h1-reqline-unknown-header-first.json
new file mode 100644
index 0000000..3c82b04
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-unknown-header-first.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-unknown-header-first",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3114000, "rx": "582d466f6f3a206261720d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3114000, "tx": "485454502f312e31203430302042616420526571756573740d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430303c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-unknown-method.json b/crates/npro-test/transcripts/h1-reqline-unknown-method.json
new file mode 100644
index 0000000..d9cc2ea
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-unknown-method.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-unknown-method",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3113000, "rx": "464f4f202f7820485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3113000, "tx": "485454502f312e3120353031204e6f7420496d706c656d656e7465640d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3530313c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-version-1-2.json b/crates/npro-test/transcripts/h1-reqline-version-1-2.json
new file mode 100644
index 0000000..5c51538
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-version-1-2.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-version-1-2",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3112000, "rx": "474554202f7820485454502f312e320d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3112000, "tx": "485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a20746578742f706c61696e0d0a636f6e74656e742d6c656e6774683a20330d0a0d0a2f780a"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-version-2.json b/crates/npro-test/transcripts/h1-reqline-version-2.json
new file mode 100644
index 0000000..a9578ca
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-version-2.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-version-2",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3109000, "rx": "474554202f7820485454502f322e300d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3109000, "tx": "485454502f312e312035303520485454502056657273696f6e204e6f7420537570706f727465640d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3530353c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-version-junk.json b/crates/npro-test/transcripts/h1-reqline-version-junk.json
new file mode 100644
index 0000000..b162843
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-version-junk.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-version-junk",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3110000, "rx": "474554202f7820485454502f312e780d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3110000, "tx": "485454502f312e31203430302042616420526571756573740d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430303c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-reqline-version-long.json b/crates/npro-test/transcripts/h1-reqline-version-long.json
new file mode 100644
index 0000000..5e6c7d4
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-reqline-version-long.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-reqline-version-long",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3111000, "rx": "474554202f7820485454502f312e31300d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3111000, "tx": "485454502f312e31203430302042616420526571756573740d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430303c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-uri-at-limit.json b/crates/npro-test/transcripts/h1-uri-at-limit.json
new file mode 100644
index 0000000..298f83f
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-uri-at-limit.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-uri-at-limit",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3104000, "rx": "474554202f323334353637383930313233343536373839303132333435363738393031323320485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3104000, "tx": "485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a20746578742f706c61696e0d0a636f6e74656e742d6c656e6774683a2033340d0a0d0a2f32333435363738393031323334353637383930313233343536373839303132330a"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-uri-dot-args.json b/crates/npro-test/transcripts/h1-uri-dot-args.json
new file mode 100644
index 0000000..be3bda4
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-uri-dot-args.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-uri-dot-args",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3102000, "rx": "474554202f782f2e3f613d6220485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3102000, "tx": "485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a20746578742f706c61696e0d0a636f6e74656e742d6c656e6774683a20370d0a0d0a2f782f0a613d62"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-uri-dotdot-args.json b/crates/npro-test/transcripts/h1-uri-dotdot-args.json
new file mode 100644
index 0000000..0c6bd0a
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-uri-dotdot-args.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-uri-dotdot-args",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3101000, "rx": "474554202f782f2e2e3f613d6220485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3101000, "tx": "485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a20746578742f706c61696e0d0a636f6e74656e742d6c656e6774683a20350d0a0d0a2f0a613d62"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-uri-past-limit.json b/crates/npro-test/transcripts/h1-uri-past-limit.json
new file mode 100644
index 0000000..b5d6f30
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-uri-past-limit.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-uri-past-limit",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3105000, "rx": "474554202f32333435363738393031323334353637383930313233343536373839303132333420485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3105000, "tx": "485454502f312e312034313420526571756573742055524920746f6f204c6f6e670d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203139340d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3431343c2f68313e4f76657273697a65642072657175657374205552493c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-uri-plus.json b/crates/npro-test/transcripts/h1-uri-plus.json
new file mode 100644
index 0000000..1349156
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-uri-plus.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-uri-plus",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3103000, "rx": "474554202f612b623f632b643d652b6620485454502f312e310d0a486f73743a2073616e73696f2d7572690d0a0d0a"},
+ {"t": 3103000, "tx": "485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a20746578742f706c61696e0d0a636f6e74656e742d6c656e6774683a2031320d0a0d0a2f612b620a6320643d652066"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h1-ws-server.json b/crates/npro-test/transcripts/h1-ws-server.json
new file mode 100644
index 0000000..908a85a
--- /dev/null
+++ b/crates/npro-test/transcripts/h1-ws-server.json
@@ -0,0 +1,17 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h1-ws-server",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 1000, "rx": "474554202f7820485454502f312e310d0a486f73743a2073616e73696f0d0a0d0a"},
+ {"t": 1000, "tx": "485454502f312e3120323030204f4b0d0a636f6e74656e742d747970653a20746578742f706c61696e0d0a636f6e74656e742d6c656e6774683a2031300d0a0d0a73616e73696f206f6b0a"},
+ {"t": 2000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 2000, "tx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20576562536f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4163636570743a20733370504c4d426954786151396b59477a7a685a52624b2b784f6f3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a0d0a"},
+ {"t": 3000, "rx": "818537fa213d7f9f4d5158"},
+ {"t": 3000, "app_rx": "48656c6c6f"},
+ {"t": 3000, "tx": "810548656c6c6f"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h2-oversized-headers.json b/crates/npro-test/transcripts/h2-oversized-headers.json
new file mode 100644
index 0000000..0b352f9
--- /dev/null
+++ b/crates/npro-test/transcripts/h2-oversized-headers.json
@@ -0,0 +1,18 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h2-oversized-headers",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3501000, "rx": "505249202a20485454502f322e300d0a0d0a534d0d0a0d0a000000040000000000000000040100000000"},
+ {"t": 3501000, "tx": "000024040000000000000100010000000200000000000300000018000400000000000600010000000800000001000000040100000000"},
+ {"t": 3502000, "rx": "0013a3010500000001828684410973616e73696f2d68320f2b7fb9167575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575757575750f247fd10e7272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272530178"},
+ {"t": 3502000, "tx": "000004080000000001000400000000040800000000000004000000003901040000000100073a73746174757303343331000c636f6e74656e742d7479706509746578742f68746d6c000e636f6e74656e742d6c656e677468033139300000be0001000000013c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3433313c2f68313e4f76657273697a656420686561646572733c2f626f64793e3c2f68746d6c3e"},
+ {"t": 3503000, "rx": "000005010500000003828684bfbe"},
+ {"t": 3503000, "tx": "000004080000000003000400000000040800000000000004000000003901040000000300073a73746174757303343331000c636f6e74656e742d7479706509746578742f68746d6c000e636f6e74656e742d6c656e677468033139300000be0001000000033c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3433313c2f68313e4f76657273697a656420686561646572733c2f626f64793e3c2f68746d6c3e"},
+ {"t": 3504000, "rx": "000004010500000005828684bf"},
+ {"t": 3504000, "tx": "000004080000000005000400000000040800000000000004000000003801040000000500073a73746174757303323030000c636f6e74656e742d747970650a746578742f706c61696e000e636f6e74656e742d6c656e67746801320000020001000000052f0a"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h2-ws-peer-close-skint.json b/crates/npro-test/transcripts/h2-ws-peer-close-skint.json
new file mode 100644
index 0000000..cc42a3c
--- /dev/null
+++ b/crates/npro-test/transcripts/h2-ws-peer-close-skint.json
@@ -0,0 +1,17 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h2-ws-peer-close-skint",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 4051000, "rx": "505249202a20485454502f322e300d0a0d0a534d0d0a0d0a000006040000000000000400000000000000040100000000"},
+ {"t": 4051000, "tx": "000024040000000000000100010000000200000000000300000018000400000000000600010000000800000001000000040100000000"},
+ {"t": 4052000, "rx": "00006a0104000000010207434f4e4e45435400093a70726f746f636f6c09776562736f636b65748604052f6563686f010b73616e73696f2d6832777300157365632d776562736f636b65742d76657273696f6e02313300167365632d776562736f636b65742d70726f746f636f6c046563686f"},
+ {"t": 4052000, "tx": "000004080000000001000400000000040800000000000004000000002a01040000000100073a7374617475730332303000167365632d776562736f636b65742d70726f746f636f6c046563686f"},
+ {"t": 4053000, "rx": "00000f00010000000188820000000003e889810000000070"},
+ {"t": 4054000, "rx": "00000408000000000100000064"},
+ {"t": 4054000, "tx": "000004000100000001880203e8"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/h2-ws-peer-close.json b/crates/npro-test/transcripts/h2-ws-peer-close.json
new file mode 100644
index 0000000..89bf8ef
--- /dev/null
+++ b/crates/npro-test/transcripts/h2-ws-peer-close.json
@@ -0,0 +1,16 @@
+{
+ "format": "lws-transcript/1",
+ "case": "h2-ws-peer-close",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 4001000, "rx": "505249202a20485454502f322e300d0a0d0a534d0d0a0d0a000000040000000000000000040100000000"},
+ {"t": 4001000, "tx": "000024040000000000000100010000000200000000000300000018000400000000000600010000000800000001000000040100000000"},
+ {"t": 4002000, "rx": "00006a0104000000010207434f4e4e45435400093a70726f746f636f6c09776562736f636b65748604052f6563686f010b73616e73696f2d6832777300157365632d776562736f636b65742d76657273696f6e02313300167365632d776562736f636b65742d70726f746f636f6c046563686f"},
+ {"t": 4002000, "tx": "000004080000000001000400000000040800000000000004000000002a01040000000100073a7374617475730332303000167365632d776562736f636b65742d70726f746f636f6c046563686f"},
+ {"t": 4003000, "rx": "00000f00000000000188820000000003e889810000000070"},
+ {"t": 4003000, "tx": "000004000100000001880203e800000403000000000100000000"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-client-huge-frame.json b/crates/npro-test/transcripts/ws-client-huge-frame.json
new file mode 100644
index 0000000..df4ba4c
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-client-huge-frame.json
@@ -0,0 +1,17 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-client-huge-frame",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 1,
+ "steps": [
+ {"t": 3209000, "tx": "474554202f6563686f20485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4b65793a204f766f6d7451704b4357556e5a5737744d52364771773d3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a0d0a"},
+ {"t": 3210000, "rx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4163636570743a207253734a662f5a4b516469756c304247494a3675514761776455383d0d0a0d0a"},
+ {"t": 3210000, "tx": "8185167398765e16f41a79"},
+ {"t": 3211000, "rx": "827f000000001000000148656c6c6f"},
+ {"t": 3211000, "tx": "888cbe4852b2bdb93ac7d92d72d4cc293fd7"},
+ {"t": 3212000, "rx": "880203e8"},
+ {"t": 3212000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-client-interim.json b/crates/npro-test/transcripts/ws-client-interim.json
new file mode 100644
index 0000000..8bc93ad
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-client-interim.json
@@ -0,0 +1,13 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-client-interim",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 1,
+ "steps": [
+ {"t": 4181000, "tx": "474554202f6563686f20485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4b65793a204f766f6d7451704b4357556e5a5737744d52364771773d3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a0d0a"},
+ {"t": 4182000, "rx": "485454502f312e312031303020436f6e74696e75650d0a0d0a485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4163636570743a207253734a662f5a4b516469756c304247494a3675514761776455383d0d0a0d0a"},
+ {"t": 4182000, "tx": "8185167398765e16f41a79"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-client-ping-close.json b/crates/npro-test/transcripts/ws-client-ping-close.json
new file mode 100644
index 0000000..ad906d8
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-client-ping-close.json
@@ -0,0 +1,15 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-client-ping-close",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 1,
+ "steps": [
+ {"t": 3901000, "tx": "474554202f6563686f20485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4b65793a204f766f6d7451704b4357556e5a5737744d52364771773d3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a0d0a"},
+ {"t": 3902000, "rx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4163636570743a207253734a662f5a4b516469756c304247494a3675514761776455383d0d0a0d0a"},
+ {"t": 3903000, "rx": "890170880203e8"},
+ {"t": 3903000, "tx": "8a8116739876668882be4852b2bda0"},
+ {"t": 3903000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-client-pmd-rsv1-continuation.json b/crates/npro-test/transcripts/ws-client-pmd-rsv1-continuation.json
new file mode 100644
index 0000000..b1bf001
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-client-pmd-rsv1-continuation.json
@@ -0,0 +1,17 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-client-pmd-rsv1-continuation",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 1,
+ "steps": [
+ {"t": 3405000, "tx": "474554202f6563686f20485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4b65793a204f766f6d7451704b4357556e5a5737744d52364771773d3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d457874656e73696f6e733a207065726d6573736167652d6465666c6174650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a0d0a"},
+ {"t": 3406000, "rx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4163636570743a207253734a662f5a4b516469756c304247494a3675514761776455383d0d0a5365632d576562536f636b65742d457874656e73696f6e733a207065726d6573736167652d6465666c6174650d0a0d0a"},
+ {"t": 3407000, "rx": "01024865c0036c6c6f"},
+ {"t": 3407000, "app_rx": "4865"},
+ {"t": 3407000, "tx": "888a167398761599ea056053fa1f6200"},
+ {"t": 3408000, "rx": "880203e8"},
+ {"t": 3408000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-client-pmd-rsv1-ping.json b/crates/npro-test/transcripts/ws-client-pmd-rsv1-ping.json
new file mode 100644
index 0000000..e541994
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-client-pmd-rsv1-ping.json
@@ -0,0 +1,16 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-client-pmd-rsv1-ping",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 1,
+ "steps": [
+ {"t": 3409000, "tx": "474554202f6563686f20485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4b65793a204f766f6d7451704b4357556e5a5737744d52364771773d3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d457874656e73696f6e733a207065726d6573736167652d6465666c6174650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a0d0a"},
+ {"t": 3410000, "rx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4163636570743a207253734a662f5a4b516469756c304247494a3675514761776455383d0d0a5365632d576562536f636b65742d457874656e73696f6e733a207065726d6573736167652d6465666c6174650d0a0d0a"},
+ {"t": 3411000, "rx": "c900"},
+ {"t": 3411000, "tx": "888a167398761599ea056053fa1f6200"},
+ {"t": 3412000, "rx": "880203e8"},
+ {"t": 3412000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-client-pmd-rsv2.json b/crates/npro-test/transcripts/ws-client-pmd-rsv2.json
new file mode 100644
index 0000000..5d0c4f3
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-client-pmd-rsv2.json
@@ -0,0 +1,16 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-client-pmd-rsv2",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 1,
+ "steps": [
+ {"t": 3401000, "tx": "474554202f6563686f20485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4b65793a204f766f6d7451704b4357556e5a5737744d52364771773d3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d457874656e73696f6e733a207065726d6573736167652d6465666c6174650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a0d0a"},
+ {"t": 3402000, "rx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4163636570743a207253734a662f5a4b516469756c304247494a3675514761776455383d0d0a5365632d576562536f636b65742d457874656e73696f6e733a207065726d6573736167652d6465666c6174650d0a0d0a"},
+ {"t": 3403000, "rx": "a10548656c6c6f"},
+ {"t": 3403000, "tx": "888a167398761599ea056053fa1f6200"},
+ {"t": 3404000, "rx": "880203e8"},
+ {"t": 3404000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-client-rsv1-no-ext.json b/crates/npro-test/transcripts/ws-client-rsv1-no-ext.json
new file mode 100644
index 0000000..93e53f0
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-client-rsv1-no-ext.json
@@ -0,0 +1,17 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-client-rsv1-no-ext",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 1,
+ "steps": [
+ {"t": 3201000, "tx": "474554202f6563686f20485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4b65793a204f766f6d7451704b4357556e5a5737744d52364771773d3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a0d0a"},
+ {"t": 3202000, "rx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4163636570743a207253734a662f5a4b516469756c304247494a3675514761776455383d0d0a0d0a"},
+ {"t": 3202000, "tx": "8185167398765e16f41a79"},
+ {"t": 3203000, "rx": "c10548656c6c6f"},
+ {"t": 3203000, "tx": "888abe4852b2bda220c1c86830dbca3b"},
+ {"t": 3204000, "rx": "880203e8"},
+ {"t": 3204000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-client-rsv2.json b/crates/npro-test/transcripts/ws-client-rsv2.json
new file mode 100644
index 0000000..5639067
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-client-rsv2.json
@@ -0,0 +1,17 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-client-rsv2",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 1,
+ "steps": [
+ {"t": 3205000, "tx": "474554202f6563686f20485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4b65793a204f766f6d7451704b4357556e5a5737744d52364771773d3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a0d0a"},
+ {"t": 3206000, "rx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4163636570743a207253734a662f5a4b516469756c304247494a3675514761776455383d0d0a0d0a"},
+ {"t": 3206000, "tx": "8185167398765e16f41a79"},
+ {"t": 3207000, "rx": "a10548656c6c6f"},
+ {"t": 3207000, "tx": "888abe4852b2bda220c1c86830dbca3b"},
+ {"t": 3208000, "rx": "880203e8"},
+ {"t": 3208000, "close": ""}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-client.json b/crates/npro-test/transcripts/ws-client.json
new file mode 100644
index 0000000..50f07f2
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-client.json
@@ -0,0 +1,15 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-client",
+ "side": "client",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 1,
+ "steps": [
+ {"t": 6000, "tx": "474554202f6563686f20485454502f312e310d0a507261676d613a206e6f2d63616368650d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a486f73743a2073616e73696f0d0a4f726967696e3a20687474703a2f2f73616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4b65793a204f766f6d7451704b4357556e5a5737744d52364771773d3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a0d0a"},
+ {"t": 7000, "rx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4163636570743a207253734a662f5a4b516469756c304247494a3675514761776455383d0d0a0d0a"},
+ {"t": 7000, "tx": "8185167398765e16f41a79"},
+ {"t": 8000, "rx": "810548656c6c6f"},
+ {"t": 8000, "app_rx": "48656c6c6f"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-server-close-partial.json b/crates/npro-test/transcripts/ws-server-close-partial.json
new file mode 100644
index 0000000..62135a9
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-server-close-partial.json
@@ -0,0 +1,15 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-server-close-partial",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3803000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 3803000, "tx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20576562536f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4163636570743a20733370504c4d426954786151396b59477a7a685a52624b2b784f6f3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a0d0a"},
+ {"t": 3804000, "rx": "81850000000048656c6c6f88820000000003e8"},
+ {"t": 3804000, "app_rx": "48656c6c6f"},
+ {"t": 3804000, "tx": "810548656c6c6f880203e8"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-server-close-when-flushed.json b/crates/npro-test/transcripts/ws-server-close-when-flushed.json
new file mode 100644
index 0000000..8bd0794
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-server-close-when-flushed.json
@@ -0,0 +1,15 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-server-close-when-flushed",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3805000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 3805000, "tx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20576562536f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4163636570743a20733370504c4d426954786151396b59477a7a685a52624b2b784f6f3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a0d0a"},
+ {"t": 3806000, "rx": "81830000000042796588820000000003e8"},
+ {"t": 3806000, "app_rx": "427965"},
+ {"t": 3806000, "tx": "8103427965"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-server-conn-no-upgrade.json b/crates/npro-test/transcripts/ws-server-conn-no-upgrade.json
new file mode 100644
index 0000000..a8dabda
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-server-conn-no-upgrade.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-server-conn-no-upgrade",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3003000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a2075700d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 3003000, "tx": "485454502f312e31203430302042616420526571756573740d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430303c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-server-huge-frame.json b/crates/npro-test/transcripts/ws-server-huge-frame.json
new file mode 100644
index 0000000..e7aaf76
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-server-huge-frame.json
@@ -0,0 +1,14 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-server-huge-frame",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3151000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 3151000, "tx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20576562536f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4163636570743a20733370504c4d426954786151396b59477a7a685a52624b2b784f6f3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a0d0a"},
+ {"t": 3152000, "rx": "82ff000000001000000100000000"},
+ {"t": 3152000, "tx": "880c03f168756765206672616d65"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-server-no-subprotocol.json b/crates/npro-test/transcripts/ws-server-no-subprotocol.json
new file mode 100644
index 0000000..52aa4d4
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-server-no-subprotocol.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-server-no-subprotocol",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3004000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a5365632d576562536f636b65742d50726f746f636f6c3a20636861740d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 3004000, "tx": "485454502f312e31203430302042616420526571756573740d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430303c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-server-no-version.json b/crates/npro-test/transcripts/ws-server-no-version.json
new file mode 100644
index 0000000..5c00cfc
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-server-no-version.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-server-no-version",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3002000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 3002000, "tx": "485454502f312e31203430302042616420526571756573740d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3430303c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-server-ping-close.json b/crates/npro-test/transcripts/ws-server-ping-close.json
new file mode 100644
index 0000000..4c26e4b
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-server-ping-close.json
@@ -0,0 +1,14 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-server-ping-close",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3801000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 3801000, "tx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20576562536f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4163636570743a20733370504c4d426954786151396b59477a7a685a52624b2b784f6f3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a0d0a"},
+ {"t": 3802000, "rx": "8981000000007088820000000003e8"},
+ {"t": 3802000, "tx": "8a0170880203e8"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-server-pmd-rsv1-continuation.json b/crates/npro-test/transcripts/ws-server-pmd-rsv1-continuation.json
new file mode 100644
index 0000000..669f686
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-server-pmd-rsv1-continuation.json
@@ -0,0 +1,16 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-server-pmd-rsv1-continuation",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3301000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f2d706d640d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d457874656e73696f6e733a207065726d6573736167652d6465666c6174650d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 3301000, "tx": "485454502f312e312031303120537769746368696e672050726f746f636f6c730d0a557067726164653a20576562536f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d4163636570743a20733370504c4d426954786151396b59477a7a685a52624b2b784f6f3d0d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a7365632d776562736f636b65742d657874656e73696f6e733a207065726d6573736167652d6465666c6174650d0a0d0a"},
+ {"t": 3302000, "rx": "0182000000004865c083000000006c6c6f"},
+ {"t": 3302000, "app_rx": "4865"},
+ {"t": 3302000, "tx": "880a03ea7273762062697473"},
+ {"t": 3303000, "rx": "88820000000003ea"}
+ ]
+}
diff --git a/crates/npro-test/transcripts/ws-server-version-8.json b/crates/npro-test/transcripts/ws-server-version-8.json
new file mode 100644
index 0000000..7896354
--- /dev/null
+++ b/crates/npro-test/transcripts/ws-server-version-8.json
@@ -0,0 +1,12 @@
+{
+ "format": "lws-transcript/1",
+ "case": "ws-server-version-8",
+ "side": "server",
+ "t0_us": 1000000000,
+ "t0_wall": 1767225600,
+ "seed": 0,
+ "steps": [
+ {"t": 3001000, "rx": "474554202f6563686f20485454502f312e310d0a486f73743a2073616e73696f0d0a557067726164653a20776562736f636b65740d0a436f6e6e656374696f6e3a20557067726164650d0a5365632d576562536f636b65742d56657273696f6e3a20380d0a5365632d576562536f636b65742d50726f746f636f6c3a206563686f0d0a5365632d576562536f636b65742d4b65793a206447686c49484e68625842735a5342756232356a5a513d3d0d0a0d0a"},
+ {"t": 3001000, "tx": "485454502f312e312034323620557067726164652052657175697265640d0a636f6e74656e742d747970653a20746578742f68746d6c0d0a7365632d776562736f636b65742d76657273696f6e3a2031330d0a636f6e74656e742d6c656e6774683a203137330d0a0d0a3c68746d6c3e3c686561643e3c6d65746120636861727365743d7574662d3820687474702d65717569763d22436f6e74656e742d4c616e67756167652220636f6e74656e743d22656e222f3e3c6c696e6b2072656c3d227374796c6573686565742220747970653d22746578742f6373732220687265663d222f6572726f722e637373222f3e3c2f686561643e3c626f64793e3c68313e3432363c2f68313e3c2f626f64793e3c2f68746d6c3e"}
+ ]
+}
diff --git a/scripts/sync-c-oracle.sh b/scripts/sync-c-oracle.sh
new file mode 100755
index 0000000..ea12256
--- /dev/null
+++ b/scripts/sync-c-oracle.sh
@@ -0,0 +1,35 @@
+#!/bin/sh
+#
+# Copy what npro is checked against from a C lws checkout:
+#
+# scripts/sync-c-oracle.sh /path/to/libwebsockets
+#
+# The api-test-sansio transcripts and their README go to
+# crates/npro-test/transcripts/, replacing what is there, and the C commit
+# they came from is recorded in C-COMMIT beside them. The checkout should be
+# clean: a transcript is behaviour, and the commit that changes one here
+# names the C commit that changed it there.
+
+set -eu
+
+if [ $# -ne 1 ] || [ ! -d "$1/minimal-examples-lowlevel/api-tests/api-test-sansio" ]; then
+ echo "usage: $0 <libwebsockets checkout>" >&2
+ exit 1
+fi
+
+c="$1"
+here="$(cd "$(dirname "$0")/.." && pwd)"
+src="$c/minimal-examples-lowlevel/api-tests/api-test-sansio/transcripts"
+dst="$here/crates/npro-test/transcripts"
+
+if [ -n "$(git -C "$c" status --porcelain -- "$src")" ]; then
+ echo "$src has uncommitted changes" >&2
+ exit 1
+fi
+
+rm -f "$dst"/*.json
+cp "$src"/*.json "$dst/"
+cp "$src/README.md" "$dst/README.lws.md"
+git -C "$c" log -1 --format='%H %s' > "$dst/C-COMMIT"
+
+echo "$(ls "$dst"/*.json | wc -l) transcripts from $(cut -c1-12 "$dst/C-COMMIT")"
|